Skip to main content

tensor_wasm_api/
server.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Axum router builder and listener.
5
6use std::net::SocketAddr;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9use std::time::Duration;
10
11use axum::http::StatusCode;
12use axum::response::{IntoResponse, Response};
13use axum::routing::{delete, get, post};
14use axum::{Json, Router};
15use serde_json::json;
16use subtle::ConstantTimeEq;
17use tower::ServiceBuilder;
18
19use crate::audit::{audit_log_middleware, AuditConfig, TrustedProxies};
20use crate::http_metrics::{http_metrics_middleware, HttpMetricsLayerConfig, RouteAllowList};
21use crate::middleware::{
22    bearer_auth, body_limit_layer, concurrency_limit_layer, cors_layer, host_validate,
23    tenant_scope, timeout_layer, trace_layer_with_propagation, AuthConfig, CorsConfig,
24    KernelPublishTokens, TenantConfig, TrustedHosts, ENV_API_TOKENS, ENV_TRUSTED_HOSTS,
25    INVOKE_CONCURRENCY_LIMIT, MAX_REQUEST_BODY_BYTES, PROBE_CONCURRENCY_LIMIT,
26    READ_CONCURRENCY_LIMIT, WRITE_CONCURRENCY_LIMIT,
27};
28use crate::rate_limit::{rate_limit, RateLimitConfig, RateLimiter};
29use crate::routes::{
30    create_function, delete_function, get_job, healthz, invoke_function, invoke_function_async,
31    invoke_function_stream, metrics, snapshot_restore, snapshot_save, AppState,
32};
33use crate::trace_propagation::{inject_trace_id_header, install_w3c_propagator};
34
35/// Build the axum Router with all routes and middleware applied.
36///
37/// Reads [`AuthConfig`] (`$TENSOR_WASM_API_TOKENS`), [`TenantConfig`]
38/// (`$TENSOR_WASM_API_REQUIRE_TENANT`), [`RateLimitConfig`]
39/// (`$TENSOR_WASM_API_RATE_LIMIT_QPS` / `$TENSOR_WASM_API_RATE_LIMIT_BURST`),
40/// and [`CorsConfig`] (`$TENSOR_WASM_API_CORS_ALLOWED_ORIGINS`) from the
41/// process environment. Empty / unset `TENSOR_WASM_API_TOKENS` puts the
42/// gateway in dev mode (auth disabled with a startup warning); unset or
43/// zero rate-limit knobs disable the limiter (pass-through); empty / unset
44/// CORS allowlist rejects every cross-origin request (safe default).
45///
46/// If any bare (unscoped) entries are present in `TENSOR_WASM_API_TOKENS`,
47/// `AuthConfig::from_env` emits a one-shot deprecation warning naming the
48/// count — scoped tokens (`token:tenant=...`) are the supported form going
49/// forward and bare entries are scheduled for removal in v1.0.
50pub fn build_router(state: Arc<AppState>) -> Router {
51    let auth = AuthConfig::from_env();
52    let tenant = TenantConfig::from_env();
53    let limiter = RateLimiter::new(RateLimitConfig::from_env());
54    let audit = AuditConfig::from_env();
55    let cors = CorsConfig::from_env();
56    // M5: read the top-level `AppConfig` (snapshot HMAC signing key +
57    // require-signature toggle) from the environment and thread it onto
58    // the shared `AppState` so the `/snapshot/save` and `/snapshot/restore`
59    // handlers consume `TENSOR_WASM_API_SNAPSHOT_HMAC_KEY`. This is the
60    // production caller that turns the previously-inert key into a
61    // live knob (closes finding M5). A malformed key is a hard startup
62    // error — we refuse to come up serving snapshot routes under a
63    // misconfigured signing key rather than silently degrade.
64    let state = apply_app_config_from_env(state);
65    build_router_with_audit(state, auth, tenant, limiter, audit, cors)
66}
67
68/// Read [`AppConfig::from_env`](crate::config::AppConfig::from_env) and
69/// install it onto `state` so the snapshot routes pick up the operator's
70/// signing key.
71///
72/// On a malformed snapshot key / toggle this logs the parse error and
73/// **panics** — unlike the kernel registry (which degrades to `503`), a
74/// snapshot signing-key misconfiguration must be fatal at startup, because
75/// silently dropping the key would downgrade restore integrity (a wrong
76/// or empty key would let unverified blobs through once the routes are
77/// reachable). The hard-fail mirrors the contract documented on
78/// `AppConfig::from_env`.
79///
80/// `state` arrives behind an `Arc`; we recover the inner value when we are
81/// the sole owner (the normal startup case) and otherwise clone-and-replace
82/// the `app_config` field so the contract holds even if a caller has
83/// already shared the handle.
84fn apply_app_config_from_env(state: Arc<AppState>) -> Arc<AppState> {
85    let cfg = crate::config::AppConfig::from_env().unwrap_or_else(|e| {
86        // Key-free: `ConfigError::Display` never prints the secret bytes.
87        panic!(
88            "invalid snapshot configuration (TENSOR_WASM_API_SNAPSHOT_*): {e}; \
89             refusing to start with a misconfigured snapshot signing key",
90        );
91    });
92    match Arc::try_unwrap(state) {
93        Ok(inner) => Arc::new(inner.with_app_config(cfg)),
94        Err(shared) => {
95            // The handle was already cloned elsewhere; rebuild a fresh
96            // `AppState` carrying the same registries/executor with the
97            // config applied. Cloning `AppState` is cheap — the maps,
98            // metrics, and executor all sit behind their own `Arc`.
99            Arc::new((*shared).clone().with_app_config(cfg))
100        }
101    }
102}
103
104/// Build the router with explicit auth / tenant config and the rate limiter
105/// disabled. Backwards-compatible shim retained for integration tests that
106/// pre-date the per-token rate limiter; new tests should call
107/// [`build_router_with_full_config`].
108pub fn build_router_with_config(
109    state: Arc<AppState>,
110    auth: AuthConfig,
111    tenant: TenantConfig,
112) -> Router {
113    build_router_with_full_config(
114        state,
115        auth,
116        tenant,
117        RateLimiter::new(RateLimitConfig::disabled()),
118    )
119}
120
121/// Build the router with explicitly supplied auth, tenant, and rate-limit
122/// config. Used by integration tests so they can drive the gateway without
123/// poisoning the process environment.
124///
125/// The audit log defaults to the no-op sink in this constructor: existing
126/// integration tests that pre-date the audit middleware must not have
127/// their stdout polluted by audit records. Tests that exercise the audit
128/// path explicitly should call [`build_router_with_audit`].
129pub fn build_router_with_full_config(
130    state: Arc<AppState>,
131    auth: AuthConfig,
132    tenant: TenantConfig,
133    limiter: RateLimiter,
134) -> Router {
135    build_router_with_audit(
136        state,
137        auth,
138        tenant,
139        limiter,
140        AuditConfig::disabled(),
141        CorsConfig::default(),
142    )
143}
144
145/// Build the router with full configuration including the audit sink and
146/// the CORS allowlist.
147///
148/// The outer ServiceBuilder is layered top-to-bottom: `host_validate`
149/// (with its `TrustedHosts` extension) is the outermost pair — T19 perf
150/// — so hostile Host probes are rejected with `400` before any trace
151/// span is allocated or any propagator hop runs. The trace layer wraps
152/// the rest of the stack, followed by `inject_trace_id_header`, the
153/// CORS layer (so cross-origin preflight short-circuits before any
154/// expensive downstream work), the body limit (which guards every
155/// downstream layer from oversized payloads), the per-request timeout
156/// and the global concurrency cap. Auth and tenant resolution are
157/// `from_fn` middleware that run after the size cap (so a request that
158/// would be rejected with 413 does not consume an auth slot). The
159/// per-token rate limiter runs after bearer auth so it can read the
160/// AuthContext the auth layer inserts. The audit middleware sits
161/// innermost, after every other layer has resolved the actor / tenant /
162/// scope, so the synthesised record captures the same identity the
163/// handler saw.
164pub fn build_router_with_audit(
165    state: Arc<AppState>,
166    auth: AuthConfig,
167    tenant: TenantConfig,
168    limiter: RateLimiter,
169    audit: AuditConfig,
170    cors: CorsConfig,
171) -> Router {
172    build_router_with_trusted_proxies(
173        state,
174        auth,
175        tenant,
176        limiter,
177        audit,
178        cors,
179        TrustedProxies::from_env(),
180    )
181}
182
183/// Build the router with full configuration *and* an explicit XFCC
184/// trusted-proxy allowlist.
185///
186/// Mirrors [`build_router_with_audit`] but lets the caller inject an
187/// explicit [`TrustedProxies`] instead of reading
188/// `TENSOR_WASM_API_TRUSTED_XFCC_PROXIES` from the ambient process
189/// environment. Integration tests that exercise the XFCC-gating path use
190/// this constructor so parallel tests do not race on a global env var.
191///
192/// See the module-level comment block on
193/// `crate::audit::extract_client_cert_subject_gated` for the threat
194/// model.
195pub fn build_router_with_trusted_proxies(
196    state: Arc<AppState>,
197    auth: AuthConfig,
198    tenant: TenantConfig,
199    limiter: RateLimiter,
200    audit: AuditConfig,
201    cors: CorsConfig,
202    trusted_proxies: TrustedProxies,
203) -> Router {
204    // Defer to the kernel-publish-tokens variant, reading the
205    // production default from the env. Existing callers that pre-date
206    // the kernel-publish gate keep their call site untouched.
207    build_router_full(
208        state,
209        auth,
210        tenant,
211        limiter,
212        audit,
213        cors,
214        trusted_proxies,
215        KernelPublishTokens::from_env(),
216    )
217}
218
219/// Build the router with every override exposed, including the explicit
220/// [`KernelPublishTokens`] allowlist for `POST /kernels`.
221///
222/// This is the lowest-level public builder. Integration tests use it to
223/// exercise the kernel-publish authorization gate without poisoning the
224/// process environment via `TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS`.
225/// Production code reaches it transitively from
226/// [`build_router_with_trusted_proxies`], which reads
227/// [`KernelPublishTokens::from_env`] internally.
228///
229/// All other parameters behave identically to
230/// [`build_router_with_trusted_proxies`].
231#[allow(clippy::too_many_arguments)]
232pub fn build_router_with_kernel_publish_tokens(
233    state: Arc<AppState>,
234    auth: AuthConfig,
235    tenant: TenantConfig,
236    limiter: RateLimiter,
237    audit: AuditConfig,
238    cors: CorsConfig,
239    trusted_proxies: TrustedProxies,
240    kernel_publish_tokens: KernelPublishTokens,
241) -> Router {
242    build_router_full(
243        state,
244        auth,
245        tenant,
246        limiter,
247        audit,
248        cors,
249        trusted_proxies,
250        kernel_publish_tokens,
251    )
252}
253
254/// Process-wide latch for the T16 "tokens set but trusted_hosts unset"
255/// startup warning. The warning fires at most once per process even if
256/// the router builders are invoked multiple times (tests routinely
257/// rebuild the router for each case; production starts the gateway once).
258static T16_HOST_WARN_FIRED: AtomicBool = AtomicBool::new(false);
259
260/// SECURITY (H3): environment variable carrying an optional bearer token
261/// that gates `GET /metrics`. When set to a non-empty value the metrics
262/// scrape endpoint requires `Authorization: Bearer <token>` matching this
263/// value (constant-time compared); when unset or empty the endpoint stays
264/// unauthenticated (the historical Prometheus-scrape posture) and a
265/// one-shot `warn!` is emitted at startup. See `MetricsAuth` and
266/// `metrics_auth_gate`.
267pub const ENV_METRICS_TOKEN: &str = "TENSOR_WASM_API_METRICS_TOKEN";
268
269/// Process-wide latch for the H3 "metrics endpoint is unauthenticated"
270/// startup warning. Like [`T16_HOST_WARN_FIRED`] this fires at most once
271/// per process so repeated `build_router*` calls in the test suite do not
272/// flood the log.
273static H3_METRICS_WARN_FIRED: AtomicBool = AtomicBool::new(false);
274
275/// SECURITY (H3): resolved gate for the unauthenticated `/metrics` scrape
276/// endpoint.
277///
278/// `/metrics` is documented `security: []` in
279/// `openapi/tensor-wasm-api.yaml` so Prometheus scrapers and k8s tooling
280/// can hit it without a bearer token — the default (env unset) preserves
281/// that posture. Operators that expose the listener on a shared interface
282/// can opt into a bearer-token gate by setting
283/// [`ENV_METRICS_TOKEN`]; when set, `metrics_auth_gate` requires
284/// `Authorization: Bearer <token>` and returns `401` otherwise.
285#[derive(Debug, Clone, Default)]
286struct MetricsAuth {
287    /// The configured token, or `None` when the endpoint is left
288    /// unauthenticated. `Arc<str>` so cloning into every request's
289    /// extensions does not copy the secret bytes.
290    token: Option<Arc<str>>,
291}
292
293impl MetricsAuth {
294    /// Load the optional metrics token from [`ENV_METRICS_TOKEN`].
295    ///
296    /// Unset / empty (after trimming) → unauthenticated (the historical
297    /// posture); the one-shot startup warning is emitted here so operators
298    /// running an internet-facing listener without an ingress ACL notice
299    /// the open scrape target. A non-empty value enables the bearer gate.
300    fn from_env() -> Self {
301        let raw = std::env::var(ENV_METRICS_TOKEN).unwrap_or_default();
302        let trimmed = raw.trim();
303        if trimmed.is_empty() {
304            // One-shot warning: the scrape target carries per-tenant
305            // operational data and is unauthenticated. Idempotent across
306            // router rebuilds via the CAS latch.
307            if H3_METRICS_WARN_FIRED
308                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
309                .is_ok()
310            {
311                tracing::warn!(
312                    target: "tensor_wasm_api::server",
313                    "GET /metrics is unauthenticated (TENSOR_WASM_API_METRICS_TOKEN \
314                     unset) — it exposes per-tenant gauges and route/error \
315                     distributions. This is the expected Prometheus-scrape \
316                     posture behind an internal interface or ingress ACL; set \
317                     TENSOR_WASM_API_METRICS_TOKEN=<token> to require \
318                     `Authorization: Bearer <token>` on the scrape path.",
319                );
320            }
321            Self { token: None }
322        } else {
323            tracing::info!(
324                target: "tensor_wasm_api::server",
325                "GET /metrics requires a bearer token (TENSOR_WASM_API_METRICS_TOKEN set)",
326            );
327            Self {
328                token: Some(Arc::from(trimmed)),
329            }
330        }
331    }
332}
333
334/// Reset the H3 metrics startup-warning latch.
335///
336/// Test-only, mirroring [`__reset_host_validation_warn_for_test`]: each
337/// scenario in an integration test that asserts on the warning needs a
338/// clean "not yet warned" state. Production code MUST NOT call this — the
339/// warning is fire-once for the life of the process by design.
340#[doc(hidden)]
341pub fn __reset_metrics_warn_for_test() {
342    H3_METRICS_WARN_FIRED.store(false, Ordering::Release);
343}
344
345/// SECURITY (H3): bearer-token gate applied ONLY to the `/metrics` route.
346///
347/// The handler in `routes.rs` is left untouched (it is owned by another
348/// agent and is `security: []` by contract); this middleware wraps the
349/// route in `server.rs` so the gate is purely additive and opt-in:
350///
351/// * No [`MetricsAuth`] in the extensions, or `token == None` → pass
352///   through unauthenticated (the default Prometheus-scrape posture).
353/// * Token configured → require `Authorization: Bearer <token>` and reject
354///   with `401 unauthorized` (standard `{ "error": { kind, message } }`
355///   envelope) otherwise. The comparison is constant-time
356///   ([`subtle::ConstantTimeEq`]) so a timing side-channel cannot be used
357///   to recover the token byte-by-byte, mirroring the bearer allowlist
358///   compare in `crate::middleware::AuthConfig::scope_for`.
359async fn metrics_auth_gate(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
360    let configured = req
361        .extensions()
362        .get::<MetricsAuth>()
363        .and_then(|m| m.token.clone());
364
365    let Some(expected) = configured else {
366        // Unauthenticated posture (default): pass through unchanged.
367        return next.run(req).await;
368    };
369
370    // A token is required. Recover the `Authorization: Bearer <token>`
371    // header and constant-time compare against the configured value.
372    let presented = req
373        .headers()
374        .get(axum::http::header::AUTHORIZATION)
375        .and_then(|h| h.to_str().ok())
376        .and_then(parse_bearer_token);
377
378    let authorized = match presented {
379        // `ct_eq` requires equal-length inputs to be meaningful; a length
380        // mismatch is an immediate non-match. We still run `ct_eq` only
381        // when lengths match so the byte loop does not early-exit on the
382        // first differing byte.
383        Some(tok) => {
384            let a = tok.as_bytes();
385            let b = expected.as_bytes();
386            a.len() == b.len() && a.ct_eq(b).into()
387        }
388        None => false,
389    };
390
391    if !authorized {
392        return metrics_unauthorized();
393    }
394    next.run(req).await
395}
396
397/// Render the `401` envelope for a rejected `/metrics` request. Mirrors the
398/// `{ "error": { "kind", "message" } }` shape produced by
399/// `crate::middleware::envelope` (which is private to that module, so this
400/// is a local copy rather than a shared import).
401fn metrics_unauthorized() -> Response {
402    (
403        StatusCode::UNAUTHORIZED,
404        Json(json!({
405            "error": {
406                "kind": "unauthorized",
407                "message": "GET /metrics requires a valid Authorization: Bearer <token> header",
408            }
409        })),
410    )
411        .into_response()
412}
413
414/// Parse the credentials of a `Bearer` `Authorization` header value.
415///
416/// Local minimal mirror of `crate::middleware::parse_bearer_credentials`
417/// (which is private to that module and lives in a file this change must
418/// not touch). Splits on the first space/tab, matches the scheme
419/// case-insensitively, trims surrounding BWS, and rejects control bytes so
420/// a CR/LF/NUL cannot be smuggled into a downstream log line. Returns
421/// `None` for a missing/empty credential so the caller treats it as a
422/// non-match.
423fn parse_bearer_token(value: &str) -> Option<&str> {
424    if value.bytes().any(|b| b != b'\t' && (b < 0x20 || b == 0x7F)) {
425        return None;
426    }
427    let split = value
428        .as_bytes()
429        .iter()
430        .position(|&b| b == b' ' || b == b'\t')?;
431    let (scheme, rest) = value.split_at(split);
432    if !scheme.eq_ignore_ascii_case("bearer") {
433        return None;
434    }
435    let token = rest.trim_matches(|c: char| c == ' ' || c == '\t');
436    if token.is_empty() {
437        None
438    } else {
439        Some(token)
440    }
441}
442
443/// One-shot startup safety check: when the gateway is in production mode
444/// (`TENSOR_WASM_API_TOKENS` set to a non-empty value) but the operator
445/// has not configured a `Host` allowlist (`TENSOR_WASM_API_TRUSTED_HOSTS`
446/// unset or empty), emit a single `tracing::warn!` that points at the
447/// virtual-host-confusion / cache-poisoning risk. The runtime
448/// `host_validate` middleware itself stays a no-op when no allowlist is
449/// configured — this only adds operator-facing visibility so a
450/// production deployment behind a misconfigured ingress that forwards
451/// arbitrary `Host` headers is no longer silent.
452///
453/// Closes the T16 audit finding. The atomic latch makes the warning
454/// idempotent across repeated `build_router*` calls in the same process
455/// (the integration test suite alone rebuilds the router dozens of
456/// times); operators see exactly one log line per process.
457fn maybe_warn_host_validation_disabled() {
458    let tokens_set = std::env::var(ENV_API_TOKENS).is_ok_and(|v| !v.is_empty());
459    if !tokens_set {
460        return;
461    }
462    let trusted_hosts_set =
463        std::env::var(ENV_TRUSTED_HOSTS).is_ok_and(|v| !TrustedHosts::from_raw(&v).is_empty());
464    if trusted_hosts_set {
465        return;
466    }
467    // CAS so we only emit once per process. `Acquire`/`Release` is
468    // overkill for a log latch but matches the pattern used by the
469    // `install_w3c_propagator` `Once` elsewhere in this module.
470    if T16_HOST_WARN_FIRED
471        .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
472        .is_ok()
473    {
474        tracing::warn!(
475            target: "tensor_wasm_api::server",
476            "TENSOR_WASM_API_TOKENS is set (production-mode) but \
477             TENSOR_WASM_API_TRUSTED_HOSTS is unset — Host header \
478             validation is disabled. This is OK behind an ingress that \
479             strips/validates Host, otherwise enable trusted hosts to \
480             avoid virtual-host confusion. Set \
481             TENSOR_WASM_API_TRUSTED_HOSTS=host1.example.com,host2.example.com \
482             to enable.",
483        );
484    }
485}
486
487/// Reset the T16 startup-warning latch.
488///
489/// Used by the integration test in
490/// `tests/host_validate_startup_warn.rs` so each scenario starts from a
491/// clean "not yet warned" state. Production code MUST NOT call this:
492/// the warning is fire-once for the life of the process by design, and
493/// resetting it lets the misconfiguration warning fire repeatedly on
494/// every router rebuild. The function is hidden from rustdoc and prefixed
495/// with `__` to discourage external callers; it is `pub` only so the
496/// integration test binary (which lives outside the crate root) can
497/// reach it without depending on `#[cfg(test)]` visibility, which does
498/// not extend to integration tests.
499#[doc(hidden)]
500pub fn __reset_host_validation_warn_for_test() {
501    T16_HOST_WARN_FIRED.store(false, Ordering::Release);
502}
503
504#[allow(clippy::too_many_arguments)]
505fn build_router_full(
506    state: Arc<AppState>,
507    auth: AuthConfig,
508    tenant: TenantConfig,
509    limiter: RateLimiter,
510    audit: AuditConfig,
511    cors: CorsConfig,
512    trusted_proxies: TrustedProxies,
513    kernel_publish_tokens: KernelPublishTokens,
514) -> Router {
515    // When `kernel-registry-api` is OFF the kernel router below is not
516    // built, so the parameter is unused on that build axis. Drop it
517    // explicitly under cfg-off to silence the unused-variables lint
518    // without poking holes in attribute placement.
519    #[cfg(not(feature = "kernel-registry-api"))]
520    let _ = kernel_publish_tokens;
521    // T16: one-shot warning when production-mode tokens are configured
522    // but the Host allowlist is not. The runtime middleware behaviour is
523    // unchanged (pass-through with no allowlist); only the operator-
524    // facing visibility is new.
525    maybe_warn_host_validation_disabled();
526    // Wire the W3C Trace Context propagator globally. Idempotent across
527    // calls; safe to invoke on every router rebuild (tests do that
528    // routinely). Without this, the tower `trace_layer_with_propagation`
529    // would silently see an empty parent context for every inbound
530    // `traceparent` header and start a fresh root span — collapsing the
531    // distributed-tracing invariant the gateway documents in
532    // `docs/OBSERVABILITY.md`.
533    install_w3c_propagator();
534
535    // HTTP metrics middleware sits OUTSIDE bearer_auth so 401 responses
536    // are counted as well — the SLO doc (`docs/SLO.md` §2.1
537    // `availability_http`) defines the SLI as a ratio over *every* HTTP
538    // response, including auth rejections, and the burn-rate alerts in
539    // §5 rely on that. Placing it inside the auth gate would drop ~all
540    // probe traffic from the rate panels in the reference dashboard.
541    let http_metrics_cfg = HttpMetricsLayerConfig {
542        metrics: Arc::clone(&state.metrics),
543        routes: RouteAllowList::new_default(),
544    };
545
546    // Layers that apply to EVERY route (protected and probe alike): tracing,
547    // trace-id injection, the body cap, the timeout, the concurrency cap,
548    // and HTTP metrics counting. Auth / tenant / rate-limit / audit are
549    // intentionally NOT in this stack — `/healthz` and `/metrics` are
550    // documented in `openapi/tensor-wasm-api.yaml` as `security: []` (no
551    // auth) so that k8s liveness/readiness probes and Prometheus scrapers
552    // can hit them without holding bearer tokens.
553    //
554    // T19 perf: `host_validate` (with its `TrustedHosts` extension) is
555    // layered OUTSIDE `trace_layer_with_propagation` so a hostile Host
556    // probe gets rejected with `400` before any trace span is allocated
557    // or any `traceparent` parent context is propagated. Under a probe
558    // storm against a production deployment that has the allowlist set,
559    // this keeps the trace pipeline (and any downstream OTel collector
560    // budget) from being burnt on rejections. Rejections short-circuit
561    // before the trace layer (and before `http_metrics_middleware`
562    // which sits inside the trace layer), so a rejected request gets
563    // neither a span nor a metric increment — intentional tradeoff: a
564    // hostile Host probe storm cannot burn trace budget or metric
565    // cardinality, at the cost of losing observability on the rejected
566    // 4xx itself. The Host allowlist is opt-in via
567    // `TENSOR_WASM_API_TRUSTED_HOSTS`; deployments that leave it unset
568    // are unaffected by the re-order (the middleware is a pass-through
569    // and never short-circuits).
570    let common_layers = ServiceBuilder::new()
571        // api S-30: reject requests whose Host header isn't in the
572        // operator-configured allowlist (TENSOR_WASM_API_TRUSTED_HOSTS).
573        // Default (env unset) is permissive — the previous behaviour —
574        // because most local-dev deployments don't set the env var.
575        // Production behind a layered proxy should set the allowlist.
576        //
577        // The parsed allowlist travels as an `axum::Extension<TrustedHosts>`
578        // so tests can override at build time
579        // (`router.layer(axum::Extension(TrustedHosts::from_hosts([...])))`)
580        // without poisoning the process environment. Inserted BEFORE the
581        // `from_fn(host_validate)` layer so the middleware sees it on the
582        // request extensions when it runs.
583        //
584        // T19 perf: this pair sits OUTERMOST so hostile Host probes are
585        // rejected before the trace layer allocates a span for them.
586        .layer(axum::Extension(TrustedHosts::from_env()))
587        .layer(axum::middleware::from_fn(host_validate))
588        .layer(trace_layer_with_propagation())
589        // `inject_trace_id_header` runs inside the trace layer so the
590        // current span (the one the trace layer just created with its
591        // parent already attached) is the one whose `trace_id` we surface
592        // back to the caller via the `x-trace-id` response header. This
593        // is the operator-facing handle the `docs/runbooks/trace-id.md`
594        // runbook points at; without it operators have to read journald
595        // to recover the trace id of a failed request.
596        .layer(axum::middleware::from_fn(inject_trace_id_header))
597        // CORS sits near the outer edge so the layer can short-circuit
598        // browser preflight (`OPTIONS`) without it needing to clear bearer
599        // auth, rate-limit, or audit. The default config has an empty
600        // origin allowlist — cross-origin browser callers get no
601        // `Access-Control-Allow-Origin` header and the browser blocks the
602        // request — operators widen the surface via
603        // `TENSOR_WASM_API_CORS_ALLOWED_ORIGINS`. The headers and methods
604        // admitted on a cross-origin request mirror the API contract in
605        // `API.md`: `Authorization`, `Content-Type`, `X-TensorWasm-Tenant`,
606        // and `Traceparent`; methods `GET`, `POST`, `DELETE`.
607        .layer(cors_layer(&cors))
608        .layer(body_limit_layer(MAX_REQUEST_BODY_BYTES))
609        .layer(timeout_layer(Duration::from_secs(30)))
610        // NOTE (api S-26): the global ConcurrencyLimit(64) is removed.
611        // Per-route caps below isolate budgets so a probe storm cannot
612        // starve /invoke, and a noisy /invoke cannot starve probes.
613        // Pass the auth + tenant + limiter + audit config into the request
614        // extensions so the protected stack's `from_fn` middleware can pick
615        // them up without capturing through a type-erased closure. The
616        // probe stack does not consume these but inserting them is cheap
617        // and keeps the stacks symmetric — relevant if a future probe
618        // grows an auth-aware behaviour (e.g. degraded-mode signalling).
619        .layer(axum::Extension(auth))
620        .layer(axum::Extension(tenant))
621        .layer(axum::Extension(limiter))
622        .layer(axum::Extension(audit))
623        // XFCC spoofing mitigation: parsed allowlist of reverse-proxy peer
624        // addresses whose `X-Forwarded-Client-Cert` headers the audit
625        // middleware will trust. Empty / unset
626        // (`TENSOR_WASM_API_TRUSTED_XFCC_PROXIES`) = trust nobody, drop
627        // every inbound XFCC. See `crate::audit::TrustedProxies` and the
628        // threat-model comment on `extract_client_cert_subject_gated`.
629        .layer(axum::Extension(trusted_proxies))
630        .layer(axum::Extension(http_metrics_cfg))
631        // Metrics emission wraps every downstream layer (including
632        // bearer_auth) so 401s, 429s, and handler responses all get
633        // counted — see the comment block above.
634        .layer(axum::middleware::from_fn(http_metrics_middleware));
635
636    // Probe stack: `/healthz` and `/metrics` deliberately bypass bearer
637    // auth, tenant scope, the per-token rate limiter, and the audit log.
638    // OpenAPI (`openapi/tensor-wasm-api.yaml` `paths./healthz` and
639    // `paths./metrics`) declares `security: []` for both, k8s probes do
640    // not carry an Authorization header, and Prometheus scrapers typically
641    // share a single credential across many endpoints — protecting these
642    // here would break the published contract and silently disable
643    // upstream health checks.
644    // SECURITY (H3): `/metrics` is mounted in its own sub-router so the
645    // opt-in bearer gate (`metrics_auth_gate`) can be layered onto it
646    // WITHOUT touching the handler in `routes.rs` or gating `/healthz`.
647    // The endpoint stays `security: []` in
648    // `openapi/tensor-wasm-api.yaml` by default — Prometheus scrapers and
649    // k8s tooling expect an unauthenticated scrape target, and the
650    // endpoint exposes per-tenant operational data (the HTTP-metric
651    // families carry a `route` label, and the registry also surfaces
652    // per-tenant gauges). Operators MUST bind the listener to an internal
653    // interface or place `/metrics` behind an ingress ACL whenever the
654    // listener is internet-facing.
655    //
656    // When `TENSOR_WASM_API_METRICS_TOKEN` is set, `metrics_auth_gate`
657    // requires `Authorization: Bearer <token>` (constant-time compared)
658    // and returns `401` otherwise; when unset, the gate is a pass-through
659    // and `MetricsAuth::from_env` emits a one-shot startup `warn!` that
660    // the scrape target is unauthenticated. The `MetricsAuth` extension
661    // is inserted on this sub-router only, so `/healthz` and every other
662    // route are oblivious to it. The published spec documents `/metrics`
663    // as `security: []` (open by default, matching the unset-env posture)
664    // and its `description` notes that setting
665    // `TENSOR_WASM_API_METRICS_TOKEN` opts the endpoint into a bearer gate;
666    // see `openapi/tensor-wasm-api.yaml` and `crates/tensor-wasm-api/openapi.json`.
667    let metrics_router = Router::new()
668        .route("/metrics", get(metrics))
669        .layer(axum::middleware::from_fn(metrics_auth_gate))
670        .layer(axum::Extension(MetricsAuth::from_env()));
671
672    let probe_router = Router::new()
673        .route("/healthz", get(healthz))
674        .merge(metrics_router)
675        // api S-26: probes get their own generous budget. A k8s deployment
676        // can have many replicas all scraping at once without affecting
677        // invoke capacity.
678        .layer(concurrency_limit_layer(PROBE_CONCURRENCY_LIMIT));
679
680    // Protected stack: everything that operates on tenant data. Auth /
681    // tenant resolution / rate limit / audit all run on top of
682    // `common_layers`, in the same order as before so the audit record
683    // still observes the final status and any handler-stamped outcome
684    // extension.
685    // api S-26: split protected routes into three sub-routers by class so
686    // each gets an isolated concurrency budget. Invoke is tightest because
687    // calls hold a Wasmtime instance lock; writes are middle; reads are
688    // loosest.
689    let invoke_router = Router::new()
690        .route("/functions/:id/invoke", post(invoke_function))
691        .route("/functions/:id/invoke-async", post(invoke_function_async))
692        // Streaming invoke (roadmap feature #2). Restored by B7.1 and
693        // wired end-to-end by T34: the handler builds an mpsc channel,
694        // installs the sender on a `StreamingContext`, threads it
695        // through `SpawnConfig::with_streaming` to the executor, and
696        // drains the receiver into the SSE / chunked-transfer response
697        // body. See `docs/STREAMING.md`.
698        .route("/functions/:id/invoke-stream", post(invoke_function_stream))
699        .layer(concurrency_limit_layer(INVOKE_CONCURRENCY_LIMIT));
700    // M5: `/snapshot/save` and `/snapshot/restore` join the write-class
701    // budget. Both are POSTs that do CPU-bound crypto + (de)compression
702    // work on the blocking pool, comparable in weight to a deploy. They
703    // sit under the same protected stack as the other resource routes
704    // (bearer_auth + tenant_scope + rate_limit + audit applied below on
705    // `protected_router`), so they inherit:
706    //   * bearer auth — an unauthenticated caller gets `401`, not `503`;
707    //   * tenant scope — `X-TensorWasm-Tenant` is resolved and the handler
708    //     runs `authorize_tenant` + a per-resource owner check (save: the
709    //     function's `tenant_id`; restore: the HMAC-authenticated snapshot
710    //     metadata's `tenant_id`) so cross-tenant access is `403
711    //     tenant_scope_denied`;
712    //   * per-token rate limiting and audit logging.
713    // When `TENSOR_WASM_API_SNAPSHOT_HMAC_KEY` is unset both handlers
714    // return `503 snapshot_signing_not_configured` (feature-detect shape).
715    let write_router = Router::new()
716        .route("/functions", post(create_function))
717        .route("/functions/:id", delete(delete_function))
718        .route("/snapshot/save", post(snapshot_save))
719        .route("/snapshot/restore", post(snapshot_restore))
720        .layer(concurrency_limit_layer(WRITE_CONCURRENCY_LIMIT));
721    let read_router = Router::new()
722        .route("/jobs/:id", get(get_job))
723        .layer(concurrency_limit_layer(READ_CONCURRENCY_LIMIT));
724
725    // Desired execution order (request flows outer -> inner):
726    //   bearer_auth -> tenant_scope -> rate_limit -> audit -> handler
727    // so the auth layer rejects unauthenticated callers FIRST (before a
728    // rate-limit permit is spent), tenant_scope then resolves the
729    // `TenantId`, the per-token limiter reads the `AuthContext`, and the
730    // audit middleware runs INNERMOST so the record it synthesises sees
731    // the resolved actor / tenant / scope and the handler's final status.
732    //
733    // CRITICAL: in axum's `Router::layer`, the LAST `.layer(...)` added is
734    // the OUTERMOST (it runs first) — the opposite of `ServiceBuilder`.
735    // The calls below are therefore written innermost-first: `audit` is
736    // added first (runs last) and `bearer_auth` is added last (runs
737    // first). The `rate_limit_runs_after_bearer_auth` integration test
738    // pins this ordering.
739    let protected_router = invoke_router
740        .merge(write_router)
741        .merge(read_router)
742        .layer(axum::middleware::from_fn(audit_log_middleware))
743        .layer(axum::middleware::from_fn(rate_limit))
744        .layer(axum::middleware::from_fn(tenant_scope))
745        .layer(axum::middleware::from_fn(bearer_auth));
746
747    // OpenAI-compat shim stack (B4.9). The two `/v1/...` routes accept
748    // off-the-shelf OpenAI SDK requests; those clients send
749    // `Authorization: Bearer <api_key>` and typically NO
750    // `X-TensorWasm-Tenant` header. The `tenant_scope` middleware tolerates
751    // the absent-header case under the default policy
752    // (`TENSOR_WASM_API_REQUIRE_TENANT` unset) by inserting
753    // `TenantId(0)` into the request extensions, which is exactly the
754    // resolution the v0.4 wiring step will want as a fallback — the
755    // handler then runs the `authorize_tenant` gate against the bearer
756    // token's `TokenScope` so a scoped token that does not cover tenant 0
757    // still receives `403 tenant_scope_denied` (not `501`). T2 security
758    // fix: the gate must exist on these routes *before* v0.4 wires real
759    // execution; otherwise the locked URL surface inherits a hole. See
760    // `crates/tensor-wasm-api/src/openai.rs` and `docs/OPENAI-COMPAT.md`.
761    //
762    // Operators that set `TENSOR_WASM_API_REQUIRE_TENANT=1` deliberately
763    // opt into the stricter posture: the OpenAI routes then require the
764    // header too, and clients must use the gateway's native shim that
765    // injects `X-TensorWasm-Tenant` (or migrate to scoped tokens once v0.4
766    // surfaces tenant resolution from the bearer scope itself).
767    //
768    // Bearer auth, rate-limit, and audit still apply so an unauthenticated
769    // OpenAI client receives `401` (not `501`), and the v0.4 wiring step
770    // can rely on the same actor/tenant/audit pipeline as the native
771    // routes once tenant inference moves out of the header layer.
772    //
773    // Concurrency budget: share INVOKE_CONCURRENCY_LIMIT — T41 executes
774    // the same `TensorWasmExecutor` spawn path as native `/invoke`, so
775    // the budgets track in lockstep. Both OpenAI handlers now take a
776    // `State<Arc<AppState>>` extractor (T41) so they can read the
777    // `openai_model_map` and dispatch through the shared executor; the
778    // explicit `Router::<Arc<AppState>>::new()` annotation lines the
779    // sub-router up with `protected_router` and `probe_router` for the
780    // outer `.merge(...)` call.
781    let openai_router: Router<Arc<AppState>> = Router::new()
782        .route("/v1/completions", post(crate::openai::completions_handler))
783        .route(
784            "/v1/chat/completions",
785            post(crate::openai::chat_completions_handler),
786        )
787        // Innermost-first (see `protected_router`): bearer_auth added last
788        // runs first (outermost), audit added first runs last (innermost),
789        // concurrency stays closest to the handler.
790        .layer(concurrency_limit_layer(INVOKE_CONCURRENCY_LIMIT))
791        .layer(axum::middleware::from_fn(audit_log_middleware))
792        .layer(axum::middleware::from_fn(rate_limit))
793        .layer(axum::middleware::from_fn(tenant_scope))
794        .layer(axum::middleware::from_fn(bearer_auth));
795
796    // Kernel registry routes (B6.4 — roadmap feature #3 server-side).
797    // The endpoints are write-class (publish) and read-class (list /
798    // resolve); we put them behind the same `WRITE_CONCURRENCY_LIMIT`
799    // budget the function-mutating endpoints use.
800    //
801    // **T1 security fix.** Earlier scaffolding mounted these OUTSIDE
802    // `tenant_scope` on the rationale that the kernel registry is
803    // operator-scope (one HMAC key per deployment). That rationale was
804    // wrong for two reasons: (1) the `publish_kernel` handler took the
805    // tenant extension as `Option<...>` and ignored it, so any
806    // allowlisted token — including a tenant-1 token, or any caller at
807    // all in dev mode — could publish; (2) the documented
808    // `kernel-publish` scope check was unimplemented. Both holes are
809    // now closed:
810    //
811    //   * The router sits under `bearer_auth` + `tenant_scope` so the
812    //     handler can rely on the tenant being established and on the
813    //     caller having cleared the API token allowlist.
814    //   * An `axum::Extension<KernelPublishTokens>` carries the parsed
815    //     `TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS` allowlist into the
816    //     handler. `publish_kernel` rejects dev-mode calls outright
817    //     (`kernel_publish_disabled_in_dev_mode`) and any non-
818    //     publish-scoped token with `kernel_publish_scope_required`.
819    //     GET routes admit any authenticated tenant.
820    //
821    // Inserting the publish-tokens extension at the kernel router level
822    // (rather than `common_layers`) keeps the surface tight — every
823    // other route is oblivious to it. The list value flows in via the
824    // `kernel_publish_tokens` parameter so tests can call
825    // [`build_router_with_kernel_publish_tokens`] with an explicit
826    // allowlist (no env poisoning); production callers reach
827    // [`build_router_with_trusted_proxies`], which fills the parameter
828    // from `TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS` via
829    // [`KernelPublishTokens::from_env`].
830    //
831    // The routes are gated behind the `kernel-registry-api` feature so
832    // the default build keeps the dep graph lean. Operators flip
833    // `--features kernel-registry-api` plus set
834    // `TENSOR_WASM_API_KERNEL_HMAC_KEY` to enable them; when the env
835    // var is unset the handlers themselves return
836    // `503 kernel_registry_not_configured` (so adding the routes here
837    // is safe even without the secret configured).
838    #[cfg(feature = "kernel-registry-api")]
839    let kernel_router: Router<Arc<AppState>> = Router::new()
840        .route(
841            "/kernels",
842            post(crate::kernels::publish_kernel).get(crate::kernels::list_kernels),
843        )
844        .route(
845            "/kernels/:name/:version",
846            get(crate::kernels::resolve_kernel),
847        )
848        // Layer ordering mirrors the protected_router stack. In axum's
849        // `Router::layer` the LAST `.layer(...)` added is the OUTERMOST
850        // (runs first), so the calls below are written innermost-first:
851        // bearer_auth is added last and therefore runs FIRST, putting the
852        // AuthContext in the request extensions before tenant_scope,
853        // rate_limit, and the innermost audit middleware run. The
854        // KernelPublishTokens extension and the concurrency limit are
855        // added first (inner-most) so they sit just above the handler —
856        // the publish-scope check reads KernelPublishTokens via
857        // `Extension<...>` in the handler signature, so the layer that
858        // installs it must run before the handler but after any layer that
859        // might short-circuit (auth / rate-limit). The
860        // `rate_limit_runs_after_bearer_auth` integration test pins this
861        // ordering.
862        .layer(concurrency_limit_layer(WRITE_CONCURRENCY_LIMIT))
863        .layer(axum::Extension(kernel_publish_tokens))
864        .layer(axum::middleware::from_fn(audit_log_middleware))
865        .layer(axum::middleware::from_fn(rate_limit))
866        .layer(axum::middleware::from_fn(tenant_scope))
867        .layer(axum::middleware::from_fn(bearer_auth));
868
869    let router = protected_router.merge(probe_router).merge(openai_router);
870    #[cfg(feature = "kernel-registry-api")]
871    let router = router.merge(kernel_router);
872    router.layer(common_layers).with_state(state)
873}
874
875/// Bind and serve the router on the given address.
876///
877/// The listener is wrapped with
878/// [`axum::Router::into_make_service_with_connect_info`] so the audit
879/// middleware can recover the immediate TCP peer's `SocketAddr` from the
880/// request extensions. The XFCC trusted-proxy gate
881/// (`crate::audit::TrustedProxies`) depends on that peer information to
882/// decide whether to honour the `X-Forwarded-Client-Cert` header.
883pub async fn serve(state: Arc<AppState>, addr: SocketAddr) -> anyhow::Result<()> {
884    let router = build_router(state);
885    let listener = tokio::net::TcpListener::bind(addr).await?;
886    tracing::info!(target: "tensor_wasm_api::server", %addr, "tensor-wasm-api listening");
887    axum::serve(
888        listener,
889        router.into_make_service_with_connect_info::<SocketAddr>(),
890    )
891    .await?;
892    Ok(())
893}
894
895// ---------------------------------------------------------------------------
896// Tests
897// ---------------------------------------------------------------------------
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902
903    // SECURITY (H3): bearer parsing for the `/metrics` gate.
904    #[test]
905    fn parse_bearer_token_accepts_canonical_scheme() {
906        assert_eq!(parse_bearer_token("Bearer abc123"), Some("abc123"));
907        assert_eq!(parse_bearer_token("bearer abc123"), Some("abc123"));
908        assert_eq!(parse_bearer_token("BEARER\tabc123"), Some("abc123"));
909        assert_eq!(parse_bearer_token("Bearer   spaced  "), Some("spaced"));
910    }
911
912    #[test]
913    fn parse_bearer_token_rejects_non_bearer_and_empty() {
914        assert_eq!(parse_bearer_token("Basic abc123"), None);
915        assert_eq!(parse_bearer_token("Bearer"), None);
916        assert_eq!(parse_bearer_token("Bearer    "), None);
917        assert_eq!(parse_bearer_token(""), None);
918    }
919
920    #[test]
921    fn parse_bearer_token_rejects_control_bytes() {
922        // CR/LF/NUL must not survive into a downstream log line.
923        assert_eq!(parse_bearer_token("Bearer ab\r\nc"), None);
924        assert_eq!(parse_bearer_token("Bearer ab\0c"), None);
925    }
926
927    // SECURITY (H3): an unconfigured `MetricsAuth` keeps the endpoint open.
928    #[test]
929    fn metrics_auth_default_is_unauthenticated() {
930        let auth = MetricsAuth::default();
931        assert!(auth.token.is_none());
932    }
933
934    // SECURITY (H3): the 401 envelope carries the standard error shape.
935    #[test]
936    fn metrics_unauthorized_envelope_shape() {
937        let resp = metrics_unauthorized();
938        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
939    }
940}