Skip to main content

netget/server/npm/
mod.rs

1//! NPM registry server implementation
2//!
3//! NPM registry runs over HTTP. The LLM controls package metadata, tarballs,
4//! listings, and search results.
5
6pub mod actions;
7
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use base64::Engine;
13use bytes::Bytes;
14use http_body_util::Full;
15use hyper::body::Incoming;
16use hyper::server::conn::http1;
17use hyper::service::service_fn;
18use hyper::{Method, Request, Response, StatusCode};
19use hyper_util::rt::TokioIo;
20use serde_json::json;
21use tokio::sync::mpsc;
22use tracing::{error, trace};
23
24use crate::llm::action_helper::call_llm;
25use crate::llm::ollama_client::OllamaClient;
26use crate::logging::emit::Log;
27use crate::protocol::EventType;
28use crate::server::accept_bounded::{
29    accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
30};
31use crate::server::connection::ConnectionId;
32use crate::server::npm::actions::NpmProtocol;
33use crate::state::app_state::AppState;
34
35/// How long a peer that has produced nothing at all may hold this connection.
36///
37/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
38/// registry client sends it inside its dial path, so a peer that has connected and sent no byte
39/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
40/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
41///
42/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
43/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
44/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
45/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
46/// without consuming it, so the request line is still there for hyper afterwards.
47const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
48
49/// How long an established connection may sit **silent** between requests.
50///
51/// This bounds the silence and never a transfer. A request being answered holds
52/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
53/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
54/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
55/// ever runs on a connection with nothing in flight.
56///
57/// Five minutes, because that is npm's own `fetch-timeout` default (300000 ms, confirmed with
58/// `npm config get fetch-timeout`): the client itself gives up on a server that has said nothing
59/// for this long, so closing a connection that has been silent for as long can never be the side
60/// that breaks a working `npm install`. Closing an idle persistent connection is ordinary HTTP -
61/// RFC 9112 requires a client to cope with it, which is why nginx ships `keepalive_timeout 75s`,
62/// a quarter of this. The tarball fetch itself is unaffected: `npm install` downloads, then
63/// unpacks locally, and only the gap between two requests is measured here.
64const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
65
66/// Concurrent connections this server admits.
67///
68/// Each admitted connection may hold one whole in-memory response body - a packument or a
69/// tarball - so the cap is what turns that per-connection bound into a total one. 256 is an
70/// order of magnitude above the most parallel client this server faces (npm's own `maxsockets`
71/// default is 15) and far below what socket exhaustion needs.
72const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
73
74/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
75///
76/// `503 Service Unavailable` with `Retry-After`, in NPM's own vocabulary:
77/// npm retries a 503 with backoff (`fetch-retries`, 2 by default)
78/// rather than reporting a hard failure, and a human with `curl` reads it directly.
79/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
80/// reason, under `decision=fail_closed_connection_cap`.
81const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
82    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
83
84/// NPM registry server that delegates to LLM
85pub struct NpmServer;
86
87impl NpmServer {
88    /// Spawn the NPM registry server with integrated LLM actions
89    pub async fn spawn_with_llm_actions(
90        listen_addr: SocketAddr,
91        llm_client: OllamaClient,
92        app_state: Arc<AppState>,
93        status_tx: mpsc::UnboundedSender<String>,
94        server_id: crate::state::ServerId,
95    ) -> anyhow::Result<SocketAddr> {
96        let listener =
97            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
98        let local_addr = listener.local_addr()?;
99        Log::new(Some(&status_tx)).info(format!("NPM registry server listening on {}", local_addr));
100
101        let protocol = Arc::new(NpmProtocol::new());
102
103        // Spawn server loop
104        let task_registrar = app_state.clone();
105        let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
106        let accept_handle = tokio::spawn(async move {
107            loop {
108                match accept_bounded(
109                    &listener,
110                    &limiter,
111                    CONNECTION_CAP_REFUSAL,
112                    "NPM",
113                    Some(&status_tx),
114                )
115                .await
116                {
117                    Ok((stream, remote_addr, permit)) => {
118                        let connection_id =
119                            ConnectionId::new(app_state.get_next_unified_id().await);
120                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
121                        Log::new(Some(&status_tx)).info(format!(
122                            "NPM connection {} from {}",
123                            connection_id, remote_addr
124                        ));
125
126                        // Add connection to ServerInstance
127                        use crate::state::server::{
128                            ConnectionState as ServerConnectionState, ConnectionStatus,
129                            ProtocolConnectionInfo,
130                        };
131                        let now = crate::utils::clock::Instant::now();
132                        let conn_state = ServerConnectionState {
133                            id: connection_id,
134                            remote_addr,
135                            local_addr: local_addr_conn,
136                            bytes_sent: 0,
137                            bytes_received: 0,
138                            packets_sent: 0,
139                            packets_received: 0,
140                            last_activity: now,
141                            status: ConnectionStatus::Active,
142                            status_changed_at: now,
143                            protocol_info: ProtocolConnectionInfo::empty(),
144                        };
145                        app_state
146                            .add_connection_to_server(server_id, conn_state)
147                            .await;
148                        let _ = status_tx.send("__UPDATE_UI__".to_string());
149
150                        let llm_client_clone = llm_client.clone();
151                        let app_state_clone = app_state.clone();
152                        let status_tx_clone = status_tx.clone();
153                        let protocol_clone = protocol.clone();
154
155                        // Spawn a task to handle this connection
156                        // Tracked, not detached: stop_server must abort this task too.
157                        let task_owner = app_state.clone();
158                        task_owner
159                            .spawn_server_task(server_id, async move {
160                                // Held for the life of the connection, so the
161                                // cap counts live clients, not accepts.
162                                let _permit = permit;
163
164                                // The first-byte bound, before hyper sees the
165                                // socket. `peek` waits for data without
166                                // consuming it, so the request line is still
167                                // there for hyper afterwards, and it bounds
168                                // exactly the case that needs bounding: a peer
169                                // that connected and said nothing at all.
170                                let spoke = matches!(
171                                    tokio::time::timeout(
172                                        FIRST_BYTE_READ_TIMEOUT,
173                                        stream.peek(&mut [0u8; 1]),
174                                    )
175                                    .await,
176                                    Ok(Ok(n)) if n > 0
177                                );
178
179                                if spoke {
180                                    let io = TokioIo::new(stream);
181
182                                    // Clone for service closure
183                                    let status_for_service = status_tx_clone.clone();
184                                    let app_state_for_service = app_state_clone.clone();
185
186                                    // Whether this connection is answering
187                                    // anything. The watchdog below reads it, so
188                                    // only genuine silence - never work in
189                                    // flight - can close a connection.
190                                    let activity = Arc::new(ConnectionActivity::new());
191                                    let activity_for_service = Arc::clone(&activity);
192                                    // Create a service that handles NPM registry requests with LLM
193                                    let service = service_fn(move |req: Request<Incoming>| {
194                                        let llm_clone = llm_client_clone.clone();
195                                        let state_clone = app_state_for_service.clone();
196                                        let status_clone = status_for_service.clone();
197                                        let protocol_clone = protocol_clone.clone();
198                                        // Busy for the whole of this request - the
199                                        // model round-trip included, and a `manual`
200                                        // rule parked for a human - so the idle
201                                        // watchdog can never close the connection an
202                                        // answer belongs to.
203                                        let activity = Arc::clone(&activity_for_service);
204                                        async move {
205                                            let _busy = activity.busy();
206                                            handle_npm_request(
207                                                req,
208                                                connection_id,
209                                                llm_clone,
210                                                state_clone,
211                                                status_clone,
212                                                protocol_clone,
213                                                server_id,
214                                            )
215                                            .await
216                                        }
217                                    });
218
219                                    // Serve HTTP/1 on this connection
220                                    let conn = http1::Builder::new().serve_connection(io, service);
221                                    tokio::pin!(conn);
222                                    tokio::select! {
223                                        result = &mut conn => {
224                                            if let Err(err) = result {
225                                                error!("Error serving NPM connection: {:?}", err);
226                                            }
227                                        }
228                                        // The idle bound, over `ConnectionActivity`
229                                        // rather than over a read: hyper owns every
230                                        // read once `serve_connection` starts, and
231                                        // keeps polling for frames while a request is
232                                        // being answered, so a deadline on those reads
233                                        // would fire mid-answer. A connection with work
234                                        // in flight is not idle at all.
235                                        _ = watch_idle(
236                                            Arc::clone(&activity),
237                                            IDLE_BETWEEN_REQUESTS_TIMEOUT,
238                                        ) => {
239                                            Log::new(Some(&status_tx_clone)).debug(format!(
240                                                "NPM connection {} idle for {}s; closing",
241                                                connection_id,
242                                                IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
243                                            ));
244                                        }
245                                    }
246                                } else {
247                                    Log::new(Some(&status_tx_clone)).debug(format!(
248                                        "NPM peer {} sent no request within {}s; closing \
249                                         decision=fail_closed_first_byte_timeout",
250                                        remote_addr,
251                                        FIRST_BYTE_READ_TIMEOUT.as_secs()
252                                    ));
253                                }
254
255                                // Mark connection as closed
256                                app_state_clone
257                                    .close_connection_on_server(server_id, connection_id)
258                                    .await;
259                                Log::new(Some(&status_tx_clone))
260                                    .info(format!("NPM connection {} closed", connection_id));
261                                let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
262                            })
263                            .await;
264                    }
265                    Err(e) => {
266                        Log::new(Some(&status_tx))
267                            .error(format!("Failed to accept NPM connection: {}", e));
268                        break;
269                    }
270                }
271            }
272        });
273
274        // Register the accept loop so stop_server can abort it and release the port.
275        task_registrar
276            .register_server_task(server_id, accept_handle)
277            .await;
278
279        Ok(local_addr)
280    }
281}
282
283/// Approximate the bytes this request cost on the wire.
284///
285/// hyper hands us a parsed `Request`, so the original head is gone; this
286/// reconstructs its size from the parts that survive. It is an estimate, and
287/// deliberately so — the alternative is a `↓` counter frozen at 0, which reads as
288/// "this peer sent nothing" rather than "we did not measure".
289fn approximate_request_bytes(req: &Request<Incoming>) -> u64 {
290    use hyper::body::Body;
291    let head: usize = req.method().as_str().len()
292        + req.uri().to_string().len()
293        + 12 // " HTTP/1.1\r\n" plus the blank line terminating the head
294        + req
295            .headers()
296            .iter()
297            .map(|(name, value)| name.as_str().len() + value.len() + 4)
298            .sum::<usize>();
299    head as u64 + req.body().size_hint().lower()
300}
301
302/// Record one request/response exchange against the connection's counters, then
303/// return the response unchanged.
304///
305/// `update_connection_stats` is what the dashboard rail's `↓ ↑` columns and the
306/// connection-scoped task prompts read, and it is what keeps `last_activity`
307/// moving. Without it a busy NPM server draws every peer as idle with zero
308/// bytes in both directions — which is exactly what the rail is for.
309#[allow(clippy::too_many_arguments)]
310async fn handle_npm_request(
311    req: Request<Incoming>,
312    connection_id: ConnectionId,
313    llm_client: OllamaClient,
314    app_state: Arc<AppState>,
315    status_tx: mpsc::UnboundedSender<String>,
316    protocol: Arc<NpmProtocol>,
317    server_id: crate::state::ServerId,
318) -> Result<Response<Full<Bytes>>, Infallible> {
319    let bytes_received = approximate_request_bytes(&req);
320    let response = handle_npm_request_inner(
321        req,
322        connection_id,
323        llm_client,
324        app_state.clone(),
325        status_tx,
326        protocol,
327        server_id,
328    )
329    .await;
330
331    let bytes_sent = response
332        .as_ref()
333        .ok()
334        .and_then(|resp| {
335            use hyper::body::Body;
336            resp.body().size_hint().exact()
337        })
338        .unwrap_or(0);
339    app_state
340        .update_connection_stats(
341            server_id,
342            connection_id,
343            Some(bytes_received),
344            Some(bytes_sent),
345            Some(1),
346            Some(1),
347        )
348        .await;
349
350    response
351}
352
353/// Handle a single NPM registry request
354async fn handle_npm_request_inner(
355    req: Request<Incoming>,
356    _connection_id: ConnectionId,
357    llm_client: OllamaClient,
358    app_state: Arc<AppState>,
359    status_tx: mpsc::UnboundedSender<String>,
360    protocol: Arc<NpmProtocol>,
361    server_id: crate::state::ServerId,
362) -> Result<Response<Full<Bytes>>, Infallible> {
363    let method = req.method().clone();
364    let uri = req.uri().clone();
365    let path = uri.path();
366    let query = uri.query().unwrap_or("");
367
368    Log::new(Some(&status_tx)).debug(format!("NPM request: {} {}", method, path));
369
370    // Only handle GET requests
371    if method != Method::GET {
372        // Refused by this server before any model call: no event is raised, so this is
373        // neither the model's answer nor a backend failure and carries its own token.
374        Log::new(Some(&status_tx)).warn(format!(
375            "NPM {} {} decision=protocol_error (only GET is served)",
376            method, path
377        ));
378        let response = json!({
379            "error": "Method not allowed"
380        });
381        return Ok(Response::builder()
382            .status(StatusCode::METHOD_NOT_ALLOWED)
383            .header("Content-Type", "application/json")
384            .body(Full::new(Bytes::from(response.to_string())))
385            .unwrap());
386    }
387
388    // Route the request
389    let (event_type, description) = if path == "/-/all" {
390        ("NPM_LIST_REQUEST", "NPM package list request".to_string())
391    } else if path.starts_with("/-/v1/search") {
392        (
393            "NPM_SEARCH_REQUEST",
394            format!("NPM package search: {}", query),
395        )
396    } else if path.contains("/-/") {
397        // Tarball request: /{package}/-/{tarball}.tgz
398        let parts: Vec<&str> = path.split("/-/").collect();
399        let package_name = parts.get(0).unwrap_or(&"").trim_start_matches('/');
400        let tarball_name = parts.get(1).unwrap_or(&"");
401        (
402            "NPM_TARBALL_REQUEST",
403            format!(
404                "NPM tarball request: package={}, tarball={}",
405                package_name, tarball_name
406            ),
407        )
408    } else {
409        // Package metadata request: /{package}
410        let package_name = path.trim_start_matches('/');
411        (
412            "NPM_PACKAGE_REQUEST",
413            format!("NPM package metadata request: {}", package_name),
414        )
415    };
416
417    trace!("NPM event: {}: {}", event_type, &description);
418
419    // Verify server exists
420    if app_state.get_instruction(server_id).await.is_none() {
421        // Nothing to ask: the server row is gone, so the request is failed rather than
422        // answered from a default. `fail_closed_server_missing` is npm-specific because it
423        // is not a model outcome at all — it is netget's own state having disappeared.
424        Log::new(Some(&status_tx)).error(format!(
425            "NPM {} {} decision=fail_closed_server_missing (server {} not found)",
426            method, path, server_id
427        ));
428        let response = json!({
429            "error": "Server not found"
430        });
431        return Ok(Response::builder()
432            .status(StatusCode::INTERNAL_SERVER_ERROR)
433            .header("Content-Type", "application/json")
434            .body(Full::new(Bytes::from(response.to_string())))
435            .unwrap());
436    }
437
438    // Build NPM event - use the static event type references
439    let event_type_static: &'static EventType = match &event_type[..] {
440        "NPM_PACKAGE_REQUEST" => &actions::NPM_PACKAGE_REQUEST,
441        "NPM_TARBALL_REQUEST" => &actions::NPM_TARBALL_REQUEST,
442        "NPM_LIST_REQUEST" => &actions::NPM_LIST_REQUEST,
443        "NPM_SEARCH_REQUEST" => &actions::NPM_SEARCH_REQUEST,
444        _ => {
445            error!(
446                "Unknown NPM event type: {} decision=protocol_error",
447                event_type
448            );
449            let error_response = json!({
450                "error": format!("Internal error: unknown event type '{}'", event_type)
451            });
452            return Ok(Response::builder()
453                .status(StatusCode::INTERNAL_SERVER_ERROR)
454                .header("Content-Type", "application/json")
455                .body(Full::new(Bytes::from(error_response.to_string())))
456                .unwrap());
457        }
458    };
459
460    let event = crate::protocol::Event::new(
461        event_type_static,
462        json!({
463            "method": method.as_str(),
464            "path": path,
465            "query": query,
466            "description": description,
467        }),
468    );
469
470    Log::new(Some(&status_tx)).debug(format!("Calling LLM for NPM request: {} {}", method, path));
471
472    // Call LLM
473    let llm_result = call_llm(
474        &llm_client,
475        &app_state,
476        server_id,
477        None,
478        &event,
479        protocol.as_ref(),
480    )
481    .await;
482
483    // Process LLM result
484    match llm_result {
485        Ok(execution_result) => {
486            // Scan for the first action that is actually an NPM response. This was a
487            // `for` loop with an unconditional `return` inside it, so it examined only
488            // the first result and returned a 500 if that happened to be something
489            // like `show_message`. It also tripped clippy's `never_loop`.
490            let failures = execution_result.failures.len();
491            for result in execution_result.protocol_results {
492                if let Some(response) = process_npm_action_result(result, &status_tx).await {
493                    // `npm_error` is the model deliberately refusing this package — a real
494                    // answer, and a different thing from netget having failed to get one.
495                    // Anything 2xx/3xx is the model having answered.
496                    let decision = if response.status().is_client_error()
497                        || response.status().is_server_error()
498                    {
499                        "model_reject"
500                    } else {
501                        "model_answer"
502                    };
503                    Log::new(Some(&status_tx)).info(format!(
504                        "NPM {} {} -> {} decision={}",
505                        method,
506                        path,
507                        response.status().as_u16(),
508                        decision
509                    ));
510                    return Ok(response);
511                }
512            }
513
514            // Nothing usable came back. npm acts on a 200 — it unpacks whatever body it is
515            // given — so a synthesised or empty packument here would be worse than an error:
516            // this path must never produce one. The model answering with nothing and the
517            // model answering with something the executor refused end in the same 500, so
518            // the token is the only place they differ.
519            let msg = format!(
520                "NPM {} {} -> 500 decision={} ({} failed action(s))",
521                method,
522                path,
523                if failures == 0 {
524                    "model_silent"
525                } else {
526                    "fail_closed_bad_action"
527                },
528                failures
529            );
530            if failures == 0 {
531                Log::new(Some(&status_tx)).warn(msg);
532            } else {
533                Log::new(Some(&status_tx)).error(msg);
534            }
535
536            // No NPM actions found, return error
537            let error_response = json!({
538                "error": "No NPM action returned"
539            });
540            Ok(Response::builder()
541                .status(StatusCode::INTERNAL_SERVER_ERROR)
542                .header("Content-Type", "application/json")
543                .body(Full::new(Bytes::from(error_response.to_string())))
544                .unwrap())
545        }
546        Err(e) => {
547            // Non-fatal: a wire fallback (JSON error response) is still delivered and the
548            // HTTP connection continues.
549            // A 500 and never a 200: npm treats a 200 as a real packument or tarball and
550            // acts on it, so a backend outage must not be able to synthesise one. The peer
551            // gets the category; the error goes only to the log, next to the token.
552            let failure = crate::utils::WireFailure::classify(&e);
553            Log::new(Some(&status_tx)).error(format!(
554                "NPM {} {} -> 500 decision={}: {}",
555                method,
556                path,
557                if failure.is_overloaded() {
558                    "fail_closed_llm_overloaded"
559                } else {
560                    "fail_closed_llm_error"
561                },
562                e
563            ));
564            let error_response = json!({
565                "error": failure.text()
566            });
567            Ok(Response::builder()
568                .status(StatusCode::INTERNAL_SERVER_ERROR)
569                .header("Content-Type", "application/json")
570                .body(Full::new(Bytes::from(error_response.to_string())))
571                .unwrap())
572        }
573    }
574}
575
576/// A 500 for an answer the model gave that this server cannot turn into a response.
577///
578/// Every branch below used to `.unwrap()` on the field it needed. A missing field is
579/// the model's mistake, and `.unwrap()` inside a per-connection tokio task is the
580/// worst possible response to it: the panic is swallowed, the server keeps reporting
581/// `Running`, and the client hangs until its own timeout with nothing in the log
582/// connecting the two. An explicit 500 tells the client *and* the operator.
583///
584/// `reason` names action fields and this server's own expectations of them, so it goes
585/// to the operator only — the peer gets a category. See `crate::utils::wire_failure`.
586fn npm_server_error(
587    status_tx: &mpsc::UnboundedSender<String>,
588    reason: &str,
589) -> Response<Full<Bytes>> {
590    Log::new(Some(status_tx)).error(format!(
591        "NPM -> 500 decision=fail_closed_bad_action: could not build a response — {}",
592        reason
593    ));
594    Response::builder()
595        .status(StatusCode::INTERNAL_SERVER_ERROR)
596        .header("Content-Type", "application/json")
597        .body(Full::new(Bytes::from(
598            json!({ "error": crate::utils::WireFailure::Unavailable.prefixed_text() }).to_string(),
599        )))
600        .unwrap()
601}
602
603/// Process LLM action result and build HTTP response
604/// Build an NPM response from one action result.
605///
606/// Returns `None` when the action is not an NPM response action, so the caller can
607/// keep scanning. It used to return a 500 for anything it did not recognise, and the
608/// caller returned unconditionally on the first result — so a model emitting the
609/// documented `show_message` + NPM-response pair got a 500 and lost the real answer.
610async fn process_npm_action_result(
611    action_result: crate::llm::ActionResult,
612    status_tx: &mpsc::UnboundedSender<String>,
613) -> Option<Response<Full<Bytes>>> {
614    use crate::llm::ActionResult;
615
616    match action_result {
617        ActionResult::Custom { name, data } => {
618            match name.as_str() {
619                "npm_package_metadata" => {
620                    // `.unwrap()` here used to panic the connection task, which
621                    // tokio::spawn swallows: the server kept reporting Running while
622                    // the client hung forever. A missing field is the model's mistake,
623                    // not ours — answer it.
624                    let Some(metadata) = data.get("metadata") else {
625                        return Some(npm_server_error(
626                            status_tx,
627                            "npm_package_metadata carried no 'metadata' field",
628                        ));
629                    };
630
631                    // FileOnly: the npm_package_metadata action's own log_template already
632                    // reports "-> NPM package metadata" to the TUI at INFO.
633                    Log::new(Some(status_tx)).debug("NPM package metadata response");
634                    Some(
635                        Response::builder()
636                            .status(StatusCode::OK)
637                            .header("Content-Type", "application/json")
638                            .body(Full::new(Bytes::from(metadata.to_string())))
639                            .unwrap(),
640                    )
641                }
642                "npm_package_tarball" => {
643                    let Some(tarball_data) = data.get("tarball_data").and_then(|v| v.as_str())
644                    else {
645                        return Some(npm_server_error(
646                            status_tx,
647                            "npm_package_tarball carried no 'tarball_data' string",
648                        ));
649                    };
650
651                    // Fail closed on undecodable base64. This was
652                    // `.unwrap_or_default()`, which turned a malformed answer into
653                    // **HTTP 200 with a zero-byte body** — `npm install` then failed
654                    // deep inside tar extraction with nothing pointing back here, and
655                    // an empty package was indistinguishable from a real one. The
656                    // action's own example made it likely rather than theoretical: it
657                    // showed an elided `"H4sIAAAAAAAAA..."`, which does not decode.
658                    let decoded =
659                        match base64::engine::general_purpose::STANDARD.decode(tarball_data) {
660                            Ok(bytes) => bytes,
661                            Err(e) => {
662                                return Some(npm_server_error(
663                                    status_tx,
664                                    &format!(
665                                        "tarball_data is not valid base64 ({}). A tarball is \
666                                     binary, so base64 is the only faithful form; send \
667                                     the whole encoded string, never an abbreviation \
668                                     ending in \"...\"",
669                                        e
670                                    ),
671                                ));
672                            }
673                        };
674
675                    if decoded.is_empty() {
676                        return Some(npm_server_error(
677                            status_tx,
678                            "tarball_data decoded to zero bytes; an empty .tgz is not a \
679                             package npm can install",
680                        ));
681                    }
682
683                    // FileOnly: the npm_package_tarball action's own log_template already
684                    // reports "-> NPM tarball (...)" to the TUI at INFO.
685                    Log::new(Some(status_tx)).debug(format!(
686                        "NPM package tarball response: {} bytes",
687                        decoded.len()
688                    ));
689                    Some(
690                        Response::builder()
691                            .status(StatusCode::OK)
692                            .header("Content-Type", "application/octet-stream")
693                            .body(Full::new(Bytes::from(decoded)))
694                            .unwrap(),
695                    )
696                }
697                "npm_package_list" => {
698                    let Some(packages) = data.get("packages") else {
699                        return Some(npm_server_error(
700                            status_tx,
701                            "npm_package_list carried no 'packages' field",
702                        ));
703                    };
704
705                    // FileOnly: the npm_package_list action's own log_template already
706                    // reports "-> NPM package list" to the TUI at INFO.
707                    Log::new(Some(status_tx)).debug("NPM package list response");
708                    Some(
709                        Response::builder()
710                            .status(StatusCode::OK)
711                            .header("Content-Type", "application/json")
712                            .body(Full::new(Bytes::from(packages.to_string())))
713                            .unwrap(),
714                    )
715                }
716                "npm_package_search" => {
717                    let Some(results) = data.get("results") else {
718                        return Some(npm_server_error(
719                            status_tx,
720                            "npm_package_search carried no 'results' field",
721                        ));
722                    };
723
724                    // FileOnly: the npm_package_search action's own log_template already
725                    // reports "-> NPM search results" to the TUI at INFO.
726                    Log::new(Some(status_tx)).debug("NPM package search response");
727                    Some(
728                        Response::builder()
729                            .status(StatusCode::OK)
730                            .header("Content-Type", "application/json")
731                            .body(Full::new(Bytes::from(results.to_string())))
732                            .unwrap(),
733                    )
734                }
735                "npm_error" => {
736                    let error_message = data
737                        .get("error")
738                        .and_then(|v| v.as_str())
739                        .unwrap_or("Unknown error");
740                    let status_code = data
741                        .get("status_code")
742                        .and_then(|v| v.as_u64())
743                        .unwrap_or(500) as u16;
744
745                    // FileOnly: the npm_error action's own log_template already reports
746                    // "-> NPM error {status_code}: {error}" to the TUI at INFO.
747                    Log::new(Some(status_tx))
748                        .debug(format!("NPM error: {} ({})", error_message, status_code));
749                    let error_response = json!({
750                        "error": error_message
751                    });
752                    Some(
753                        Response::builder()
754                            .status(
755                                StatusCode::from_u16(status_code)
756                                    .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
757                            )
758                            .header("Content-Type", "application/json")
759                            .body(Full::new(Bytes::from(error_response.to_string())))
760                            .unwrap(),
761                    )
762                }
763                _ => {
764                    error!("Unknown NPM action: {}", name);
765                    // Not an NPM action: let the caller keep scanning.
766                    None
767                }
768            }
769        }
770        _ => {
771            error!("Unexpected action result type for NPM request");
772            // Not an NPM action: let the caller keep scanning.
773            None
774        }
775    }
776}