Skip to main content

improv_server/
main.rs

1//! `improv-server`: a small JSON HTTP API over an Improv model store.
2//!
3//! The (blocking SQLite-backed) Mentat store is opened inside `spawn_blocking`
4//! and never shared across tasks. The loaded model is cached in [`AppState`]
5//! and replaced on every write this process makes (API or scheduler), so reads
6//! cost one store load per write, not per request. The server assumes it owns
7//! the store: a write from another process is not seen until restart.
8#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
9// The attribute is only used inside #[cfg(test)] modules, so non-test
10// coverage builds would otherwise warn that the feature is unused.
11#![cfg_attr(coverage_nightly, allow(unused_features))]
12
13use std::collections::HashSet;
14use std::net::SocketAddr;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::Arc;
17use std::time::Duration;
18
19use axum::{
20    error_handling::HandleErrorLayer,
21    extract::{rejection::JsonRejection, DefaultBodyLimit, Path, Request, State},
22    http::StatusCode,
23    middleware::{self, Next},
24    response::{IntoResponse, Response},
25    routing::{get, post},
26    Extension, Json, Router,
27};
28use improv_core_model::{Coordinate, MeasureId, MeasureKind, Model};
29use improv_engine::dataflow::evaluate;
30use improv_engine::session::{Edit, Engine};
31use improv_engine::{decode_coord, CellValue, CoordKey};
32use improv_nl_formula::{describe_formula, parse_nl_formula, NlContext};
33use improv_store::{Store, StoreError};
34use serde::{Deserialize, Serialize};
35use serde_json::{json, Value as JsonValue};
36use tower::ServiceBuilder;
37
38mod scheduler;
39use scheduler::SharedStatus;
40
41/// Accepted bearer tokens, or open mode.
42///
43/// `Disabled` = no auth (local dev / tests, preserves v1 behavior);
44/// `Tokens` = require `Authorization: Bearer <t>`: a `t` in `rw` may read and
45/// write, a `t` in `ro` may only read (S12).
46#[derive(Clone)]
47pub enum Auth {
48    Disabled,
49    Tokens {
50        rw: HashSet<String>,
51        ro: HashSet<String>,
52    },
53}
54
55impl Auth {
56    /// Build from `IMPROV_API_TOKEN` (single) + `IMPROV_API_TOKENS` (comma-sep)
57    /// for read-write, and `IMPROV_API_TOKENS_RO` (comma-sep) for read-only.
58    /// Empty/whitespace tokens are ignored; no tokens => `Disabled`.
59    fn from_env() -> Self {
60        let list = |var: &str| -> HashSet<String> {
61            std::env::var(var)
62                .unwrap_or_default()
63                .split(',')
64                .map(str::trim)
65                .filter(|t| !t.is_empty())
66                .map(String::from)
67                .collect()
68        };
69        let mut rw = list("IMPROV_API_TOKENS");
70        if let Ok(t) = std::env::var("IMPROV_API_TOKEN") {
71            let t = t.trim();
72            if !t.is_empty() {
73                rw.insert(t.to_string());
74            }
75        }
76        let ro = list("IMPROV_API_TOKENS_RO");
77        if rw.is_empty() && ro.is_empty() {
78            Auth::Disabled
79        } else {
80            Auth::Tokens { rw, ro }
81        }
82    }
83}
84
85/// Request extension set by [`require_token`] for a read-only token.
86#[derive(Clone, Copy)]
87struct ReadOnly;
88
89/// Request body cap (S13). Over-limit bodies get 413.
90const MAX_BODY_BYTES: usize = 1 << 20;
91/// Edits accepted per `/eval` request (S13). More gets 400.
92const MAX_EDITS: usize = 10_000;
93/// Whole-request deadline, including time queued for a concurrency slot (S10).
94const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
95/// Requests in flight across all routes (S10); the rest wait (then time out).
96const MAX_IN_FLIGHT: usize = 64;
97/// Default deadline for a client to finish sending request headers (S10).
98const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10);
99
100pub struct AppState {
101    db_path: String,
102    auth: Auth,
103    /// `None` when the scheduler is disabled (the default; `IMPROV_SCHEDULER=1` enables).
104    scheduler_status: Option<SharedStatus>,
105    /// Held across every load-modify-save of the store (API writes and the
106    /// scheduler), so concurrent writers cannot lose each other's updates (S12).
107    write_lock: tokio::sync::Mutex<()>,
108    /// The loaded model, `None` until first read or after invalidation (P4).
109    cache: tokio::sync::RwLock<Option<Arc<Model>>>,
110    /// The store high-water `cache` was loaded/saved at; a save from a stale
111    /// cache (another process wrote since) is refused with 409.
112    loaded_tx: std::sync::Mutex<Option<i64>>,
113    /// Store loads performed, so tests can check the cache.
114    #[cfg(test)]
115    loads: AtomicU64,
116}
117
118impl AppState {
119    pub fn new(db_path: String, auth: Auth, scheduler_status: Option<SharedStatus>) -> Self {
120        AppState {
121            db_path,
122            auth,
123            scheduler_status,
124            write_lock: tokio::sync::Mutex::new(()),
125            cache: tokio::sync::RwLock::new(None),
126            loaded_tx: std::sync::Mutex::new(None),
127            #[cfg(test)]
128            loads: AtomicU64::new(0),
129        }
130    }
131}
132
133// Coverage exclusion: process entry point (argv, env, bind, `process::exit`);
134// its parts (`Auth::from_env`, `startup_check`, `serve`, `app`) are tested.
135#[cfg_attr(coverage_nightly, coverage(off))]
136#[tokio::main]
137async fn main() {
138    // argv[1]: db path (default in-memory-ish temp file). argv[2] or
139    // IMPROV_ADDR: bind address (default 127.0.0.1:3000).
140    let mut args = std::env::args().skip(1);
141    let db_path = args.next().unwrap_or_else(|| "improv.db".to_string());
142    let addr = args
143        .next()
144        .or_else(|| std::env::var("IMPROV_ADDR").ok())
145        .unwrap_or_else(|| "127.0.0.1:3000".to_string());
146
147    let auth = Auth::from_env();
148    if matches!(auth, Auth::Disabled) {
149        eprintln!("auth disabled: set IMPROV_API_TOKEN to require a bearer token");
150    }
151    let listener = tokio::net::TcpListener::bind(&addr)
152        .await
153        .unwrap_or_else(|e| panic!("bind {addr}: {e}"));
154    let local = listener.local_addr().expect("bound socket has an address");
155    let allow_open = std::env::var("IMPROV_ALLOW_OPEN").ok();
156    if let Err(e) = startup_check(local, &auth, allow_open.as_deref()) {
157        eprintln!("improv-server: {e}");
158        std::process::exit(2);
159    }
160
161    let scheduler_enabled =
162        scheduler::scheduler_enabled(std::env::var("IMPROV_SCHEDULER").ok().as_deref());
163    let tick_secs = std::env::var("IMPROV_SCHEDULER_TICK_SECS")
164        .ok()
165        .and_then(|v| v.parse::<u64>().ok())
166        .unwrap_or(5);
167
168    let scheduler_status = scheduler_enabled.then(|| {
169        Arc::new(std::sync::Mutex::new(scheduler::SchedulerStatus {
170            tick_secs,
171            ..Default::default()
172        }))
173    });
174    let state = Arc::new(AppState::new(db_path, auth, scheduler_status.clone()));
175    if let Some(status) = scheduler_status {
176        let allow = match std::env::var("IMPROV_EXTFN_ALLOW") {
177            Ok(file) => match std::fs::read_to_string(&file) {
178                Ok(text) => scheduler::parse_allowlist(&text),
179                Err(e) => {
180                    eprintln!("scheduler: cannot read IMPROV_EXTFN_ALLOW {file}: {e}; no function will run");
181                    Default::default()
182                }
183            },
184            Err(_) => {
185                eprintln!("scheduler: IMPROV_EXTFN_ALLOW unset; no function will run");
186                Default::default()
187            }
188        };
189        scheduler::spawn_scheduler(
190            state.clone(),
191            std::time::Duration::from_secs(tick_secs),
192            status,
193            Arc::new(allow),
194        );
195    } else {
196        eprintln!("scheduler disabled (set IMPROV_SCHEDULER=1 to enable)");
197    }
198
199    eprintln!("improv-server listening on {addr}");
200    let header_timeout = std::env::var("IMPROV_HEADER_TIMEOUT_MS")
201        .ok()
202        .and_then(|v| v.parse().ok())
203        .map(Duration::from_millis)
204        .unwrap_or(HEADER_READ_TIMEOUT);
205    serve(listener, app(state), header_timeout).await;
206}
207
208/// Refuse open mode (no tokens) on a non-loopback address unless the
209/// operator opts in with `IMPROV_ALLOW_OPEN=1` (S15).
210pub fn startup_check(
211    addr: SocketAddr,
212    auth: &Auth,
213    allow_open: Option<&str>,
214) -> Result<(), String> {
215    if matches!(auth, Auth::Disabled) && !addr.ip().is_loopback() && allow_open != Some("1") {
216        return Err(format!(
217            "refusing to serve {addr} without auth: set IMPROV_API_TOKEN, bind a \
218             loopback address, or set IMPROV_ALLOW_OPEN=1"
219        ));
220    }
221    Ok(())
222}
223
224/// Accept loop: hyper HTTP/1 with a header-read timeout (S10). `axum::serve`
225/// (0.7) sets no header timer, so a client that never finishes its request
226/// line holds a socket forever (slow-loris). HTTP/1 only: the `hyper-util`
227/// `auto` builder would pull in h2, which the API does not need.
228async fn serve(listener: tokio::net::TcpListener, app: Router, header_timeout: Duration) {
229    use hyper_util::rt::{TokioIo, TokioTimer};
230    loop {
231        let stream = match listener.accept().await {
232            Ok((stream, _)) => stream,
233            Err(e) => {
234                // EMFILE etc.: back off instead of spinning.
235                eprintln!("accept: {e}");
236                tokio::time::sleep(Duration::from_millis(100)).await;
237                continue;
238            }
239        };
240        let svc = hyper_util::service::TowerToHyperService::new(app.clone());
241        tokio::spawn(async move {
242            let _ = hyper::server::conn::http1::Builder::new()
243                .timer(TokioTimer::new())
244                .header_read_timeout(header_timeout)
245                .serve_connection(TokioIo::new(stream), svc)
246                .await;
247        });
248    }
249}
250
251/// Build the router. Exposed so tests can drive it via `oneshot`.
252pub fn app(state: Arc<AppState>) -> Router {
253    // Protected routes: everything except `/health` (public for liveness probes).
254    let protected = Router::new()
255        .route("/model", get(get_model))
256        .route("/measures", get(list_measures))
257        .route("/measures/:id/values", get(measure_values))
258        .route("/measures/:id/eval", post(eval_measure))
259        .route("/measures/:id/cells", post(set_cell))
260        .route("/nl/parse", post(nl_parse))
261        .route("/nl/describe", post(nl_describe))
262        .route("/scheduler/status", get(scheduler_status_handler))
263        .route_layer(middleware::from_fn_with_state(state.clone(), require_token));
264
265    // Global (not per-route) limit: `Router::layer` clones the layer per
266    // route, and `GlobalConcurrencyLimitLayer` shares one semaphore.
267    let limits = ServiceBuilder::new()
268        .layer(HandleErrorLayer::new(|e: tower::BoxError| async move {
269            if e.is::<tower::timeout::error::Elapsed>() {
270                (StatusCode::REQUEST_TIMEOUT, "request timed out")
271            } else {
272                (StatusCode::INTERNAL_SERVER_ERROR, "internal error")
273            }
274        }))
275        .layer(tower::timeout::TimeoutLayer::new(REQUEST_TIMEOUT))
276        .layer(tower::limit::GlobalConcurrencyLimitLayer::new(
277            MAX_IN_FLIGHT,
278        ));
279
280    Router::new()
281        .route("/health", get(health))
282        .merge(protected)
283        .layer(limits)
284        .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
285        .with_state(state)
286}
287
288/// Bearer-token gate. In open mode every request passes. Otherwise:
289/// missing/blank `Authorization` -> 401; present but not an accepted token -> 403;
290/// a read-only token passes with a [`ReadOnly`] extension the write paths refuse.
291/// The token value is never logged.
292async fn require_token(
293    State(state): State<Arc<AppState>>,
294    mut req: Request,
295    next: Next,
296) -> Response {
297    let (rw, ro) = match &state.auth {
298        Auth::Disabled => return next.run(req).await,
299        Auth::Tokens { rw, ro } => (rw, ro),
300    };
301    // Check both classes every time so timing does not reveal which matched.
302    let class = req
303        .headers()
304        .get(axum::http::header::AUTHORIZATION)
305        .and_then(|v| v.to_str().ok())
306        .and_then(|v| v.strip_prefix("Bearer "))
307        .map(str::trim)
308        .filter(|t| !t.is_empty())
309        .map(|t| (token_matches(rw, t), token_matches(ro, t)));
310    match class {
311        None => (StatusCode::UNAUTHORIZED, "missing bearer token").into_response(),
312        Some((true, _)) => next.run(req).await,
313        Some((false, true)) => {
314            req.extensions_mut().insert(ReadOnly);
315            next.run(req).await
316        }
317        Some((false, false)) => (StatusCode::FORBIDDEN, "invalid bearer token").into_response(),
318    }
319}
320
321/// Constant-time token check (S15): every accepted token is compared, and
322/// each comparison folds over all bytes, so timing reveals neither which token
323/// matched nor the length of a matching prefix.
324fn token_matches<'a>(tokens: impl IntoIterator<Item = &'a String>, presented: &str) -> bool {
325    tokens.into_iter().fold(false, |hit, t| {
326        hit | ct_eq(t.as_bytes(), presented.as_bytes())
327    })
328}
329
330/// Byte-wise xor accumulate; no early exit on the first differing byte.
331fn ct_eq(a: &[u8], b: &[u8]) -> bool {
332    let diff = a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y));
333    (a.len() == b.len()) & (diff == 0)
334}
335
336// --- error handling: never panic on request input ---
337
338impl From<JsonRejection> for ApiError {
339    fn from(e: JsonRejection) -> Self {
340        // Keep the v1 contract (any bad body -> 400) except an oversized one.
341        let status = match e.status() {
342            StatusCode::PAYLOAD_TOO_LARGE => StatusCode::PAYLOAD_TOO_LARGE,
343            _ => StatusCode::BAD_REQUEST,
344        };
345        ApiError::Rejected(status, e.body_text())
346    }
347}
348
349enum ApiError {
350    /// A rejected JSON body (malformed -> 400, oversized -> 413).
351    Rejected(StatusCode, String),
352    BadRequest(String),
353    Forbidden(String),
354    NotFound(String),
355    /// The store changed under the cached model (another process wrote).
356    Conflict(String),
357    Internal(String),
358}
359
360/// Id logged with each internal error and echoed to the client as `request_id`.
361static NEXT_ERROR_ID: AtomicU64 = AtomicU64::new(1);
362
363impl IntoResponse for ApiError {
364    fn into_response(self) -> Response {
365        let (status, msg) = match self {
366            ApiError::Rejected(s, m) => (s, m),
367            ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
368            ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, m),
369            ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m),
370            ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
371            ApiError::Internal(detail) => {
372                // The detail (store/SQLite errors carry the DB path) stays in
373                // the server log; the client gets an id to quote (S14).
374                let id = NEXT_ERROR_ID.fetch_add(1, Ordering::Relaxed);
375                eprintln!("request {id}: internal error: {detail}");
376                let body = json!({ "error": "internal error", "request_id": id });
377                return (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response();
378            }
379        };
380        (status, Json(json!({ "error": msg }))).into_response()
381    }
382}
383
384/// The current model: the cached copy, or (on a miss) one load from the store
385/// on a blocking thread. The cache write lock is held across the load, so a
386/// concurrent writer's [`save_model`] replaces it only after a stale load lands.
387async fn load_model(state: &Arc<AppState>) -> Result<Arc<Model>, ApiError> {
388    if let Some(m) = state.cache.read().await.as_ref() {
389        return Ok(m.clone());
390    }
391    let mut cache = state.cache.write().await;
392    if let Some(m) = cache.as_ref() {
393        return Ok(m.clone());
394    }
395    #[cfg(test)]
396    state.loads.fetch_add(1, Ordering::Relaxed);
397    let path = state.db_path.clone();
398    let (model, tx) = tokio::task::spawn_blocking(move || {
399        let mut store = Store::open(&path).map_err(|e| e.to_string())?;
400        let model = store.load_model().map_err(|e| e.to_string())?;
401        Ok((model, store.loaded_tx()))
402    })
403    .await
404    .map_err(|e| ApiError::Internal(format!("task join: {e}")))?
405    .map_err(ApiError::Internal)?;
406    let model = Arc::new(model);
407    *state.loaded_tx.lock().unwrap() = tx;
408    *cache = Some(model.clone());
409    Ok(model)
410}
411
412/// Save the model on a blocking thread and make it the cached copy. Call with
413/// `write_lock` held.
414async fn save_model(state: &Arc<AppState>, model: Model) -> Result<Arc<Model>, ApiError> {
415    let path = state.db_path.clone();
416    let base = *state.loaded_tx.lock().unwrap();
417    let saved = tokio::task::spawn_blocking(move || {
418        let mut store = Store::open(&path)?;
419        store.set_loaded_tx(base);
420        store.save_model(&model)?;
421        Ok((model, store.loaded_tx()))
422    })
423    .await
424    .map_err(|e| ApiError::Internal(format!("task join: {e}")))?;
425    let (model, tx) = match saved {
426        Ok(v) => v,
427        Err(e @ StoreError::Conflict(_)) => {
428            // Drop the stale cache so the client's retry reloads.
429            *state.cache.write().await = None;
430            return Err(ApiError::Conflict(e.to_string()));
431        }
432        Err(e) => return Err(ApiError::Internal(e.to_string())),
433    };
434    let model = Arc::new(model);
435    *state.loaded_tx.lock().unwrap() = tx;
436    *state.cache.write().await = Some(model.clone());
437    Ok(model)
438}
439
440/// S12: a write must target an existing input measure, at a coordinate naming
441/// exactly the measure's categories, each with an item of that category, and
442/// carry a finite value (`None` clears the cell).
443fn validate_write(
444    model: &Model,
445    mid: MeasureId,
446    coord: &Coordinate,
447    value: Option<f64>,
448) -> Result<(), ApiError> {
449    let bad = |m: String| Err(ApiError::BadRequest(m));
450    let Some(measure) = model.measures.get(&mid) else {
451        return bad(format!("no measure {}", mid.0));
452    };
453    if !measure.is_input() {
454        return bad(format!("measure {} is derived; cannot set a cell", mid.0));
455    }
456    if value.is_some_and(|v| !v.is_finite()) {
457        return bad("value must be a finite number".into());
458    }
459    let dims_ok = coord.dims.len() == measure.categories.len()
460        && measure.categories.iter().all(|c| {
461            coord
462                .dims
463                .get(c)
464                .and_then(|i| model.items.get(i))
465                .is_some_and(|item| item.category == *c)
466        });
467    if !dims_ok {
468        return bad(format!(
469            "coordinate must name one item for each category of measure {}",
470            mid.0
471        ));
472    }
473    Ok(())
474}
475
476// --- handlers ---
477
478async fn health() -> &'static str {
479    "ok"
480}
481
482/// Scheduler observability: enabled/tick/last-refreshed. Protected (not a
483/// liveness probe like `/health`) since it can reveal which measures exist.
484async fn scheduler_status_handler(State(state): State<Arc<AppState>>) -> Json<JsonValue> {
485    match &state.scheduler_status {
486        None => Json(json!({ "enabled": false })),
487        Some(status) => {
488            let s = status.lock().unwrap_or_else(|e| e.into_inner());
489            Json(json!({
490                "enabled": true,
491                "tick_secs": s.tick_secs,
492                "last_tick_refreshed": s.last_tick_refreshed,
493                "last_error": s.last_error,
494            }))
495        }
496    }
497}
498
499async fn get_model(State(state): State<Arc<AppState>>) -> Result<Response, ApiError> {
500    Ok(Json(&*load_model(&state).await?).into_response())
501}
502
503#[derive(Serialize)]
504struct MeasureSummary {
505    id: u32,
506    name: String,
507    kind: &'static str,
508    categories: Vec<String>,
509}
510
511async fn list_measures(
512    State(state): State<Arc<AppState>>,
513) -> Result<Json<Vec<MeasureSummary>>, ApiError> {
514    let model = load_model(&state).await?;
515    let mut out: Vec<MeasureSummary> = model
516        .measures
517        .values()
518        .map(|m| MeasureSummary {
519            id: m.id.0,
520            name: m.name.0.clone(),
521            kind: if m.is_input() { "input" } else { "derived" },
522            categories: m
523                .categories
524                .iter()
525                .map(|c| category_name(&model, *c))
526                .collect(),
527        })
528        .collect();
529    out.sort_by_key(|m| m.id);
530    Ok(Json(out))
531}
532
533#[derive(Serialize)]
534struct Cell {
535    coord: Vec<[String; 2]>,
536    value: f64,
537}
538
539async fn measure_values(
540    State(state): State<Arc<AppState>>,
541    Path(id): Path<u32>,
542) -> Result<Json<Vec<Cell>>, ApiError> {
543    let model = load_model(&state).await?;
544    let mid = MeasureId(id);
545    let measure = model
546        .measures
547        .get(&mid)
548        .ok_or_else(|| ApiError::NotFound(format!("no measure {id}")))?;
549
550    let cells = match &measure.kind {
551        MeasureKind::Input => model
552            .inputs
553            .iter()
554            .filter(|((m, _), _)| *m == mid)
555            .filter_map(|((_, coord), val)| {
556                val.as_number().map(|n| readable_cell(&model, coord, n))
557            })
558            .collect(),
559        MeasureKind::Derived(_) => {
560            let mut out =
561                evaluate(&model, &[mid]).map_err(|e| ApiError::Internal(e.to_string()))?;
562            let values = out.remove(&mid).unwrap_or_default();
563            values
564                .into_iter()
565                .map(|(k, n)| readable_key(&model, &k, n.as_num().unwrap_or(f64::NAN)))
566                .collect()
567        }
568    };
569    Ok(Json(cells))
570}
571
572/// One edit in an `/eval` request: `measure` id, `coord` as `[[cat_id, item_id], ...]`
573/// (numeric ids, matching `CoordKey`), and `value` (`null` clears the cell).
574#[derive(Deserialize)]
575struct EvalEdit {
576    measure: u32,
577    coord: CoordKey,
578    value: Option<f64>,
579}
580
581#[derive(Deserialize)]
582struct EvalReq {
583    #[serde(default)]
584    edits: Vec<EvalEdit>,
585    #[serde(default)]
586    persist: bool,
587}
588
589/// Incremental / what-if evaluation. Builds a short-lived live `Engine` over
590/// all derived measures, applies the batch of edits as deltas, and returns the
591/// recomputed snapshot for measure `:id` as `[{coord: [[cat_name,item_name]..], value}]`.
592///
593/// Stateless by design: the `Engine` owns a worker thread and a live dataflow
594/// and is not cheaply shareable across async tasks, so v1 rebuilds one per
595/// request inside `spawn_blocking`. A persistent per-session engine pool is a
596/// later optimization. Edits are pure what-if unless `"persist": true`, in
597/// which case each edit is also written to the model store.
598async fn eval_measure(
599    State(state): State<Arc<AppState>>,
600    ro: Option<Extension<ReadOnly>>,
601    Path(id): Path<u32>,
602    body: Result<Json<EvalReq>, JsonRejection>,
603) -> Result<Json<Vec<Cell>>, ApiError> {
604    let Json(req) = body?;
605    if req.persist && ro.is_some() {
606        return Err(ApiError::Forbidden("read-only token cannot persist".into()));
607    }
608    if req.edits.len() > MAX_EDITS {
609        return Err(ApiError::BadRequest(format!(
610            "too many edits ({}); at most {MAX_EDITS} per request",
611            req.edits.len()
612        )));
613    }
614    let mid = MeasureId(id);
615
616    let write = match req.persist {
617        true => Some(state.write_lock.lock().await),
618        false => None,
619    };
620    let mut model = load_model(&state).await?;
621    let measure = model
622        .measures
623        .get(&mid)
624        .ok_or_else(|| ApiError::NotFound(format!("no measure {id}")))?;
625    if measure.is_input() {
626        return Err(ApiError::BadRequest(format!(
627            "measure {id} is an input; use /eval on a derived measure"
628        )));
629    }
630
631    // Validate every edit, what-if ones too: the engine never sees junk.
632    let coords = req
633        .edits
634        .iter()
635        .map(|e| {
636            let coord = decode_coord(&e.coord);
637            if coord.dims.len() != e.coord.len() {
638                return Err(ApiError::BadRequest("coordinate repeats a category".into()));
639            }
640            validate_write(&model, MeasureId(e.measure), &coord, e.value)?;
641            Ok(coord)
642        })
643        .collect::<Result<Vec<_>, ApiError>>()?;
644
645    // Persist first (so a persisted edit is durable even if the caller only
646    // wants the side effect); the engine below then sees the same values.
647    if req.persist {
648        let mut updated = (*model).clone();
649        for (e, coord) in req.edits.iter().zip(coords) {
650            match e.value {
651                Some(v) => updated.set_input(
652                    MeasureId(e.measure),
653                    coord,
654                    improv_core_model::Value::Number(v),
655                ),
656                None => {
657                    updated.inputs.remove(&(MeasureId(e.measure), coord));
658                }
659            }
660        }
661        model = save_model(&state, updated).await?;
662    }
663    drop(write);
664
665    // All derived measures are the engine's targets.
666    let targets: Vec<MeasureId> = model
667        .measures
668        .iter()
669        .filter(|(_, m)| !m.is_input())
670        .map(|(id, _)| *id)
671        .collect();
672    let edits: Vec<Edit> = req
673        .edits
674        .into_iter()
675        .map(|e| Edit {
676            measure: MeasureId(e.measure),
677            coord: e.coord,
678            value: e.value.map(CellValue::num),
679        })
680        .collect();
681
682    // Engine owns a worker thread and holds a live dataflow: do it all on a
683    // blocking thread and never across an await point.
684    let engine_model = model.clone();
685    let values = tokio::task::spawn_blocking(move || {
686        let (mut engine, _initial) =
687            Engine::new(&engine_model, &targets).map_err(|e| e.to_string())?;
688        let snap = engine.apply(edits).map_err(|e| e.to_string())?;
689        Ok::<_, String>(snap.get(&mid).cloned().unwrap_or_default())
690    })
691    .await
692    .map_err(|e| ApiError::Internal(format!("task join: {e}")))?
693    .map_err(ApiError::Internal)?;
694
695    let cells = values
696        .iter()
697        .map(|(k, n)| readable_key(&model, k, n.as_num().unwrap_or(f64::NAN)))
698        .collect();
699    Ok(Json(cells))
700}
701
702#[derive(Deserialize)]
703struct NlParseReq {
704    text: String,
705}
706
707async fn nl_parse(
708    State(state): State<Arc<AppState>>,
709    body: Result<Json<NlParseReq>, JsonRejection>,
710) -> Result<Json<JsonValue>, ApiError> {
711    let Json(req) = body?;
712    let model = load_model(&state).await?;
713    let ctx = NlContext::new(&model);
714    match parse_nl_formula(&ctx, &req.text) {
715        Ok(formula) => Ok(Json(
716            serde_json::to_value(&formula).map_err(|e| ApiError::Internal(e.to_string()))?,
717        )),
718        Err(e) => Err(ApiError::BadRequest(e.to_string())),
719    }
720}
721
722#[derive(Deserialize)]
723struct NlDescribeReq {
724    formula: JsonValue,
725}
726
727async fn nl_describe(
728    State(state): State<Arc<AppState>>,
729    body: Result<Json<NlDescribeReq>, JsonRejection>,
730) -> Result<Json<JsonValue>, ApiError> {
731    let Json(req) = body?;
732    let formula = serde_json::from_value(req.formula)
733        .map_err(|e| ApiError::BadRequest(format!("invalid formula: {e}")))?;
734    let model = load_model(&state).await?;
735    let ctx = NlContext::new(&model);
736    Ok(Json(json!({ "text": describe_formula(&ctx, &formula) })))
737}
738
739#[derive(Deserialize)]
740struct SetCellReq {
741    coord: Coordinate,
742    value: f64,
743}
744
745async fn set_cell(
746    State(state): State<Arc<AppState>>,
747    ro: Option<Extension<ReadOnly>>,
748    Path(id): Path<u32>,
749    body: Result<Json<SetCellReq>, JsonRejection>,
750) -> Result<Json<JsonValue>, ApiError> {
751    if ro.is_some() {
752        return Err(ApiError::Forbidden("read-only token cannot write".into()));
753    }
754    let Json(req) = body?;
755    let mid = MeasureId(id);
756
757    let _write = state.write_lock.lock().await;
758    let model = load_model(&state).await?;
759    if !model.measures.contains_key(&mid) {
760        return Err(ApiError::NotFound(format!("no measure {id}")));
761    }
762    validate_write(&model, mid, &req.coord, Some(req.value))?;
763    let mut model = (*model).clone();
764    model.set_input(mid, req.coord, improv_core_model::Value::Number(req.value));
765    save_model(&state, model).await?;
766
767    Ok(Json(json!({ "ok": true })))
768}
769
770// --- coordinate rendering helpers ---
771
772fn category_name(model: &Model, c: improv_core_model::CategoryId) -> String {
773    model
774        .categories
775        .get(&c)
776        .map(|cat| cat.name.0.clone())
777        .unwrap_or_else(|| format!("category {}", c.0))
778}
779
780/// Render a coordinate as `[[category_name, item_name], ...]`, sorted by
781/// category id for stability.
782fn readable_cell(model: &Model, coord: &Coordinate, value: f64) -> Cell {
783    let mut pairs: Vec<[String; 2]> = coord
784        .dims
785        .iter()
786        .map(|(cat, item)| {
787            let cat_name = category_name(model, *cat);
788            let item_name = model
789                .items
790                .get(item)
791                .map(|i| i.name.0.clone())
792                .unwrap_or_else(|| format!("item {}", item.0));
793            [cat_name, item_name]
794        })
795        .collect();
796    pairs.sort();
797    Cell {
798        coord: pairs,
799        value,
800    }
801}
802
803fn readable_key(model: &Model, key: &CoordKey, value: f64) -> Cell {
804    readable_cell(model, &decode_coord(key), value)
805}
806
807#[cfg(test)]
808#[cfg_attr(coverage_nightly, coverage(off))]
809mod tests {
810    use super::*;
811    use axum::body::{to_bytes, Body};
812    use axum::http::{Request, StatusCode};
813    use improv_core_model::{
814        BinaryOp, DimensionSpec, Expr, ItemId, Measure, MeasureKind, Name, Value, ValueType,
815    };
816    use improv_core_model::{CategoryId, Coordinate};
817    use tower::util::ServiceExt; // for `oneshot`
818
819    // Canonical Time x Product revenue model, matching the engine's fixture.
820    fn revenue_model() -> Model {
821        let mut m = Model::new();
822        let (time, product) = (CategoryId(1), CategoryId(2));
823        m.add_category(time, "Time");
824        m.add_category(product, "Product");
825        m.add_item(ItemId(10), time, "2025");
826        m.add_item(ItemId(11), time, "2026");
827        m.add_item(ItemId(20), product, "WidgetA");
828        m.add_item(ItemId(21), product, "WidgetB");
829
830        m.add_measure(Measure {
831            id: MeasureId(100),
832            name: Name("Price".into()),
833            value_type: ValueType::Number,
834            categories: vec![product],
835            kind: MeasureKind::Input,
836            description: None,
837        });
838        m.add_measure(Measure {
839            id: MeasureId(101),
840            name: Name("Quantity".into()),
841            value_type: ValueType::Number,
842            categories: vec![time, product],
843            kind: MeasureKind::Input,
844            description: None,
845        });
846        m.add_measure(Measure {
847            id: MeasureId(102),
848            name: Name("Revenue".into()),
849            value_type: ValueType::Number,
850            categories: vec![time, product],
851            kind: MeasureKind::Derived(improv_core_model::Formula::new(Expr::BinaryOp(
852                BinaryOp::Mul,
853                Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
854                Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
855            ))),
856            description: None,
857        });
858
859        let coord = |pairs: &[(CategoryId, ItemId)]| Coordinate::from_pairs(pairs.iter().copied());
860        m.set_input(
861            MeasureId(100),
862            coord(&[(product, ItemId(20))]),
863            Value::Number(10.0),
864        );
865        m.set_input(
866            MeasureId(100),
867            coord(&[(product, ItemId(21))]),
868            Value::Number(20.0),
869        );
870        m.set_input(
871            MeasureId(101),
872            coord(&[(time, ItemId(10)), (product, ItemId(20))]),
873            Value::Number(100.0),
874        );
875        m.set_input(
876            MeasureId(101),
877            coord(&[(time, ItemId(10)), (product, ItemId(21))]),
878            Value::Number(50.0),
879        );
880        m.set_input(
881            MeasureId(101),
882            coord(&[(time, ItemId(11)), (product, ItemId(20))]),
883            Value::Number(120.0),
884        );
885        m.set_input(
886            MeasureId(101),
887            coord(&[(time, ItemId(11)), (product, ItemId(21))]),
888            Value::Number(80.0),
889        );
890        m
891    }
892
893    // Seed a fresh on-disk store in a temp dir and return the router + path.
894    fn seeded_app() -> (Router, std::path::PathBuf) {
895        let dir = std::env::temp_dir().join(format!("improv-test-{}", std::process::id()));
896        std::fs::create_dir_all(&dir).unwrap();
897        let path = dir.join(format!(
898            "model-{}.db",
899            std::time::SystemTime::now()
900                .duration_since(std::time::UNIX_EPOCH)
901                .unwrap()
902                .as_nanos()
903        ));
904        let _ = std::fs::remove_file(&path);
905        let mut store = Store::open(path.to_str().unwrap()).unwrap();
906        store.save_model(&revenue_model()).unwrap();
907        let state = Arc::new(AppState::new(
908            path.to_str().unwrap().to_string(),
909            Auth::Disabled,
910            None,
911        ));
912        (app(state), path)
913    }
914
915    // Same seeded store, but auth enabled with a single accepted token.
916    fn authed_app(token: &str) -> (Router, std::path::PathBuf) {
917        let (_, path) = seeded_app();
918        let state = Arc::new(AppState::new(
919            path.to_str().unwrap().to_string(),
920            Auth::Tokens {
921                rw: HashSet::from([token.to_string()]),
922                ro: HashSet::new(),
923            },
924            None,
925        ));
926        (app(state), path)
927    }
928
929    fn get_with_bearer(uri: &str, token: Option<&str>) -> Request<Body> {
930        let mut b = Request::get(uri);
931        if let Some(t) = token {
932            b = b.header("authorization", format!("Bearer {t}"));
933        }
934        b.body(Body::empty()).unwrap()
935    }
936
937    async fn body_json(resp: Response) -> JsonValue {
938        let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
939        serde_json::from_slice(&bytes).unwrap()
940    }
941
942    #[tokio::test]
943    async fn health_ok() {
944        let (app, _p) = seeded_app();
945        let resp = app
946            .oneshot(Request::get("/health").body(Body::empty()).unwrap())
947            .await
948            .unwrap();
949        assert_eq!(resp.status(), StatusCode::OK);
950        let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
951        assert_eq!(&bytes[..], b"ok");
952    }
953
954    #[tokio::test]
955    async fn lists_measures() {
956        let (app, _p) = seeded_app();
957        let resp = app
958            .oneshot(Request::get("/measures").body(Body::empty()).unwrap())
959            .await
960            .unwrap();
961        assert_eq!(resp.status(), StatusCode::OK);
962        let v = body_json(resp).await;
963        let arr = v.as_array().unwrap();
964        assert_eq!(arr.len(), 3);
965        let revenue = arr.iter().find(|m| m["name"] == "Revenue").unwrap();
966        assert_eq!(revenue["kind"], "derived");
967        let price = arr.iter().find(|m| m["name"] == "Price").unwrap();
968        assert_eq!(price["kind"], "input");
969        assert_eq!(price["categories"], json!(["Product"]));
970    }
971
972    #[tokio::test]
973    async fn derived_values_match_known_revenue() {
974        let (app, _p) = seeded_app();
975        let resp = app
976            .oneshot(
977                Request::get("/measures/102/values")
978                    .body(Body::empty())
979                    .unwrap(),
980            )
981            .await
982            .unwrap();
983        assert_eq!(resp.status(), StatusCode::OK);
984        let v = body_json(resp).await;
985        let cells = v.as_array().unwrap();
986        assert_eq!(cells.len(), 4);
987        // Collect values keyed by the readable coordinate.
988        let mut got = std::collections::HashMap::new();
989        for c in cells {
990            let coord = serde_json::to_string(&c["coord"]).unwrap();
991            got.insert(coord, c["value"].as_f64().unwrap());
992        }
993        // Revenue[2025,WidgetA] = 10*100 = 1000
994        let key = |t: &str, p: &str| {
995            serde_json::to_string(&json!([["Product", p], ["Time", t]])).unwrap()
996        };
997        assert_eq!(got[&key("2025", "WidgetA")], 1000.0);
998        assert_eq!(got[&key("2025", "WidgetB")], 1000.0);
999        assert_eq!(got[&key("2026", "WidgetA")], 1200.0);
1000        assert_eq!(got[&key("2026", "WidgetB")], 1600.0);
1001    }
1002
1003    #[tokio::test]
1004    async fn input_values_returned() {
1005        let (app, _p) = seeded_app();
1006        let resp = app
1007            .oneshot(
1008                Request::get("/measures/100/values")
1009                    .body(Body::empty())
1010                    .unwrap(),
1011            )
1012            .await
1013            .unwrap();
1014        assert_eq!(resp.status(), StatusCode::OK);
1015        let cells = body_json(resp).await;
1016        assert_eq!(cells.as_array().unwrap().len(), 2);
1017    }
1018
1019    #[tokio::test]
1020    async fn unknown_measure_is_404() {
1021        let (app, _p) = seeded_app();
1022        let resp = app
1023            .oneshot(
1024                Request::get("/measures/999/values")
1025                    .body(Body::empty())
1026                    .unwrap(),
1027            )
1028            .await
1029            .unwrap();
1030        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1031    }
1032
1033    #[tokio::test]
1034    async fn nl_parse_price_times_quantity() {
1035        let (app, _p) = seeded_app();
1036        let resp = app
1037            .oneshot(
1038                Request::post("/nl/parse")
1039                    .header("content-type", "application/json")
1040                    .body(Body::from(
1041                        json!({"text": "price times quantity"}).to_string(),
1042                    ))
1043                    .unwrap(),
1044            )
1045            .await
1046            .unwrap();
1047        assert_eq!(resp.status(), StatusCode::OK);
1048        let v = body_json(resp).await;
1049        // Formula -> Expr::BinaryOp(Mul, Ref(100), Ref(101)).
1050        let op = &v["expr"]["BinaryOp"];
1051        assert_eq!(op[0], "Mul");
1052    }
1053
1054    #[tokio::test]
1055    async fn nl_parse_unknown_measure_is_400() {
1056        let (app, _p) = seeded_app();
1057        let resp = app
1058            .oneshot(
1059                Request::post("/nl/parse")
1060                    .header("content-type", "application/json")
1061                    .body(Body::from(
1062                        json!({"text": "widgets times quantity"}).to_string(),
1063                    ))
1064                    .unwrap(),
1065            )
1066            .await
1067            .unwrap();
1068        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1069    }
1070
1071    #[tokio::test]
1072    async fn malformed_body_is_400_no_panic() {
1073        let (app, _p) = seeded_app();
1074        let resp = app
1075            .clone()
1076            .oneshot(
1077                Request::post("/nl/parse")
1078                    .header("content-type", "application/json")
1079                    .body(Body::from("{ not json"))
1080                    .unwrap(),
1081            )
1082            .await
1083            .unwrap();
1084        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1085
1086        // Missing required field also 400.
1087        let resp = app
1088            .oneshot(
1089                Request::post("/nl/parse")
1090                    .header("content-type", "application/json")
1091                    .body(Body::from(json!({"nope": 1}).to_string()))
1092                    .unwrap(),
1093            )
1094            .await
1095            .unwrap();
1096        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1097    }
1098
1099    #[tokio::test]
1100    async fn nl_describe_round_trips() {
1101        let (app, _p) = seeded_app();
1102        // First parse to get a formula JSON, then describe it.
1103        let resp = app
1104            .clone()
1105            .oneshot(
1106                Request::post("/nl/parse")
1107                    .header("content-type", "application/json")
1108                    .body(Body::from(
1109                        json!({"text": "price times quantity"}).to_string(),
1110                    ))
1111                    .unwrap(),
1112            )
1113            .await
1114            .unwrap();
1115        let formula = body_json(resp).await;
1116        let resp = app
1117            .oneshot(
1118                Request::post("/nl/describe")
1119                    .header("content-type", "application/json")
1120                    .body(Body::from(json!({"formula": formula}).to_string()))
1121                    .unwrap(),
1122            )
1123            .await
1124            .unwrap();
1125        assert_eq!(resp.status(), StatusCode::OK);
1126        let v = body_json(resp).await;
1127        assert_eq!(v["text"], "Price times Quantity");
1128    }
1129
1130    /// Another process writes the store after the server cached its model: the
1131    /// server's next write must 409 (not retract that write), and a retry,
1132    /// which reloads, succeeds.
1133    #[tokio::test]
1134    async fn write_over_another_processes_change_is_409() {
1135        let (app, p) = seeded_app();
1136        let get = || Request::get("/measures").body(Body::empty()).unwrap();
1137        assert_eq!(
1138            app.clone().oneshot(get()).await.unwrap().status(),
1139            StatusCode::OK
1140        );
1141
1142        let mut other = Store::open(p.to_str().unwrap()).unwrap();
1143        let mut m = other.load_model().unwrap();
1144        let c = Coordinate::from_pairs([(improv_core_model::CategoryId(2), ItemId(21))]);
1145        m.set_input(
1146            MeasureId(100),
1147            c.clone(),
1148            improv_core_model::Value::Number(77.0),
1149        );
1150        other.save_model(&m).unwrap();
1151
1152        let post = || {
1153            Request::post("/measures/100/cells")
1154                .header("content-type", "application/json")
1155                .body(Body::from(
1156                    json!({"coord": {"dims": {"2": 20}}, "value": 42.0}).to_string(),
1157                ))
1158                .unwrap()
1159        };
1160        let resp = app.clone().oneshot(post()).await.unwrap();
1161        assert_eq!(resp.status(), StatusCode::CONFLICT);
1162        assert!(body_json(resp).await["error"]
1163            .as_str()
1164            .unwrap()
1165            .contains("reload"));
1166        assert_eq!(app.oneshot(post()).await.unwrap().status(), StatusCode::OK);
1167        let after = Store::open(p.to_str().unwrap())
1168            .unwrap()
1169            .load_model()
1170            .unwrap();
1171        assert_eq!(
1172            after.input(MeasureId(100), &c),
1173            Some(&improv_core_model::Value::Number(77.0))
1174        );
1175    }
1176
1177    #[tokio::test]
1178    async fn set_cell_persists() {
1179        let (app, _p) = seeded_app();
1180        let body = json!({
1181            "coord": {"dims": {"2": 20}},
1182            "value": 42.0
1183        });
1184        let resp = app
1185            .clone()
1186            .oneshot(
1187                Request::post("/measures/100/cells")
1188                    .header("content-type", "application/json")
1189                    .body(Body::from(body.to_string()))
1190                    .unwrap(),
1191            )
1192            .await
1193            .unwrap();
1194        assert_eq!(resp.status(), StatusCode::OK);
1195
1196        // Re-read Price values; WidgetA should now be 42.
1197        let resp = app
1198            .oneshot(
1199                Request::get("/measures/100/values")
1200                    .body(Body::empty())
1201                    .unwrap(),
1202            )
1203            .await
1204            .unwrap();
1205        let cells = body_json(resp).await;
1206        let has_42 = cells
1207            .as_array()
1208            .unwrap()
1209            .iter()
1210            .any(|c| c["value"].as_f64() == Some(42.0));
1211        assert!(has_42, "updated cell value should persist");
1212    }
1213
1214    // Read Revenue[2025,WidgetA] from a /values or /eval response body.
1215    fn revenue_2025_widgeta(cells: &JsonValue) -> Option<f64> {
1216        let want = json!([["Product", "WidgetA"], ["Time", "2025"]]);
1217        cells
1218            .as_array()
1219            .unwrap()
1220            .iter()
1221            .find(|c| c["coord"] == want)
1222            .and_then(|c| c["value"].as_f64())
1223    }
1224
1225    #[tokio::test]
1226    async fn eval_what_if_does_not_persist() {
1227        let (app, _p) = seeded_app();
1228        // Set Quantity[2025,WidgetA] (time cat 1/item 10, product cat 2/item 20)
1229        // from 100 to 200 -> Revenue there should be 10*200 = 2000 in response.
1230        let body = json!({
1231            "edits": [{"measure": 101, "coord": [[1, 10], [2, 20]], "value": 200.0}]
1232        });
1233        let resp = app
1234            .clone()
1235            .oneshot(
1236                Request::post("/measures/102/eval")
1237                    .header("content-type", "application/json")
1238                    .body(Body::from(body.to_string()))
1239                    .unwrap(),
1240            )
1241            .await
1242            .unwrap();
1243        assert_eq!(resp.status(), StatusCode::OK);
1244        let cells = body_json(resp).await;
1245        assert_eq!(revenue_2025_widgeta(&cells), Some(2000.0));
1246
1247        // Without persist, the stored model is untouched: GET /values still 1000.
1248        let resp = app
1249            .oneshot(
1250                Request::get("/measures/102/values")
1251                    .body(Body::empty())
1252                    .unwrap(),
1253            )
1254            .await
1255            .unwrap();
1256        let cells = body_json(resp).await;
1257        assert_eq!(revenue_2025_widgeta(&cells), Some(1000.0));
1258    }
1259
1260    #[tokio::test]
1261    async fn eval_with_persist_writes_through() {
1262        let (app, _p) = seeded_app();
1263        let body = json!({
1264            "persist": true,
1265            "edits": [{"measure": 101, "coord": [[1, 10], [2, 20]], "value": 200.0}]
1266        });
1267        let resp = app
1268            .clone()
1269            .oneshot(
1270                Request::post("/measures/102/eval")
1271                    .header("content-type", "application/json")
1272                    .body(Body::from(body.to_string()))
1273                    .unwrap(),
1274            )
1275            .await
1276            .unwrap();
1277        assert_eq!(resp.status(), StatusCode::OK);
1278        let cells = body_json(resp).await;
1279        assert_eq!(revenue_2025_widgeta(&cells), Some(2000.0));
1280
1281        // With persist, GET /values now reflects the change: 10*200 = 2000.
1282        let resp = app
1283            .oneshot(
1284                Request::get("/measures/102/values")
1285                    .body(Body::empty())
1286                    .unwrap(),
1287            )
1288            .await
1289            .unwrap();
1290        let cells = body_json(resp).await;
1291        assert_eq!(revenue_2025_widgeta(&cells), Some(2000.0));
1292    }
1293
1294    #[tokio::test]
1295    async fn eval_on_input_measure_is_400() {
1296        let (app, _p) = seeded_app();
1297        let resp = app
1298            .oneshot(
1299                Request::post("/measures/101/eval")
1300                    .header("content-type", "application/json")
1301                    .body(Body::from(json!({"edits": []}).to_string()))
1302                    .unwrap(),
1303            )
1304            .await
1305            .unwrap();
1306        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1307    }
1308
1309    #[tokio::test]
1310    async fn eval_unknown_measure_is_404() {
1311        let (app, _p) = seeded_app();
1312        let resp = app
1313            .oneshot(
1314                Request::post("/measures/999/eval")
1315                    .header("content-type", "application/json")
1316                    .body(Body::from(json!({"edits": []}).to_string()))
1317                    .unwrap(),
1318            )
1319            .await
1320            .unwrap();
1321        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1322    }
1323
1324    #[tokio::test]
1325    async fn eval_malformed_body_is_400_no_panic() {
1326        let (app, _p) = seeded_app();
1327        let resp = app
1328            .oneshot(
1329                Request::post("/measures/102/eval")
1330                    .header("content-type", "application/json")
1331                    .body(Body::from("{ not json"))
1332                    .unwrap(),
1333            )
1334            .await
1335            .unwrap();
1336        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1337    }
1338
1339    #[tokio::test]
1340    async fn authed_health_is_public() {
1341        let (app, _p) = authed_app("secret");
1342        let resp = app.oneshot(get_with_bearer("/health", None)).await.unwrap();
1343        assert_eq!(resp.status(), StatusCode::OK);
1344    }
1345
1346    #[tokio::test]
1347    async fn authed_protected_route_requires_token() {
1348        let (app, _p) = authed_app("secret");
1349        // No header -> 401.
1350        let resp = app
1351            .clone()
1352            .oneshot(get_with_bearer("/measures", None))
1353            .await
1354            .unwrap();
1355        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1356        // Wrong token -> 403.
1357        let resp = app
1358            .clone()
1359            .oneshot(get_with_bearer("/measures", Some("nope")))
1360            .await
1361            .unwrap();
1362        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1363        // Correct token -> 200.
1364        let resp = app
1365            .oneshot(get_with_bearer("/measures", Some("secret")))
1366            .await
1367            .unwrap();
1368        assert_eq!(resp.status(), StatusCode::OK);
1369    }
1370
1371    #[tokio::test]
1372    async fn scheduler_status_is_protected_and_reports_disabled() {
1373        // Disabled scheduler (test apps carry `scheduler_status: None`).
1374        let (app, _p) = seeded_app();
1375        let resp = app
1376            .oneshot(
1377                Request::get("/scheduler/status")
1378                    .body(Body::empty())
1379                    .unwrap(),
1380            )
1381            .await
1382            .unwrap();
1383        assert_eq!(resp.status(), StatusCode::OK);
1384        let v = body_json(resp).await;
1385        assert_eq!(v["enabled"], false);
1386
1387        // Auth still gates it like any other protected route.
1388        let (app, _p) = authed_app("secret");
1389        let resp = app
1390            .oneshot(get_with_bearer("/scheduler/status", None))
1391            .await
1392            .unwrap();
1393        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1394    }
1395
1396    fn post_json(uri: &str, body: String) -> Request<Body> {
1397        Request::post(uri)
1398            .header("content-type", "application/json")
1399            .body(Body::from(body))
1400            .unwrap()
1401    }
1402
1403    #[tokio::test]
1404    async fn over_limit_body_is_413() {
1405        let (app, _p) = seeded_app();
1406        let big = format!("{{\"text\":\"{}\"}}", "x".repeat(MAX_BODY_BYTES + 1));
1407        let resp = app.oneshot(post_json("/nl/parse", big)).await.unwrap();
1408        assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
1409    }
1410
1411    #[tokio::test]
1412    async fn too_many_edits_is_400() {
1413        let (app, _p) = seeded_app();
1414        let edit = json!({"measure": 101, "coord": [[1, 10], [2, 20]], "value": 1.0});
1415        let body = json!({ "edits": vec![edit; MAX_EDITS + 1] }).to_string();
1416        assert!(
1417            body.len() < MAX_BODY_BYTES,
1418            "must hit the edit cap, not the body cap"
1419        );
1420        let resp = app
1421            .oneshot(post_json("/measures/102/eval", body))
1422            .await
1423            .unwrap();
1424        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1425        assert!(body_json(resp).await["error"]
1426            .as_str()
1427            .unwrap()
1428            .contains("too many edits"));
1429    }
1430
1431    // Slow-loris: a partial request must be dropped once the header timeout
1432    // passes, not held open forever. Returns the time until the server closes.
1433    async fn partial_request_closed_after(header_timeout: Duration) -> Duration {
1434        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1435        let (app, _p) = seeded_app();
1436        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1437        let addr = listener.local_addr().unwrap();
1438        let server = tokio::spawn(serve(listener, app, header_timeout));
1439        let mut c = tokio::net::TcpStream::connect(addr).await.unwrap();
1440        c.write_all(b"GET /health HTTP/1.1\r\nHost: x\r\n")
1441            .await
1442            .unwrap();
1443        let start = std::time::Instant::now();
1444        let mut buf = Vec::new();
1445        // EOF (or a reset) is the server hanging up; cap the wait well past it.
1446        let _ = tokio::time::timeout(header_timeout * 3, c.read_to_end(&mut buf))
1447            .await
1448            .expect("server should close a stalled connection");
1449        server.abort();
1450        start.elapsed()
1451    }
1452
1453    #[tokio::test]
1454    async fn slow_loris_closed_fast_timeout() {
1455        let t = partial_request_closed_after(Duration::from_millis(200)).await;
1456        assert!(t < Duration::from_secs(2), "closed after {t:?}");
1457    }
1458
1459    #[tokio::test]
1460    #[ignore = "takes ~10 s (default header timeout)"]
1461    async fn slow_loris_closed_default_timeout() {
1462        let t = partial_request_closed_after(HEADER_READ_TIMEOUT).await;
1463        assert!(t < Duration::from_secs(11), "closed after {t:?}");
1464    }
1465
1466    #[tokio::test]
1467    async fn internal_error_hides_store_path() {
1468        let state = Arc::new(AppState::new(
1469            "/nonexistent-improv-dir/secret-store.db".into(),
1470            Auth::Disabled,
1471            None,
1472        ));
1473        let resp = app(state)
1474            .oneshot(Request::get("/measures").body(Body::empty()).unwrap())
1475            .await
1476            .unwrap();
1477        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1478        let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1479        let text = String::from_utf8_lossy(&bytes);
1480        assert!(
1481            !text.contains("nonexistent-improv-dir"),
1482            "path leaked: {text}"
1483        );
1484        let v: JsonValue = serde_json::from_slice(&bytes).unwrap();
1485        assert_eq!(v["error"], "internal error");
1486        assert!(v["request_id"].as_u64().is_some());
1487    }
1488
1489    #[test]
1490    fn ct_eq_and_token_matches() {
1491        assert!(ct_eq(b"secret", b"secret"));
1492        assert!(!ct_eq(b"secret", b"secreT"));
1493        assert!(!ct_eq(b"secret", b"secret2"));
1494        assert!(!ct_eq(b"", b"x"));
1495        let toks = HashSet::from(["a1".to_string(), "b2".to_string()]);
1496        assert!(token_matches(&toks, "b2"));
1497        assert!(!token_matches(&toks, "b"));
1498    }
1499
1500    #[test]
1501    fn startup_check_refuses_open_non_loopback() {
1502        let open = Auth::Disabled;
1503        let toks = Auth::Tokens {
1504            rw: HashSet::from(["t".to_string()]),
1505            ro: HashSet::new(),
1506        };
1507        let lo: SocketAddr = "127.0.0.1:3000".parse().unwrap();
1508        let lo6: SocketAddr = "[::1]:3000".parse().unwrap();
1509        let any: SocketAddr = "0.0.0.0:3000".parse().unwrap();
1510        assert!(startup_check(lo, &open, None).is_ok());
1511        assert!(startup_check(lo6, &open, None).is_ok());
1512        assert!(startup_check(any, &open, None).is_err());
1513        assert!(startup_check(any, &open, Some("0")).is_err());
1514        assert!(startup_check(any, &open, Some("1")).is_ok());
1515        assert!(startup_check(any, &toks, None).is_ok());
1516    }
1517
1518    fn post_cell(uri: &str, body: JsonValue, token: Option<&str>) -> Request<Body> {
1519        let mut b = Request::post(uri).header("content-type", "application/json");
1520        if let Some(t) = token {
1521            b = b.header("authorization", format!("Bearer {t}"));
1522        }
1523        b.body(Body::from(body.to_string())).unwrap()
1524    }
1525
1526    #[tokio::test]
1527    async fn write_to_derived_or_unknown_measure_is_rejected() {
1528        let (app, _p) = seeded_app();
1529        let cell = json!({"coord": {"dims": {"1": 10, "2": 20}}, "value": 1.0});
1530        let resp = app
1531            .clone()
1532            .oneshot(post_cell("/measures/102/cells", cell.clone(), None));
1533        assert_eq!(resp.await.unwrap().status(), StatusCode::BAD_REQUEST);
1534        let resp = app
1535            .clone()
1536            .oneshot(post_cell("/measures/999/cells", cell, None));
1537        assert_eq!(resp.await.unwrap().status(), StatusCode::NOT_FOUND);
1538        // /eval edits must target an existing input measure too.
1539        for m in [102, 999] {
1540            let body = json!({"persist": true,
1541                "edits": [{"measure": m, "coord": [[1, 10], [2, 20]], "value": 1.0}]});
1542            let resp = app
1543                .clone()
1544                .oneshot(post_cell("/measures/102/eval", body, None));
1545            assert_eq!(
1546                resp.await.unwrap().status(),
1547                StatusCode::BAD_REQUEST,
1548                "measure {m}"
1549            );
1550        }
1551    }
1552
1553    #[tokio::test]
1554    async fn write_with_bad_coordinate_is_400() {
1555        let (app, _p) = seeded_app();
1556        // Price is dimensioned by Product (cat 2) only.
1557        for dims in [
1558            json!({"1": 10}),          // wrong category
1559            json!({"2": 10}),          // item 10 belongs to Time, not Product
1560            json!({"2": 20, "1": 10}), // extra dimension
1561            json!({"2": 99}),          // unknown item
1562            json!({}),                 // missing dimension
1563        ] {
1564            let body = json!({"coord": {"dims": dims}, "value": 1.0});
1565            let resp = app
1566                .clone()
1567                .oneshot(post_cell("/measures/100/cells", body, None));
1568            assert_eq!(
1569                resp.await.unwrap().status(),
1570                StatusCode::BAD_REQUEST,
1571                "{dims}"
1572            );
1573        }
1574        for coord in [json!([[2, 20]]), json!([[1, 10], [1, 11], [2, 20]])] {
1575            let body = json!({"edits": [{"measure": 101, "coord": coord, "value": 1.0}]});
1576            let resp = app
1577                .clone()
1578                .oneshot(post_cell("/measures/102/eval", body, None));
1579            assert_eq!(
1580                resp.await.unwrap().status(),
1581                StatusCode::BAD_REQUEST,
1582                "{coord}"
1583            );
1584        }
1585    }
1586
1587    #[test]
1588    fn validate_write_rejects_non_finite() {
1589        let m = revenue_model();
1590        let c = Coordinate::from_pairs([(CategoryId(2), ItemId(20))]);
1591        assert!(validate_write(&m, MeasureId(100), &c, Some(1.0)).is_ok());
1592        assert!(validate_write(&m, MeasureId(100), &c, None).is_ok());
1593        for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1594            assert!(validate_write(&m, MeasureId(100), &c, Some(v)).is_err());
1595        }
1596    }
1597
1598    #[tokio::test]
1599    async fn read_only_token_can_read_not_write() {
1600        let (_, path) = seeded_app();
1601        let auth = Auth::Tokens {
1602            rw: HashSet::from(["w".to_string()]),
1603            ro: HashSet::from(["r".to_string()]),
1604        };
1605        let app = app(Arc::new(AppState::new(
1606            path.to_str().unwrap().to_string(),
1607            auth,
1608            None,
1609        )));
1610        let resp = app.clone().oneshot(get_with_bearer("/measures", Some("r")));
1611        assert_eq!(resp.await.unwrap().status(), StatusCode::OK);
1612        let cell = json!({"coord": {"dims": {"2": 20}}, "value": 5.0});
1613        let resp = app
1614            .clone()
1615            .oneshot(post_cell("/measures/100/cells", cell.clone(), Some("r")));
1616        assert_eq!(resp.await.unwrap().status(), StatusCode::FORBIDDEN);
1617        let edit = json!([{"measure": 101, "coord": [[1, 10], [2, 20]], "value": 1.0}]);
1618        let persist = json!({"persist": true, "edits": edit});
1619        let resp = app
1620            .clone()
1621            .oneshot(post_cell("/measures/102/eval", persist, Some("r")));
1622        assert_eq!(resp.await.unwrap().status(), StatusCode::FORBIDDEN);
1623        let what_if = json!({"edits": edit});
1624        let resp = app
1625            .clone()
1626            .oneshot(post_cell("/measures/102/eval", what_if, Some("r")));
1627        assert_eq!(resp.await.unwrap().status(), StatusCode::OK);
1628        let resp = app.oneshot(post_cell("/measures/100/cells", cell, Some("w")));
1629        assert_eq!(resp.await.unwrap().status(), StatusCode::OK);
1630    }
1631
1632    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1633    async fn concurrent_writes_do_not_lose_updates() {
1634        let (app, _p) = seeded_app();
1635        let mut tasks = Vec::new();
1636        for (t, p) in [(10, 20), (10, 21), (11, 20), (11, 21)] {
1637            let app = app.clone();
1638            tasks.push(tokio::spawn(async move {
1639                let body = json!({"coord": {"dims": {"1": t, "2": p}}, "value": 1000.0 + f64::from(t * 100 + p)});
1640                app.oneshot(post_cell("/measures/101/cells", body, None)).await.unwrap().status()
1641            }));
1642        }
1643        for t in tasks {
1644            assert_eq!(t.await.unwrap(), StatusCode::OK);
1645        }
1646        let resp = app.oneshot(
1647            Request::get("/measures/101/values")
1648                .body(Body::empty())
1649                .unwrap(),
1650        );
1651        let cells = body_json(resp.await.unwrap()).await;
1652        let mut vals: Vec<f64> = cells
1653            .as_array()
1654            .unwrap()
1655            .iter()
1656            .map(|c| c["value"].as_f64().unwrap())
1657            .collect();
1658        vals.sort_by(f64::total_cmp);
1659        assert_eq!(
1660            vals,
1661            vec![2020.0, 2021.0, 2120.0, 2121.0],
1662            "an update was lost"
1663        );
1664    }
1665
1666    #[tokio::test]
1667    async fn reads_share_one_load_until_a_write() {
1668        let (_, path) = seeded_app();
1669        let state = Arc::new(AppState::new(
1670            path.to_str().unwrap().into(),
1671            Auth::Disabled,
1672            None,
1673        ));
1674        let app = app(state.clone());
1675        let get = |uri: &str| Request::get(uri).body(Body::empty()).unwrap();
1676        for uri in ["/measures", "/measures/102/values"] {
1677            let resp = app.clone().oneshot(get(uri)).await.unwrap();
1678            assert_eq!(resp.status(), StatusCode::OK);
1679        }
1680        assert_eq!(state.loads.load(Ordering::Relaxed), 1, "two GETs, one load");
1681
1682        // A write replaces the cache: the next read sees it without reloading.
1683        let body = json!({"coord": {"dims": {"2": 20}}, "value": 42.0});
1684        let resp = app
1685            .clone()
1686            .oneshot(post_cell("/measures/100/cells", body, None));
1687        assert_eq!(resp.await.unwrap().status(), StatusCode::OK);
1688        let resp = app.oneshot(get("/measures/100/values")).await.unwrap();
1689        let cells = body_json(resp).await;
1690        assert!(cells.as_array().unwrap().iter().any(|c| c["value"] == 42.0));
1691        assert_eq!(state.loads.load(Ordering::Relaxed), 1);
1692    }
1693
1694    #[test]
1695    fn auth_from_env_reads_rw_and_ro_tokens() {
1696        // The only test touching these vars (`main` is their only other reader).
1697        std::env::set_var("IMPROV_API_TOKEN", " solo ");
1698        std::env::set_var("IMPROV_API_TOKENS", "a, b,,");
1699        std::env::set_var("IMPROV_API_TOKENS_RO", "r");
1700        let Auth::Tokens { rw, ro } = Auth::from_env() else {
1701            panic!("tokens are set");
1702        };
1703        assert_eq!(rw, HashSet::from(["solo", "a", "b"].map(String::from)));
1704        assert_eq!(ro, HashSet::from(["r".to_string()]));
1705
1706        // A blank single token is ignored; read-only tokens alone still enable auth.
1707        std::env::set_var("IMPROV_API_TOKEN", "  ");
1708        std::env::remove_var("IMPROV_API_TOKENS");
1709        let Auth::Tokens { rw, ro } = Auth::from_env() else {
1710            panic!("a read-only token is set");
1711        };
1712        assert!(rw.is_empty());
1713        assert_eq!(ro.len(), 1);
1714
1715        std::env::remove_var("IMPROV_API_TOKEN");
1716        std::env::remove_var("IMPROV_API_TOKENS_RO");
1717        assert!(matches!(Auth::from_env(), Auth::Disabled));
1718    }
1719
1720    // A request still waiting when REQUEST_TIMEOUT elapses gets 408 (S10).
1721    #[tokio::test(start_paused = true)]
1722    async fn request_past_the_deadline_is_408() {
1723        let state = Arc::new(AppState::new("unused.db".into(), Auth::Disabled, None));
1724        let _held = state.write_lock.lock().await;
1725        let body = json!({"coord": {"dims": {"2": 20}}, "value": 1.0});
1726        let resp = app(state.clone())
1727            .oneshot(post_cell("/measures/100/cells", body, None))
1728            .await
1729            .unwrap();
1730        assert_eq!(resp.status(), StatusCode::REQUEST_TIMEOUT);
1731    }
1732
1733    #[tokio::test]
1734    async fn scheduler_status_reports_a_failing_tick() {
1735        let status: SharedStatus = Arc::new(std::sync::Mutex::new(scheduler::SchedulerStatus {
1736            tick_secs: 1,
1737            ..Default::default()
1738        }));
1739        let state = Arc::new(AppState::new(
1740            "/nonexistent-improv-dir/sched.db".into(),
1741            Auth::Disabled,
1742            Some(status.clone()),
1743        ));
1744        let handle = scheduler::spawn_scheduler(
1745            state.clone(),
1746            Duration::from_millis(10),
1747            status.clone(),
1748            Default::default(),
1749        );
1750        for _ in 0..500 {
1751            if status.lock().unwrap().last_error.is_some() {
1752                break;
1753            }
1754            tokio::time::sleep(Duration::from_millis(10)).await;
1755        }
1756        handle.abort();
1757        let resp = app(state)
1758            .oneshot(
1759                Request::get("/scheduler/status")
1760                    .body(Body::empty())
1761                    .unwrap(),
1762            )
1763            .await
1764            .unwrap();
1765        let v = body_json(resp).await;
1766        assert_eq!(v["enabled"], true);
1767        assert_eq!(v["tick_secs"], 1);
1768        assert!(v["last_error"].is_string(), "{v}");
1769    }
1770
1771    #[tokio::test]
1772    async fn get_model_returns_the_stored_model() {
1773        let (app, _p) = seeded_app();
1774        let resp = app
1775            .oneshot(Request::get("/model").body(Body::empty()).unwrap())
1776            .await
1777            .unwrap();
1778        assert_eq!(resp.status(), StatusCode::OK);
1779        assert_eq!(
1780            body_json(resp).await["measures"].as_object().unwrap().len(),
1781            3
1782        );
1783    }
1784
1785    // A formula naming a missing measure is a 500, not a panic; a cell whose
1786    // coordinate names a missing category/item renders a placeholder name.
1787    #[tokio::test]
1788    async fn unresolvable_model_parts_are_reported_not_panicked() {
1789        let mut m = revenue_model();
1790        m.add_measure(Measure {
1791            id: MeasureId(103),
1792            name: Name("Broken".into()),
1793            value_type: ValueType::Number,
1794            categories: vec![CategoryId(1)],
1795            kind: MeasureKind::Derived(improv_core_model::Formula::new(Expr::Ref(
1796                MeasureId(999),
1797                DimensionSpec::default(),
1798            ))),
1799            description: None,
1800        });
1801        m.set_input(
1802            MeasureId(100),
1803            Coordinate::from_pairs([(CategoryId(9), ItemId(99))]),
1804            Value::Number(1.0),
1805        );
1806        let (app, path) = seeded_app();
1807        Store::open(path.to_str().unwrap())
1808            .unwrap()
1809            .save_model(&m)
1810            .unwrap();
1811
1812        let get = |uri: &str| Request::get(uri).body(Body::empty()).unwrap();
1813        let resp = app
1814            .clone()
1815            .oneshot(get("/measures/103/values"))
1816            .await
1817            .unwrap();
1818        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1819        let resp = app
1820            .clone()
1821            .oneshot(post_json("/measures/102/eval", "{}".into()))
1822            .await
1823            .unwrap();
1824        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1825
1826        let resp = app
1827            .clone()
1828            .oneshot(get("/measures/100/values"))
1829            .await
1830            .unwrap();
1831        let cells = body_json(resp).await;
1832        assert!(
1833            cells
1834                .as_array()
1835                .unwrap()
1836                .iter()
1837                .any(|c| c["coord"] == json!([["category 9", "item 99"]])),
1838            "{cells}"
1839        );
1840
1841        let resp = app
1842            .oneshot(post_json("/nl/describe", json!({"formula": 5}).to_string()))
1843            .await
1844            .unwrap();
1845        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1846    }
1847}