Skip to main content

codetopo_server/
handlers.rs

1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar (https://nrupalakolkar.com) · Built with Muse by Meta (https://muse.ai/join) — join Muse, then redeem code 101EEK in Settings within 48 hours of joining and we both earn tokens.
4
5//! The request handlers of the HTTP API (Phase 2 design §3, extended by
6//! the analytics-depth program: the symbol dossier, the diagnostics read,
7//! the ranking, the diff, and the coupling/cohesion metrics).
8//!
9//! This module owns no graph logic. Every route is a transport rename of a
10//! command the CLI already runs: the same `codetopo_core` query answers it, and
11//! the same `codetopo_store` read backs it. That is deliberate — §3.2 makes
12//! CLI/API parity an exit criterion, and shared code is the only way to make
13//! parity true by construction rather than by two implementations that happen
14//! to agree today.
15//!
16//! Three rules shape the code below:
17//!
18//! * **Aggregate first.** A closure response always carries `total`, the full
19//!   size of the answer, before any list. An agent learns the shape of the
20//!   result from one integer and decides whether it can afford the list; a
21//!   response that is only a list forces it to page before it knows the size.
22//! * **Blocking work leaves the async runtime.** `rusqlite` and tree walking
23//!   are synchronous, and §6 requires every db-touching handler to run inside
24//!   `spawn_blocking` so a slow query cannot stall the accept loop.
25//! * **Errors are flat and coded.** Every failure path returns [`ApiError`],
26//!   whose body is `{"error": "<code>", "message": "…"}` (see [`crate::error`]
27//!   for why the body is flat rather than nested).
28
29use std::collections::BTreeMap;
30use std::path::{Path as FsPath, PathBuf};
31use std::sync::Arc;
32
33use axum::extract::{Extension, Path as UrlPath, Query, Request, State};
34use axum::http::header::CONTENT_TYPE;
35use axum::routing::{get, post};
36use axum::{Json, Router};
37use serde::{Deserialize, Serialize};
38use serde_json::{json, Value};
39
40use codetopo_cli::{
41    default_package_name, describe_schema, index_repo, load_graph_from_db, render_path,
42    require_node, CliError,
43};
44use codetopo_core::graph::{Graph, PathStep};
45use codetopo_core::schema::{EdgeKind, NodeKind};
46use codetopo_core::snapshot::Snapshot;
47use codetopo_store::Store;
48
49use crate::auth::{KeyId, KeyPlan};
50use crate::config::Config;
51use crate::error::ApiError;
52use crate::ingest;
53use crate::keys::now_unix_secs;
54use crate::limits::{plan_limits, Envelope};
55use crate::tenancy::Tenancy;
56
57/// Default `limit` for closure queries, matching the CLI's `--limit` default.
58const DEFAULT_LIMIT: usize = 20;
59
60/// Upper bound on `limit` (§3.1). A closure over a whole repository can hold
61/// hundreds of thousands of ids, and an unbounded `limit` would let one request
62/// allocate the whole graph's id set — the id list is the response, so the cap
63/// is the only thing between a query and an out-of-memory.
64const MAX_LIMIT: usize = 1000;
65
66/// Shared, immutable server state.
67///
68/// The resolved configuration plus the tenancy store: the metering log
69/// is owned by the auth layer, which is applied around the router
70/// rather than inside it, and the key store is shared with that layer.
71/// A handler still cannot reach a secret — [`KeyStore`] exposes none;
72/// the state holds it for exactly one number, the active-key count the
73/// ownerless-index rule turns on (see [`crate::tenancy`]). Tenancy is
74/// different — a handler *must* consult it before touching an index —
75/// and it carries no secrets, only ownership records. The [`Envelope`]
76/// is the abuse-envelope state of paid-path S3: the query routes'
77/// concurrency cap and per-key index-operation permits
78/// (see [`crate::limits`]).
79#[derive(Debug, Clone)]
80pub struct AppState {
81    config: Arc<Config>,
82    keys: Arc<crate::keys::KeyStore>,
83    tenancy: Arc<Tenancy>,
84    envelope: Arc<Envelope>,
85}
86
87impl AppState {
88    /// Wraps a resolved [`Config`] and the [`Tenancy`] store as axum state.
89    ///
90    /// Takes the `Arc`s the process already holds rather than values, so
91    /// the state and the configuration `main` logs at startup are provably
92    /// the same values and building a router in a test does not require
93    /// cloning either. The envelope is process state, not configuration,
94    /// so it is created here rather than passed in.
95    pub fn new(
96        config: Arc<Config>,
97        keys: Arc<crate::keys::KeyStore>,
98        tenancy: Arc<Tenancy>,
99    ) -> Self {
100        AppState {
101            config,
102            keys,
103            tenancy,
104            envelope: Arc::new(Envelope::new()),
105        }
106    }
107
108    /// Database path for one index id (§6: `<data_dir>/<id>.db`).
109    fn db_path(&self, index_id: &str) -> PathBuf {
110        self.config.index_db_path(index_id)
111    }
112}
113
114/// The tenancy gate every `/v1/graphs/:id/…` handler passes before it
115/// touches the index (paid-path plan S1, gap G2).
116///
117/// * No ownership record: a legacy index (written before tenancy, or
118///   placed by another tool) — readable only as
119///   [`crate::tenancy::ownerless_readable`] rules: the operator's flag
120///   when set, else only while the key store holds a single active
121///   key. A denied legacy index is answered exactly like a missing
122///   one, so the refusal cannot confirm the index exists either.
123/// * Record owned by another key: answered exactly like a missing index
124///   (`404 unknown_index`), so the response cannot confirm the index
125///   exists. There is no `403` anywhere in this API's error vocabulary,
126///   and a distinguishable "exists but not yours" would leak it.
127/// * Expired record: refused the same way, and cleaned up on the spot —
128///   the record is dropped and the database file deleted — so expiry is
129///   enforced even between the startup sweeps in `Config::prepare`.
130fn enforce_tenancy(state: &AppState, index_id: &str, key: &KeyId) -> Result<(), ApiError> {
131    let Some(record) = state.tenancy.lookup(index_id)? else {
132        if crate::tenancy::ownerless_readable(
133            state.config.deny_ownerless_indexes,
134            state.keys.active_len(),
135        ) {
136            return Ok(());
137        }
138        return Err(ApiError::unknown_index(index_id));
139    };
140    if record.is_expired(now_unix_secs()) {
141        state.tenancy.remove(index_id)?;
142        // The database and, for a hosted ingest, its staged tree:
143        // an expired index leaves nothing behind.
144        state.config.remove_index_files(index_id);
145        return Err(ApiError::unknown_index(index_id));
146    }
147    if record.owner_key_id != key.as_str() {
148        return Err(ApiError::unknown_index(index_id));
149    }
150    Ok(())
151}
152
153/// Build the unauthenticated route table.
154///
155/// [`crate::auth::metered`] wraps the result; this function only decides which
156/// paths exist, so that auth coverage is a property of the wrapper rather than
157/// something each route has to opt into. The index handler also takes the
158/// [`KeyId`] and [`KeyPlan`] request extensions, which only that wrapper
159/// inserts — serving this router without it answers `500` on that route
160/// rather than skipping the tenancy and plan checks.
161pub fn router(state: AppState) -> Router {
162    // The query routes sit under the envelope middleware (global
163    // concurrency cap + the caller's plan timeout, paid-path S3);
164    // index creation does not — it has its own per-key permit and
165    // ingest bounds inside the handler, and a creation's blocking
166    // work must never be abandoned by a timer (see `index`).
167    let envelope = state.envelope.clone();
168    let queries = Router::new()
169        .route("/v1/graphs/:id/descendants", get(descendants))
170        .route("/v1/graphs/:id/ancestors", get(ancestors))
171        .route("/v1/graphs/:id/blast-radius", get(blast_radius))
172        .route("/v1/graphs/:id/path", get(path))
173        .route("/v1/graphs/:id/stats", get(stats))
174        .route("/v1/graphs/:id/verify", get(verify))
175        .route("/v1/graphs/:id/snapshot", get(snapshot))
176        .route("/v1/graphs/:id/neighborhood", get(neighborhood))
177        .route("/v1/graphs/:id/symbol", get(symbol))
178        .route("/v1/graphs/:id/search", get(search))
179        .route("/v1/graphs/:id/diagnostics", get(diagnostics))
180        .route("/v1/graphs/:id/rank", get(rank))
181        .route("/v1/graphs/:id/diff", get(diff))
182        .route("/v1/graphs/:id/metrics", get(metrics))
183        .route("/v1/graphs/:id/history", get(history))
184        .route("/v1/schema", get(schema))
185        .route_layer(axum::middleware::from_fn_with_state(
186            envelope,
187            crate::limits::query_envelope,
188        ));
189    Router::new()
190        .route("/v1/index", post(index))
191        .merge(queries)
192        .with_state(state)
193}
194
195// ---------------------------------------------------------------------------
196// POST /v1/index
197// ---------------------------------------------------------------------------
198
199/// Body of `POST /v1/index` in JSON mode.
200///
201/// Every field is optional at the type level so that a *missing* field is
202/// reported in the API's own error vocabulary naming it, rather than as a
203/// generic deserialization rejection. The handler reads and parses the
204/// body itself instead of using an extractor, because the same route
205/// also serves the archive-upload mode, whose body is not JSON at all —
206/// the content type, not the route, picks the mode (see [`index`]).
207#[derive(Debug, Default, Deserialize)]
208struct IndexRequest {
209    repo_path: Option<String>,
210    repo_url: Option<String>,
211    package: Option<String>,
212}
213
214/// Query string of `POST /v1/index`. Only the archive mode reads it: an
215/// upload has no JSON body to carry a `package`, so the name rides the
216/// query string there. JSON mode takes `package` from the body.
217#[derive(Debug, Default, Deserialize)]
218struct IndexQuery {
219    package: Option<String>,
220}
221
222// Package name recorded for the index when the request omits one:
223// the shared default every surface uses
224// (`codetopo_cli::default_package_name` — the repository directory's
225// file stem). Before this was unified, the same fixture indexed as
226// `fixture-repo::…` via CLI/MCP but `unknown::…` here, so node ids —
227// and every query keyed on them — disagreed by surface.
228
229/// The largest JSON body `POST /v1/index` reads: the limit the JSON
230/// extractor enforced when it read the body, kept so taking over the
231/// read does not silently change what a client may send.
232const JSON_BODY_LIMIT: usize = 2 * 1024 * 1024;
233
234/// A field's value, trimmed, with empty treated as absent — the rule
235/// every optional field of this route follows.
236fn non_empty(field: Option<String>) -> Option<String> {
237    field
238        .map(|raw| raw.trim().to_string())
239        .filter(|raw| !raw.is_empty())
240}
241
242/// Reads a request body up to `limit`, mapping any failure (including
243/// passing the limit) to `invalid_body` with the caller's wording.
244async fn read_body(request: Request, limit: usize, failure: &str) -> Result<axum::body::Bytes, ApiError> {
245    axum::body::to_bytes(request.into_body(), limit)
246        .await
247        .map_err(|_| ApiError::invalid_body(failure))
248}
249
250/// `POST /v1/index` — index a repository into a new index id.
251///
252/// Walks the tree, writes `<data_dir>/<index_id>.db`, and returns the CLI's own
253/// `IndexReport` fields, so API stats and CLI stats come from one computation.
254/// The index is recorded as owned by the creating key (see
255/// [`enforce_tenancy`]), with the configured TTL as its expiry if one is set.
256/// If the ownership record cannot be written, the fresh database is deleted
257/// again rather than left behind as an unowned index anyone could read.
258///
259/// ## Ingest modes (paid-path S2, gap G1)
260///
261/// The request names the tree in exactly one of three ways:
262///
263/// * **`repo_path`** (JSON) — a path on the server host, offered only
264///   to `self-hosted` keys. Naming host paths is the operator's own
265///   power; a hosted key asking for it is refused `invalid_repo_path`.
266/// * **`repo_url`** (JSON) — a public HTTPS git URL, validated and
267///   shallow-cloned into the index's workdir
268///   (`<data_dir>/ingest/<index_id>/`) by [`crate::ingest`]. Private
269///   repositories are out of v1 scope: no credentials are accepted in
270///   the URL, and the server holds none.
271/// * **Archive upload** — the request body itself is a `.zip` or
272///   `.tar.gz` (declared by content type, or sniffed from the magic
273///   bytes), extracted into the same workdir under the containment
274///   rules in [`crate::ingest`]. An explicit `package` rides the query
275///   string (`?package=`), there being no JSON body to carry it.
276///
277/// `repo_path` and `repo_url` in one body is `invalid_body`; neither is
278/// `invalid_repo_path`. An ingested tree belongs to its index: expiry
279/// deletes the workdir with the database, and a failed ingest or index
280/// removes the workdir before the error is answered — a partial tree is
281/// never left behind.
282///
283/// ## Limits (paid-path S3, gap G4)
284///
285/// The caller's plan ([`crate::limits`]) bounds this route four ways,
286/// all refused `429` before any ingest work is spent on them: one
287/// index operation at a time per key (the permit is held until the
288/// ownership record is written, so the next check is exact), an
289/// active-index quota counted from the tenancy store, the plan's
290/// ingest caps on the clone/upload itself, and a per-index file cap
291/// counted over the resolved tree before indexing starts — a tree
292/// past the cap leaves nothing behind, like any other failed ingest.
293async fn index(
294    State(state): State<AppState>,
295    Extension(key): Extension<KeyId>,
296    Extension(plan): Extension<KeyPlan>,
297    Query(query): Query<IndexQuery>,
298    request: Request,
299) -> Result<Json<Value>, ApiError> {
300    let content_type = request
301        .headers()
302        .get(CONTENT_TYPE)
303        .and_then(|value| value.to_str().ok())
304        .map(|value| {
305            value
306                .split(';')
307                .next()
308                .unwrap_or("")
309                .trim()
310                .to_ascii_lowercase()
311        })
312        .filter(|value| !value.is_empty());
313
314    // §6: a UUID v4 rather than a repo name, so re-indexing the same tree does
315    // not clobber the previous index and no customer directory layout leaks
316    // into a URL or a log line.
317    let index_id = uuid::Uuid::new_v4().to_string();
318    let db = state.db_path(&index_id);
319
320    // The plan envelope (S3): this key's limits, the per-key index
321    // permit, and its active-index quota — all before mode resolution,
322    // so a refused creation costs no clone, no upload read, no walk.
323    let limits = plan_limits(plan.as_str());
324    let _index_permit = match limits.max_concurrent_index_ops {
325        Some(max_ops) => match state.envelope.try_acquire_index_op(key.as_str(), max_ops) {
326            Some(permit) => Some(permit),
327            None => return Err(ApiError::index_concurrency(plan.as_str(), max_ops)),
328        },
329        None => None,
330    };
331    if let Some(max_active) = limits.max_active_indexes {
332        let current = state
333            .tenancy
334            .count_active_for_owner(key.as_str(), now_unix_secs())?;
335        if current >= max_active {
336            return Err(ApiError::index_quota(plan.as_str(), max_active, current));
337        }
338    }
339    let caps = limits.ingest;
340
341    // Resolve the three modes to the tree to walk, the package name to
342    // record, and the workdir this request owns (hosted modes only —
343    // a local path is the caller's own tree and is never deleted here).
344    let (repo, package, workdir) = if content_type.as_deref() == Some("application/json") {
345        let bytes = read_body(
346            request,
347            JSON_BODY_LIMIT,
348            "request body could not be read (2 MiB limit)",
349        )
350        .await?;
351        let body: IndexRequest = serde_json::from_slice(&bytes).map_err(|err| {
352            ApiError::invalid_body(&format!("not valid JSON for this route: {err}"))
353        })?;
354        match (non_empty(body.repo_path), non_empty(body.repo_url)) {
355            (Some(_), Some(_)) => {
356                return Err(ApiError::invalid_body(
357                    "repo_path and repo_url are alternatives; send exactly one",
358                ));
359            }
360            (None, None) => {
361                return Err(ApiError::invalid_repo_path(
362                    "one of repo_path or repo_url is required",
363                ));
364            }
365            (Some(raw_path), None) => {
366                if !plan.is_self_hosted() {
367                    return Err(ApiError::invalid_repo_path(
368                        "repo_path names a path on the server host and is available \
369                         only to self-hosted keys; use repo_url or an archive upload",
370                    ));
371                }
372                let repo = PathBuf::from(raw_path);
373                if !repo.is_dir() {
374                    return Err(ApiError::invalid_repo_path(&format!(
375                        "not a directory: {}",
376                        repo.display()
377                    )));
378                }
379                let package =
380                    non_empty(body.package).unwrap_or_else(|| default_package_name(&repo));
381                (repo, package, None)
382            }
383            (None, Some(raw_url)) => {
384                let validated = ingest::validate_repo_url(&raw_url).await?;
385                let workdir = state.config.index_workdir_path(&index_id);
386                let clone_dir = workdir.clone();
387                let clone_validated = validated.clone();
388                tokio::task::spawn_blocking(move || {
389                    ingest::clone_into(&clone_validated, &clone_dir, caps)
390                })
391                .await
392                .map_err(|err| ApiError::internal(format!("clone task failed: {err}")))??;
393                let package =
394                    non_empty(body.package).unwrap_or_else(|| validated.package_name());
395                (workdir.clone(), package, Some(workdir))
396            }
397        }
398    } else {
399        let bytes = read_body(
400            request,
401            caps.max_upload_bytes,
402            "archive body could not be read or exceeds the upload limit",
403        )
404        .await?;
405        let kind = ingest::archive_kind(content_type.as_deref(), &bytes).ok_or_else(|| {
406            ApiError::invalid_body(
407                "unrecognized archive body: send a .zip or .tar.gz archive",
408            )
409        })?;
410        let workdir = state.config.index_workdir_path(&index_id);
411        let extract_dir = workdir.clone();
412        let root = tokio::task::spawn_blocking(move || {
413            ingest::extract_into(kind, &bytes, &extract_dir, caps)
414        })
415        .await
416        .map_err(|err| ApiError::internal(format!("extract task failed: {err}")))??;
417        let package = non_empty(query.package).unwrap_or_else(|| {
418            if root != workdir {
419                default_package_name(&root)
420            } else {
421                "repo".to_string()
422            }
423        });
424        (root, package, Some(workdir))
425    };
426
427    // The per-index file cap (S3): counted over the resolved tree
428    // before indexing starts, with the walk stopping just past the
429    // cap. Counting first is what makes the refusal clean — no
430    // extraction is paid for and no partial index exists to clean
431    // up; the workdir removal is the same discipline every failed
432    // ingest follows.
433    if let Some(max_files) = limits.max_files_per_index {
434        let count_root = repo.clone();
435        let counted = tokio::task::spawn_blocking(move || {
436            ingest::count_tree_files(&count_root, max_files as u64 + 1)
437        })
438        .await
439        .map_err(|err| ApiError::internal(format!("file count task failed: {err}")))?;
440        if counted > max_files as u64 {
441            if let Some(workdir) = &workdir {
442                let _ = std::fs::remove_dir_all(workdir);
443            }
444            return Err(ApiError::file_quota(plan.as_str(), max_files));
445        }
446    }
447
448    let report = tokio::task::spawn_blocking(move || index_repo(&repo, &db, &package, None, None))
449        .await
450        .map_err(|err| ApiError::internal(format!("index task failed: {err}")))?;
451    let report = match report {
452        Ok(report) => report,
453        Err(err) => {
454            // A failed index leaves nothing behind: not the workdir,
455            // and not a partially written database — an unrecorded
456            // database file would read as a legacy index to any key.
457            let _ = std::fs::remove_file(state.db_path(&index_id));
458            if let Some(workdir) = &workdir {
459                let _ = std::fs::remove_dir_all(workdir);
460            }
461            return Err(ApiError::internal(format!("cannot index repository: {err}")));
462        }
463    };
464
465    let created_at = now_unix_secs();
466    let expires_at = state
467        .config
468        .index_ttl_secs
469        .map(|ttl| created_at + ttl as i64);
470    if let Err(err) = state
471        .tenancy
472        .record(&index_id, key.as_str(), created_at, expires_at)
473    {
474        let _ = std::fs::remove_file(state.db_path(&index_id));
475        if let Some(workdir) = &workdir {
476            let _ = std::fs::remove_dir_all(workdir);
477        }
478        return Err(err);
479    }
480
481    Ok(Json(json!({
482        "index_id": index_id,
483        "files_indexed": report.files_indexed,
484        "files_failed": report.files_failed,
485        "nodes": report.nodes,
486        "edges": report.edges,
487        "diagnostics": report.diagnostics,
488    })))
489}
490
491// ---------------------------------------------------------------------------
492// Closure routes: descendants / ancestors / blast-radius
493// ---------------------------------------------------------------------------
494
495/// Raw query string for a closure route.
496///
497/// `expand` and `limit` are read as strings so a bad value is reported as
498/// `invalid_param` naming the parameter, instead of being turned into a
499/// deserialization error before a handler sees it.
500#[derive(Debug, Default, Deserialize)]
501struct ClosureQuery {
502    node: Option<String>,
503    expand: Option<String>,
504    limit: Option<String>,
505}
506
507/// Validated closure parameters.
508#[derive(Debug)]
509struct ClosureParams {
510    node: String,
511    expand: bool,
512    limit: usize,
513}
514
515/// Which direction a closure walks.
516#[derive(Debug, Clone, Copy)]
517enum Direction {
518    /// Outgoing edges: what this node affects.
519    Downstream,
520    /// Incoming edges: what depends on this node.
521    Upstream,
522}
523
524/// Parse `node`, `expand`, and `limit` per §3.1.
525///
526/// Absent and empty are treated alike, so `?limit=` means "use the default"
527/// rather than "reject", which keeps a client that templates optional query
528/// parameters from failing on the empty string.
529fn closure_params(query: &ClosureQuery) -> Result<ClosureParams, ApiError> {
530    let node = query
531        .node
532        .as_deref()
533        .map(str::trim)
534        .filter(|raw| !raw.is_empty())
535        .ok_or_else(|| ApiError::missing_param("node"))?
536        .to_string();
537
538    let expand = match query.expand.as_deref().map(str::trim) {
539        None | Some("") => false,
540        Some("true") => true,
541        Some("false") => false,
542        Some(other) => {
543            return Err(ApiError::invalid_param(
544                "expand",
545                &format!("expected 'true' or 'false', got '{other}'"),
546            ))
547        }
548    };
549
550    let limit = match query.limit.as_deref().map(str::trim) {
551        None | Some("") => DEFAULT_LIMIT,
552        Some(raw) => {
553            let parsed: usize = raw.parse().map_err(|_| {
554                ApiError::invalid_param("limit", &format!("expected an integer, got '{raw}'"))
555            })?;
556            if parsed == 0 || parsed > MAX_LIMIT {
557                return Err(ApiError::invalid_param(
558                    "limit",
559                    &format!("must be 1..={MAX_LIMIT}, got {parsed}"),
560                ));
561            }
562            parsed
563        }
564    };
565
566    Ok(ClosureParams {
567        node,
568        expand,
569        limit,
570    })
571}
572
573/// Aggregate-first closure response.
574///
575/// `total` is the size of the *whole* closure; `nodes` is a possibly-truncated
576/// view of it. `truncated` says which of the two the client got, so a client
577/// never has to infer it by comparing two numbers.
578#[derive(Debug, Serialize)]
579struct ClosureResponse {
580    index_id: String,
581    relation: &'static str,
582    node: String,
583    total: usize,
584    expand: bool,
585    limit: usize,
586    truncated: bool,
587    nodes: Vec<String>,
588}
589
590/// `GET /v1/graphs/{id}/descendants?node=&expand=&limit=`.
591///
592/// What this node affects: the transitive closure of its outgoing call and
593/// import edges.
594async fn descendants(
595    State(state): State<AppState>,
596    Extension(key): Extension<KeyId>,
597    UrlPath(index_id): UrlPath<String>,
598    Query(query): Query<ClosureQuery>,
599) -> Result<Json<ClosureResponse>, ApiError> {
600    closure(state, &key, index_id, query, "descendants", Direction::Downstream).await
601}
602
603/// `GET /v1/graphs/{id}/ancestors?node=&expand=&limit=`.
604///
605/// The reverse closure: everything that transitively reaches this node.
606async fn ancestors(
607    State(state): State<AppState>,
608    Extension(key): Extension<KeyId>,
609    UrlPath(index_id): UrlPath<String>,
610    Query(query): Query<ClosureQuery>,
611) -> Result<Json<ClosureResponse>, ApiError> {
612    closure(state, &key, index_id, query, "ancestors", Direction::Upstream).await
613}
614
615/// `GET /v1/graphs/{id}/blast-radius?node=&expand=&limit=`.
616///
617/// Breakage propagation: the dependency-side closure a failure of this node
618/// would take with it.
619async fn blast_radius(
620    State(state): State<AppState>,
621    Extension(key): Extension<KeyId>,
622    UrlPath(index_id): UrlPath<String>,
623    Query(query): Query<ClosureQuery>,
624) -> Result<Json<ClosureResponse>, ApiError> {
625    closure(
626        state,
627        &key,
628        index_id,
629        query,
630        "blast_radius",
631        Direction::Downstream,
632    )
633    .await
634}
635
636/// Shared body of the three closure routes.
637///
638/// ## Deviation from §3.1
639///
640/// §3.1 says `expand=true` returns "the full id list" and, in the same table,
641/// that `limit` is the "max ids returned when `expand=true`". Those two
642/// statements cannot both hold, and the design gives no tie-breaker. CLI
643/// parity (§3.2) is the tie-breaker used here: `print_id_list` caps the list at
644/// `limit` by default and returns everything under `--expand`. So `limit` caps
645/// the default list and `expand=true` lifts the cap. Nothing is lost either way,
646/// because `total` is always the full size — which is the reason the aggregate
647/// comes first.
648async fn closure(
649    state: AppState,
650    key: &KeyId,
651    index_id: String,
652    query: ClosureQuery,
653    relation: &'static str,
654    direction: Direction,
655) -> Result<Json<ClosureResponse>, ApiError> {
656    enforce_tenancy(&state, &index_id, key)?;
657    let ClosureParams {
658        node,
659        expand,
660        limit,
661    } = closure_params(&query)?;
662    let db = state.db_path(&index_id);
663    let graph = load_graph_blocking(&db, &index_id)?;
664
665    // The walk owns the graph and a copy of the id: `spawn_blocking` needs a
666    // `'static` closure, so neither can be borrowed from this frame. The
667    // response still reports the caller's `node`, so the copy is the only
668    // duplicate of the id that crosses the boundary.
669    let walk_node = node.clone();
670    let ids = spawn_blocking(move || {
671        Ok(match direction {
672            Direction::Downstream if relation == "blast_radius" => graph.blast_radius(&walk_node),
673            Direction::Downstream => graph.descendants(&walk_node),
674            Direction::Upstream => graph.ancestors(&walk_node),
675        })
676    })
677    .await?;
678
679    let total = ids.len();
680    let truncated = !expand && total > limit;
681    let shown = if expand {
682        ids
683    } else {
684        ids[..total.min(limit)].to_vec()
685    };
686
687    Ok(Json(ClosureResponse {
688        index_id,
689        relation,
690        node,
691        total,
692        expand,
693        limit,
694        truncated,
695        nodes: shown,
696    }))
697}
698
699// ---------------------------------------------------------------------------
700// GET /v1/graphs/{id}/path
701// ---------------------------------------------------------------------------
702
703/// Raw query string for `path`. Read as strings for the same reason as
704/// [`ClosureQuery`]: a missing `from` must be `missing_param`, not a serde
705/// error from the extractor.
706#[derive(Debug, Default, Deserialize)]
707struct PathQuery {
708    from: Option<String>,
709    to: Option<String>,
710}
711
712/// `GET /v1/graphs/{id}/path?from=&to=` — the shortest typed path between two
713/// nodes.
714///
715/// The response is deliberately the *whole* path rather than a capped prefix:
716/// a path is a sequence whose meaning depends on every step, so truncating it
717/// would produce a well-formed but false answer.
718#[derive(Debug, Serialize)]
719struct PathResponse {
720    index_id: String,
721    from: String,
722    to: String,
723    steps: Vec<PathStep>,
724    rendered: String,
725}
726
727async fn path(
728    State(state): State<AppState>,
729    Extension(key): Extension<KeyId>,
730    UrlPath(index_id): UrlPath<String>,
731    Query(query): Query<PathQuery>,
732) -> Result<Json<PathResponse>, ApiError> {
733    enforce_tenancy(&state, &index_id, &key)?;
734    let from = required_param(query.from.as_deref(), "from")?;
735    let to = required_param(query.to.as_deref(), "to")?;
736    let db = state.db_path(&index_id);
737    let graph = load_graph_blocking(&db, &index_id)?;
738
739    let steps = graph
740        .path(&from, &to)
741        .ok_or_else(|| ApiError::no_path(&from, &to))?;
742    let rendered = render_path(&steps);
743
744    Ok(Json(PathResponse {
745        index_id,
746        from,
747        to,
748        steps,
749        rendered,
750    }))
751}
752
753fn required_param(raw: Option<&str>, name: &str) -> Result<String, ApiError> {
754    raw.map(str::trim)
755        .filter(|value| !value.is_empty())
756        .map(str::to_string)
757        .ok_or_else(|| ApiError::missing_param(name))
758}
759
760// ---------------------------------------------------------------------------
761// GET /v1/graphs/{id}/stats
762// ---------------------------------------------------------------------------
763
764/// Aggregate counts: totals plus per-kind breakdowns.
765///
766/// The breakdown omits zero kinds, matching `print_stats` (§3.2 parity), and
767/// `by_kind` is a map rather than an array so a client can read a single kind
768/// without walking a list to find it.
769#[derive(Debug, Serialize)]
770struct StatsResponse {
771    index_id: String,
772    nodes: KindCounts,
773    edges: KindCounts,
774}
775
776#[derive(Debug, Serialize)]
777struct KindCounts {
778    total: usize,
779    by_kind: BTreeMap<String, usize>,
780}
781
782async fn stats(
783    State(state): State<AppState>,
784    Extension(key): Extension<KeyId>,
785    UrlPath(index_id): UrlPath<String>,
786) -> Result<Json<StatsResponse>, ApiError> {
787    enforce_tenancy(&state, &index_id, &key)?;
788    let db = state.db_path(&index_id);
789    let graph = load_graph_blocking(&db, &index_id)?;
790
791    let mut node_kinds = BTreeMap::new();
792    for kind in ALL_NODE_KINDS {
793        let count = graph.nodes().filter(|node| node.kind == kind).count();
794        if count > 0 {
795            node_kinds.insert(node_kind_name(kind).to_string(), count);
796        }
797    }
798
799    let mut edge_kinds = BTreeMap::new();
800    for kind in ALL_EDGE_KINDS {
801        let count = graph.edge_count_by_kind(kind);
802        if count > 0 {
803            edge_kinds.insert(kind.as_str().to_string(), count);
804        }
805    }
806
807    Ok(Json(StatsResponse {
808        index_id,
809        nodes: KindCounts {
810            total: graph.node_count(),
811            by_kind: node_kinds,
812        },
813        edges: KindCounts {
814            total: graph.edge_count(),
815            by_kind: edge_kinds,
816        },
817    }))
818}
819
820/// Every node kind, in schema declaration order.
821const ALL_NODE_KINDS: [NodeKind; 10] = [
822    NodeKind::Package,
823    NodeKind::Module,
824    NodeKind::File,
825    NodeKind::Class,
826    NodeKind::Interface,
827    NodeKind::Function,
828    NodeKind::Method,
829    NodeKind::Variable,
830    NodeKind::External,
831    NodeKind::Doc,
832];
833
834/// Every edge kind, in schema declaration order.
835const ALL_EDGE_KINDS: [EdgeKind; 9] = [
836    EdgeKind::Imports,
837    EdgeKind::Calls,
838    EdgeKind::Inherits,
839    EdgeKind::Implements,
840    EdgeKind::Defines,
841    EdgeKind::References,
842    EdgeKind::Contains,
843    EdgeKind::Reads,
844    EdgeKind::Writes,
845];
846
847/// Snake_case wire name of a node kind.
848///
849/// `NodeKind` has no `as_str` of its own (unlike `EdgeKind`), so the names
850/// live here; they are kept in step with the schema's `rename_all =
851/// "snake_case"` serialization, which is what clients see.
852fn node_kind_name(kind: NodeKind) -> &'static str {
853    match kind {
854        NodeKind::Package => "package",
855        NodeKind::Module => "module",
856        NodeKind::File => "file",
857        NodeKind::Class => "class",
858        NodeKind::Interface => "interface",
859        NodeKind::Function => "function",
860        NodeKind::Method => "method",
861        NodeKind::Variable => "variable",
862        NodeKind::External => "external",
863        NodeKind::Doc => "doc",
864    }
865}
866
867// ---------------------------------------------------------------------------
868// GET /v1/graphs/{id}/verify
869// ---------------------------------------------------------------------------
870
871/// Audit-chain verification: entry count, chain validity, and head hash.
872///
873/// The same three facts `verify_db` prints. This is the route an agent calls
874/// before trusting a snapshot it was handed.
875#[derive(Debug, Serialize)]
876struct VerifyResponse {
877    index_id: String,
878    entries: usize,
879    chain_valid: bool,
880    head: String,
881}
882
883async fn verify(
884    State(state): State<AppState>,
885    Extension(key): Extension<KeyId>,
886    UrlPath(index_id): UrlPath<String>,
887) -> Result<Json<VerifyResponse>, ApiError> {
888    enforce_tenancy(&state, &index_id, &key)?;
889    let db = state.db_path(&index_id);
890    require_index(&db, &index_id)?;
891
892    let (entries, chain_valid, head) = spawn_blocking(move || {
893        let store = Store::open(&db).map_err(|err| ApiError::internal(err.to_string()))?;
894        let audit = store
895            .load_audit()
896            .map_err(|err| ApiError::internal(err.to_string()))?;
897        Ok::<_, ApiError>((audit.len(), audit.verify(), audit.head().to_string()))
898    })
899    .await?;
900
901    Ok(Json(VerifyResponse {
902        index_id,
903        entries,
904        chain_valid,
905        head,
906    }))
907}
908
909// ---------------------------------------------------------------------------
910// GET /v1/graphs/{id}/snapshot
911// ---------------------------------------------------------------------------
912
913/// `GET /v1/graphs/{id}/snapshot` — portable frozen export of the whole graph.
914///
915/// Serialized as the `Snapshot` struct itself rather than wrapped, so the
916/// response is byte-compatible with what `codetopo restore` accepts: a client
917/// can pipe this body straight to a file and restore it with no translation
918/// step. The snapshot binds `audit_head`, so a consumer can tell which audit
919/// chain it came from.
920async fn snapshot(
921    State(state): State<AppState>,
922    Extension(key): Extension<KeyId>,
923    UrlPath(index_id): UrlPath<String>,
924) -> Result<Json<Snapshot>, ApiError> {
925    enforce_tenancy(&state, &index_id, &key)?;
926    let db = state.db_path(&index_id);
927    require_index(&db, &index_id)?;
928
929    spawn_blocking(move || {
930        let store = Store::open(&db).map_err(|err| ApiError::internal(err.to_string()))?;
931        let graph = store
932            .load_graph()
933            .map_err(|err| ApiError::internal(err.to_string()))?;
934        let audit = store
935            .load_audit()
936            .map_err(|err| ApiError::internal(err.to_string()))?;
937        Ok::<_, ApiError>(Snapshot::from_graph(&graph, &audit.snapshot_entries()))
938    })
939    .await
940    .map(Json)
941}
942
943// ---------------------------------------------------------------------------
944// shared helpers
945// ---------------------------------------------------------------------------
946
947// ---------------------------------------------------------------------------
948// GET /v1/graphs/{id}/neighborhood, /symbol, /search, /diagnostics;
949// GET /v1/schema
950// ---------------------------------------------------------------------------
951
952/// Query for `neighborhood`: the shared closure params plus `depth`.
953#[derive(Debug, Default, Deserialize)]
954struct NeighborhoodQuery {
955    node: Option<String>,
956    depth: Option<String>,
957}
958
959/// `GET /v1/graphs/{id}/neighborhood?node=&depth=` — the subgraph
960/// within `depth` hops of a node, both directions (CODE_SCHEMA §6's
961/// neighborhood query; the CLI and MCP serve the same computation).
962async fn neighborhood(
963    State(state): State<AppState>,
964    Extension(key): Extension<KeyId>,
965    UrlPath(index_id): UrlPath<String>,
966    Query(query): Query<NeighborhoodQuery>,
967) -> Result<Json<Value>, ApiError> {
968    enforce_tenancy(&state, &index_id, &key)?;
969    let node = query
970        .node
971        .as_deref()
972        .map(str::trim)
973        .filter(|raw| !raw.is_empty())
974        .ok_or_else(|| ApiError::missing_param("node"))?
975        .to_string();
976    let depth: u32 = match query.depth.as_deref().map(str::trim) {
977        None | Some("") => 1,
978        Some(raw) => raw.parse().map_err(|_| {
979            ApiError::invalid_param("depth", &format!("expected a non-negative integer, got '{raw}'"))
980        })?,
981    };
982    let db = state.db_path(&index_id);
983    let graph = load_graph_blocking(&db, &index_id)?;
984    require_node(&graph, &node).map_err(|e| map_cli_error(&index_id, e))?;
985    let sub = graph.neighborhood(&node, depth);
986    Ok(Json(json!({
987        "index_id": index_id,
988        "node": node,
989        "depth": depth,
990        "nodes": sub.nodes,
991        "edges": sub.edges,
992    })))
993}
994
995/// `GET /v1/graphs/{id}/symbol?node=` — one node in full: the DESIGN §3
996/// dossier (the node, its incident edges grouped by kind and direction,
997/// and the counts), serialized by the core type itself so the CLI and
998/// MCP answers are the same object plus `index_id`.
999async fn symbol(
1000    State(state): State<AppState>,
1001    Extension(key): Extension<KeyId>,
1002    UrlPath(index_id): UrlPath<String>,
1003    Query(query): Query<ClosureQuery>,
1004) -> Result<Json<Value>, ApiError> {
1005    enforce_tenancy(&state, &index_id, &key)?;
1006    let node_id = query
1007        .node
1008        .as_deref()
1009        .map(str::trim)
1010        .filter(|raw| !raw.is_empty())
1011        .ok_or_else(|| ApiError::missing_param("node"))?
1012        .to_string();
1013    let db = state.db_path(&index_id);
1014    let graph = load_graph_blocking(&db, &index_id)?;
1015    let dossier = graph
1016        .dossier(&node_id)
1017        .ok_or_else(|| ApiError::unknown_node(&node_id))?;
1018    let mut value = serde_json::to_value(&dossier)
1019        .map_err(|err| ApiError::internal(format!("dossier does not serialize: {err}")))?;
1020    value["index_id"] = json!(index_id);
1021    Ok(Json(value))
1022}
1023
1024/// `GET /v1/graphs/{id}/diagnostics` — the diagnostics recorded when the
1025/// index was built (cycles, multi-definitions, unresolved calls, …),
1026/// read from the store exactly as the CLI prints them: the shared
1027/// [`codetopo_cli::diagnostics_value`] shape plus `index_id`.
1028async fn diagnostics(
1029    State(state): State<AppState>,
1030    Extension(key): Extension<KeyId>,
1031    UrlPath(index_id): UrlPath<String>,
1032) -> Result<Json<Value>, ApiError> {
1033    enforce_tenancy(&state, &index_id, &key)?;
1034    let db = state.db_path(&index_id);
1035    require_index(&db, &index_id)?;
1036    // The blocking closure is 'static, so it takes its own copy of the id
1037    // (the same pattern as the closure routes); the response still reports
1038    // the caller's `index_id`.
1039    let walk_index = index_id.clone();
1040    let mut value = spawn_blocking(move || {
1041        codetopo_cli::diagnostics_value(&db).map_err(|err| map_cli_error(&walk_index, err))
1042    })
1043    .await?;
1044    value["index_id"] = json!(index_id);
1045    Ok(Json(value))
1046}
1047
1048/// Query for `rank`: the metric name and the cap, read as strings so a
1049/// bad value is `invalid_param` naming the parameter (the closure
1050/// routes' discipline).
1051#[derive(Debug, Default, Deserialize)]
1052struct RankQuery {
1053    by: Option<String>,
1054    limit: Option<String>,
1055}
1056
1057/// `GET /v1/graphs/{id}/rank?by=&limit=` — the top symbols by a
1058/// measured count (fan-in, fan-out, or blast-radius size), with the
1059/// metric's published definition stated in the response. The shared
1060/// [`codetopo_cli::rank_value`] shape plus `index_id`: the CLI's
1061/// `rank` command and the MCP `codetopo_rank` tool return the same
1062/// object. Default metric is `blast_radius`; `limit` follows the
1063/// closure routes' rule (default 20, 1..=1000).
1064async fn rank(
1065    State(state): State<AppState>,
1066    Extension(key): Extension<KeyId>,
1067    UrlPath(index_id): UrlPath<String>,
1068    Query(query): Query<RankQuery>,
1069) -> Result<Json<Value>, ApiError> {
1070    enforce_tenancy(&state, &index_id, &key)?;
1071    let by = query
1072        .by
1073        .as_deref()
1074        .map(str::trim)
1075        .filter(|raw| !raw.is_empty())
1076        .unwrap_or("blast_radius");
1077    let metric = codetopo_core::RankMetric::parse(by).ok_or_else(|| {
1078        ApiError::invalid_param(
1079            "by",
1080            &format!("expected fan_in, fan_out, blast_radius, or complexity, got '{by}'"),
1081        )
1082    })?;
1083    let limit: usize = match query.limit.as_deref().map(str::trim) {
1084        None | Some("") => DEFAULT_LIMIT,
1085        Some(raw) => {
1086            let parsed: usize = raw.parse().map_err(|_| {
1087                ApiError::invalid_param("limit", &format!("expected an integer, got '{raw}'"))
1088            })?;
1089            if parsed == 0 || parsed > MAX_LIMIT {
1090                return Err(ApiError::invalid_param(
1091                    "limit",
1092                    &format!("must be 1..={MAX_LIMIT}, got {parsed}"),
1093                ));
1094            }
1095            parsed
1096        }
1097    };
1098    let db = state.db_path(&index_id);
1099    let graph = load_graph_blocking(&db, &index_id)?;
1100    let mut value = codetopo_cli::rank_value(&graph, metric, limit);
1101    value["index_id"] = json!(index_id);
1102    Ok(Json(value))
1103}
1104
1105/// `GET /v1/graphs/{id}/metrics` — coupling and cohesion measurements
1106/// for every type and package of the index (CODE_SCHEMA §9): CBO, RFC,
1107/// LCOM pair counts, TCC, ATFD, WMC per type; Ca, Ce, instability, and
1108/// separately-named external coupling per package — each metric's
1109/// method stated in the response. The shared
1110/// [`codetopo_cli::metrics_value`] shape plus `index_id`.
1111async fn metrics(
1112    State(state): State<AppState>,
1113    Extension(key): Extension<KeyId>,
1114    UrlPath(index_id): UrlPath<String>,
1115) -> Result<Json<Value>, ApiError> {
1116    enforce_tenancy(&state, &index_id, &key)?;
1117    let db = state.db_path(&index_id);
1118    let graph = load_graph_blocking(&db, &index_id)?;
1119    let mut value = codetopo_cli::metrics_value(&graph);
1120    value["index_id"] = json!(index_id);
1121    Ok(Json(value))
1122}
1123
1124/// The reason hosted history is always unmeasured (ruling B0.1:
1125/// history is a local-surface input; an index carries none).
1126const HOSTED_HISTORY_REASON: &str = "hosted indexes carry no repository history; \
1127     history is measured on local surfaces only (the CLI's history command, \
1128     or the MCP tool with a local working tree)";
1129
1130/// `GET /v1/graphs/{id}/history` — the change-history measurements of
1131/// the index. Hosted indexes carry no repository history, so this
1132/// route always answers the unmeasured report (`measured: false` with
1133/// the reason) — absence stated, never zeros invented. The shared
1134/// [`codetopo_cli::history_value`] shape plus `index_id`.
1135async fn history(
1136    State(state): State<AppState>,
1137    Extension(key): Extension<KeyId>,
1138    UrlPath(index_id): UrlPath<String>,
1139) -> Result<Json<Value>, ApiError> {
1140    enforce_tenancy(&state, &index_id, &key)?;
1141    let db = state.db_path(&index_id);
1142    let graph = load_graph_blocking(&db, &index_id)?;
1143    let mut value = codetopo_cli::history_value(&graph, None, 2, HOSTED_HISTORY_REASON);
1144    value["index_id"] = json!(index_id);
1145    Ok(Json(value))
1146}
1147
1148/// Query for `diff`: the other index's id.
1149#[derive(Debug, Default, Deserialize)]
1150struct DiffQuery {
1151    other: Option<String>,
1152}
1153
1154/// `GET /v1/graphs/{id}/diff?other={other_id}` — the change-impact diff
1155/// of this index (before) against another index (after): added,
1156/// removed, and changed nodes and edges, plus the blast-radius
1157/// movement of every touched symbol. Tenancy is enforced on BOTH
1158/// indexes — naming another key's index as either side is answered
1159/// exactly like a missing one. The shared
1160/// [`codetopo_cli::diff_value`] shape plus the two index ids.
1161async fn diff(
1162    State(state): State<AppState>,
1163    Extension(key): Extension<KeyId>,
1164    UrlPath(index_id): UrlPath<String>,
1165    Query(query): Query<DiffQuery>,
1166) -> Result<Json<Value>, ApiError> {
1167    enforce_tenancy(&state, &index_id, &key)?;
1168    let other_id = query
1169        .other
1170        .as_deref()
1171        .map(str::trim)
1172        .filter(|raw| !raw.is_empty())
1173        .ok_or_else(|| ApiError::missing_param("other"))?
1174        .to_string();
1175    enforce_tenancy(&state, &other_id, &key)?;
1176    let before = load_graph_blocking(&state.db_path(&index_id), &index_id)?;
1177    let after = load_graph_blocking(&state.db_path(&other_id), &other_id)?;
1178    let mut value = codetopo_cli::diff_value(&before, &after);
1179    value["index_id"] = json!(index_id);
1180    value["other_index_id"] = json!(other_id);
1181    Ok(Json(value))
1182}
1183
1184/// Query for `search`.
1185#[derive(Debug, Default, Deserialize)]
1186struct SearchQuery {
1187    q: Option<String>,
1188    limit: Option<String>,
1189}
1190
1191/// `GET /v1/graphs/{id}/search?q=&limit=` — name discovery: the step
1192/// the exact-id queries always assumed a caller had already done.
1193async fn search(
1194    State(state): State<AppState>,
1195    Extension(key): Extension<KeyId>,
1196    UrlPath(index_id): UrlPath<String>,
1197    Query(query): Query<SearchQuery>,
1198) -> Result<Json<Value>, ApiError> {
1199    enforce_tenancy(&state, &index_id, &key)?;
1200    let q = query
1201        .q
1202        .as_deref()
1203        .map(str::trim)
1204        .filter(|raw| !raw.is_empty())
1205        .ok_or_else(|| ApiError::missing_param("q"))?
1206        .to_string();
1207    let limit: usize = match query.limit.as_deref().map(str::trim) {
1208        None | Some("") => 20,
1209        Some(raw) => raw.parse().map_err(|_| {
1210            ApiError::invalid_param("limit", &format!("expected a positive integer, got '{raw}'"))
1211        })?,
1212    };
1213    let db = state.db_path(&index_id);
1214    let graph = load_graph_blocking(&db, &index_id)?;
1215    let hits = graph.search_symbols(&q, limit);
1216    Ok(Json(json!({
1217        "index_id": index_id,
1218        "query": q,
1219        "total": hits.len(),
1220        "results": hits,
1221    })))
1222}
1223
1224/// `GET /v1/schema` — the schema description, identical to the CLI's
1225/// `schema` command and the MCP `codetopo_describe_schema` tool
1226/// (one generator in the CLI library serves all three).
1227async fn schema() -> Json<Value> {
1228    Json(describe_schema())
1229}
1230
1231/// Open an index database, mapping "no such file" to `unknown_index`.
1232fn require_index(db: &FsPath, index_id: &str) -> Result<(), ApiError> {
1233    if !db.is_file() {
1234        return Err(ApiError::unknown_index(index_id));
1235    }
1236    Ok(())
1237}
1238
1239/// Load a graph off the runtime, mapping store and id failures to API errors.
1240fn load_graph_blocking(db: &FsPath, index_id: &str) -> Result<Graph, ApiError> {
1241    require_index(db, index_id)?;
1242    load_graph_from_db(db).map_err(|err| map_cli_error(index_id, err))
1243}
1244
1245/// Translate a CLI-layer error into the API's error vocabulary.
1246///
1247/// Only `UnknownId` is a client mistake: the queried node is not in the graph,
1248/// which is the same `404 unknown_node` the CLI signals with exit code 2.
1249/// Everything else is the server's problem — a store or io failure behind a
1250/// request that was otherwise well-formed.
1251fn map_cli_error(index_id: &str, err: CliError) -> ApiError {
1252    match err {
1253        CliError::UnknownId(id) => ApiError::unknown_node(&id),
1254        other => ApiError::internal(format!("index {index_id}: {other}")),
1255    }
1256}
1257
1258/// Run synchronous work on the blocking pool and propagate a join failure.
1259///
1260/// A `JoinError` means the worker panicked or was cancelled; there is no
1261/// request left to answer, so it is reported as `internal` rather than
1262/// silently swallowed.
1263async fn spawn_blocking<T, F>(work: F) -> Result<T, ApiError>
1264where
1265    F: FnOnce() -> Result<T, ApiError> + Send + 'static,
1266    T: Send + 'static,
1267{
1268    tokio::task::spawn_blocking(work)
1269        .await
1270        .map_err(|err| ApiError::internal(format!("worker task failed: {err}")))?
1271}