Skip to main content

tensor_wasm_api/
kernels.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! `/kernels` HTTP endpoints (roadmap feature #3 — server-side path).
5//!
6//! Exposes the kernel registry to authenticated operators via three
7//! routes:
8//!
9//!   * `POST /kernels` — publish a signed [`KernelManifest`] + PTX text.
10//!   * `GET  /kernels` — list manifests (PTX omitted).
11//!   * `GET  /kernels/{name}/{version}` — resolve a manifest + PTX.
12//!
13//! All three routes sit behind `bearer_auth` + `tenant_scope` (so the
14//! tenant extension is always present and every caller has cleared the
15//! token allowlist). The two GET routes admit any authenticated
16//! tenant. `POST /kernels` additionally requires the
17//! **kernel-publish** scope:
18//!
19//!   * **Dev mode** (`TENSOR_WASM_API_TOKENS` unset/empty) — every
20//!     `POST /kernels` is rejected with `403
21//!     kernel_publish_disabled_in_dev_mode`. Dev mode already grants
22//!     every caller wildcard tenant scope, so allowing publish there
23//!     would let unauthenticated traffic seed the registry. Operators
24//!     who genuinely want a dev-time publish path must configure a
25//!     token allowlist *and* opt the publishing token into
26//!     [`TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS`](crate::middleware::ENV_KERNEL_PUBLISH_TOKENS).
27//!   * **Production mode** — the caller's bearer token's
28//!     [`crate::rate_limit::TokenId`] must appear in
29//!     [`KernelPublishTokens`](crate::middleware::KernelPublishTokens),
30//!     the allowlist parsed from
31//!     [`TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS`](crate::middleware::ENV_KERNEL_PUBLISH_TOKENS).
32//!     A token in the main allowlist but not in the publish allowlist
33//!     gets `403 kernel_publish_scope_required`.
34//!
35//! The server-side [`InMemoryRegistry`] is held in
36//! [`AppState::kernel_registry`] as an `Option<Arc<dyn KernelRegistry>>`
37//! and constructed at startup from the `TENSOR_WASM_API_KERNEL_HMAC_KEY`
38//! environment variable (64-hex 32-byte key). When the env var is unset,
39//! the routes return `503 kernel_registry_not_configured` so client
40//! tools can detect feature availability without inspecting the URL
41//! surface.
42//!
43//! ## URL syntax
44//!
45//! `axum 0.7` resolves path parameters via the colon-prefix syntax
46//! (`:name`, `:version`); the literal `{name}/{version}` shape used by
47//! the OpenAPI spec maps to the `:name/:version` axum route. The CLI
48//! issues `GET /kernels/<name>/<version>` (slash-separated) rather than
49//! the `name@version` form used by the in-memory key — the at-sign in a
50//! path segment is awkward to encode and most HTTP clients eagerly
51//! percent-encode it, so the canonical wire form takes the two-segment
52//! shape and the handler concatenates internally before the registry
53//! lookup. See `docs/KERNEL-REGISTRY.md` §"v0.4 rollout plan".
54//!
55//! ## Error envelope
56//!
57//! Every failure surfaces the same `{error: {kind, message}}` shape the
58//! native routes use (see [`crate::routes::ApiError`]). The kinds
59//! introduced by this module:
60//!
61//! | kind                                  | status | meaning                                                                   |
62//! |---------------------------------------|--------|---------------------------------------------------------------------------|
63//! | `kernel_registry_not_configured`      | 503    | `TENSOR_WASM_API_KERNEL_HMAC_KEY` is unset; routes are wired but disabled |
64//! | `kernel_registry_storage_error`       | 503    | T35: disk-backed registry I/O error during publish                        |
65//! | `kernel_publish_disabled_in_dev_mode` | 403    | `POST /kernels` rejected because the gateway is in dev mode (no tokens)   |
66//! | `kernel_publish_scope_required`       | 403    | Caller's bearer token is not in `TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS`   |
67//! | `publisher_not_allowed`               | 403    | T35: `manifest.publisher` is not in the registry's publisher allowlist    |
68//! | `bad_signature`                       | 403    | HMAC verification under the configured key failed                         |
69//! | `digest_mismatch`                     | 400    | BLAKE3(`ptx_text`) does not match `manifest.digest`                       |
70//! | `already_registered`                  | 409    | A manifest with the same `name@version` is already present                |
71//! | `invalid_request`                     | 400    | Catch-all for other `RegistryError` variants (forward-compat)             |
72//! | `not_found`                           | 404    | The selector did not resolve to a registered manifest                     |
73
74use std::sync::Arc;
75
76use axum::{
77    extract::{Extension, Path, Query, State},
78    http::StatusCode,
79    Json,
80};
81use serde::{Deserialize, Serialize};
82
83use crate::middleware::KernelPublishTokens;
84use crate::rate_limit::{AuthContext, TokenId};
85use crate::routes::{ApiError, AppState};
86use tensor_wasm_core::types::TenantId;
87#[allow(unused_imports)]
88use tensor_wasm_jit::registry::KernelRegistry as _;
89
90/// Request body for `POST /kernels`.
91///
92/// `manifest` carries the signed envelope (name, version, sm_version,
93/// digest, signature, advisory metadata); `ptx_text` is the kernel
94/// source whose BLAKE3 digest must match `manifest.digest`. The server
95/// re-verifies both the digest match and the HMAC signature under the
96/// configured key before persisting — see
97/// [`tensor_wasm_jit::registry::InMemoryRegistry::publish`] for the
98/// verification order.
99#[derive(Debug, Deserialize)]
100pub struct PublishKernelRequest {
101    /// The signed manifest. Must be HMAC-SHA256-signed under the same key
102    /// the server was configured with.
103    pub manifest: tensor_wasm_jit::registry::KernelManifest,
104    /// PTX text. The server computes BLAKE3 over the UTF-8 bytes and
105    /// requires a match with `manifest.digest` before any signature
106    /// check (cheaper failure for corrupted uploads).
107    pub ptx_text: String,
108}
109
110/// Response body for `GET /kernels`.
111#[derive(Debug, Serialize)]
112pub struct ListKernelsResponse {
113    /// Page of registered manifests, in unspecified order. PTX text is NOT
114    /// included — callers that need the kernel source must issue a
115    /// follow-up `GET /kernels/{name}/{version}`. The size of this page
116    /// is at most `limit` (clamped to 1000 server-side); a shorter page
117    /// indicates the end of the keyspace was reached.
118    pub manifests: Vec<tensor_wasm_jit::registry::KernelManifest>,
119    /// Echo of the effective `offset` the handler used. Matches the
120    /// inbound query parameter, defaulting to 0.
121    pub offset: usize,
122    /// Echo of the effective `limit` the handler used (after clamping).
123    /// Matches the inbound query parameter, defaulting to 100, clamped
124    /// to 1000.
125    pub limit: usize,
126}
127
128/// Query parameters for `GET /kernels`.
129///
130/// Both fields are optional; defaults mirror the registry's pagination
131/// constants (`offset = 0`, `limit = 100`). `limit` is silently clamped
132/// to 1000 on the registry side so a caller asking for an unbounded
133/// page still receives a bounded response.
134#[derive(Debug, Default, Deserialize)]
135pub struct ListKernelsQuery {
136    /// Index of the first manifest to return; defaults to 0.
137    #[serde(default)]
138    pub offset: Option<usize>,
139    /// Maximum number of manifests in the returned page; defaults to
140    /// 100. Server-side cap: 1000.
141    #[serde(default)]
142    pub limit: Option<usize>,
143}
144
145/// Response body for `GET /kernels/{name}/{version}`.
146#[derive(Debug, Serialize)]
147pub struct ResolveKernelResponse {
148    /// The verified manifest as previously published.
149    pub manifest: tensor_wasm_jit::registry::KernelManifest,
150    /// PTX text whose BLAKE3 matches `manifest.digest`.
151    pub ptx_text: String,
152}
153
154/// `POST /kernels` — publish a signed manifest + PTX text.
155///
156/// The handler:
157///
158/// 1. Verifies the caller has the **kernel-publish** scope:
159///    * In dev mode (`TokenId::DEV` — empty `TENSOR_WASM_API_TOKENS`)
160///      the call is rejected with `403
161///      kernel_publish_disabled_in_dev_mode`. Closing the T1 finding:
162///      previously, dev mode silently admitted every anonymous caller.
163///    * Otherwise the caller's [`TokenId`] must appear in
164///      [`KernelPublishTokens`] (parsed from
165///      [`TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS`](crate::middleware::ENV_KERNEL_PUBLISH_TOKENS)).
166///      A token in the main API allowlist but missing from the publish
167///      allowlist gets `403 kernel_publish_scope_required`.
168/// 2. Pulls the registry out of [`AppState`]; returns `503` when unset.
169/// 3. Hands the (manifest, ptx_text) pair to
170///    [`InMemoryRegistry::publish`](tensor_wasm_jit::registry::InMemoryRegistry::publish),
171///    which performs digest + HMAC verification before insertion.
172/// 4. Maps each [`RegistryError`](tensor_wasm_jit::registry::RegistryError)
173///    variant to a stable `(status, kind)` pair on the wire.
174///
175/// On success, returns `201 Created` with `{name, version}` echoed back
176/// so the CLI can confirm the canonical key.
177///
178/// The `_tenant` extension is required (the route sits under
179/// `tenant_scope` so it is always present). It is captured here for
180/// future use by audit/per-tenant key-scope tables without re-shaping
181/// the handler signature; today it is intentionally unused beyond
182/// proving its presence — the kernel registry is operator-scope.
183///
184/// The [`KernelPublishTokens`] extension is optional only because tests
185/// that drive the handler directly may not install one; production
186/// routers always layer it on. An absent extension is treated as the
187/// empty allowlist (deny by default), which is the same posture as
188/// `TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS` being unset.
189pub async fn publish_kernel(
190    State(state): State<Arc<AppState>>,
191    Extension(_tenant): Extension<TenantId>,
192    Extension(auth): Extension<AuthContext>,
193    publish_tokens: Option<Extension<KernelPublishTokens>>,
194    Json(req): Json<PublishKernelRequest>,
195) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
196    // Step 1: enforce the kernel-publish scope BEFORE any registry work.
197    // The HMAC verification that runs inside `InMemoryRegistry::publish`
198    // is an integrity check, NOT an authorization check — without this
199    // gate, any allowlisted token (including a tenant-1 token) could
200    // seed the deployment-wide registry.
201    if auth.token_id == TokenId::DEV {
202        return Err(ApiError::forbidden(
203            "kernel_publish_disabled_in_dev_mode",
204            "POST /kernels is disabled when TENSOR_WASM_API_TOKENS is empty; \
205             configure an API token allowlist and add the publishing token to \
206             TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS",
207        ));
208    }
209    let allow = publish_tokens.map(|Extension(t)| t).unwrap_or_default();
210    if !allow.allows(auth.token_id) {
211        return Err(ApiError::forbidden(
212            "kernel_publish_scope_required",
213            "bearer token is not allowlisted for POST /kernels; \
214             add it to TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS",
215        ));
216    }
217
218    let registry = state.kernel_registry.as_ref().ok_or_else(|| {
219        ApiError::service_unavailable(
220            "kernel_registry_not_configured",
221            "set TENSOR_WASM_API_KERNEL_HMAC_KEY to enable /kernels",
222        )
223    })?;
224    // The InMemoryRegistry::publish does signature + digest verification.
225    // Snapshot the (name, version) BEFORE handing the manifest off — the
226    // call takes the manifest by value so we cannot read its fields on
227    // the response side without an extra clone.
228    let echo_name = req.manifest.name.clone();
229    let echo_version = req.manifest.version.clone();
230    registry
231        .publish(req.manifest, req.ptx_text)
232        .map_err(|e| match e {
233            tensor_wasm_jit::registry::RegistryError::BadSignature(_) => {
234                ApiError::forbidden("bad_signature", e.to_string())
235            }
236            tensor_wasm_jit::registry::RegistryError::DigestMismatch(_) => {
237                ApiError::bad_request("digest_mismatch", e.to_string())
238            }
239            tensor_wasm_jit::registry::RegistryError::AlreadyRegistered(_) => {
240                ApiError::conflict("already_registered", e.to_string())
241            }
242            // T35: the disk-backed registry can additionally refuse a
243            // publish when the manifest's `publisher` field is not in
244            // the operator's configured allowlist. Surface as 403
245            // (authorization failure) — distinct from `bad_signature`
246            // (signature didn't verify under any key) and from
247            // `kernel_publish_scope_required` (the route-level scope
248            // gate from T1, which fires before we even reach the
249            // registry call).
250            tensor_wasm_jit::registry::RegistryError::PublisherNotAllowed(_) => {
251                ApiError::forbidden("publisher_not_allowed", e.to_string())
252            }
253            // T35: artifact-store I/O failure (disk full, permissions,
254            // tampered envelope, etc.). The store layer already
255            // logged the underlying cause; surface to the client as
256            // 503 so the operator's tooling can distinguish a
257            // transient backend failure from a malformed request.
258            // Storage failures during publish are non-retriable from
259            // the client's perspective without operator action — same
260            // posture as `kernel_registry_not_configured`.
261            //
262            // M-2: the inner `ArtifactError` Display (carried verbatim in
263            // `RegistryError::Storage`) can embed host filesystem paths or
264            // backend internals — leaking it to the client is an
265            // information-disclosure bug. Log the full chain server-side
266            // (matching the `sanitised_exec_error_message` /
267            // `ExecError::Wasmtime` pattern in `routes.rs`) and return a
268            // stable, content-light message instead of `e.to_string()`.
269            tensor_wasm_jit::registry::RegistryError::Storage(_) => {
270                tracing::error!(
271                    target: "tensor_wasm_api::kernels",
272                    error = %e,
273                    "kernel publish failed: artifact-store error mapped to 503",
274                );
275                ApiError::service_unavailable(
276                    "kernel_registry_storage_error",
277                    "kernel registry storage error",
278                )
279            }
280            // T35: bincode encode failure during publish. Should only
281            // fire on truly pathological input (it's the encode path
282            // for a manifest we just verified, so the bytes are well-
283            // formed). Map to 500 internal — there is no client action
284            // that resolves this.
285            //
286            // M-2: the inner bincode Display is internal serialization
287            // detail of the on-disk blob format and is of no use to the
288            // client. Log it server-side and return a stable, content-light
289            // 500 message rather than echoing the codec error verbatim.
290            tensor_wasm_jit::registry::RegistryError::Codec(_) => {
291                tracing::error!(
292                    target: "tensor_wasm_api::kernels",
293                    error = %e,
294                    "kernel publish failed: manifest codec error mapped to 500",
295                );
296                ApiError::internal("kernel registry internal error")
297            }
298            // Forward-compat catch-all: future RegistryError variants
299            // (e.g. NotFound, which `publish` cannot actually produce
300            // today) collapse to 400 invalid_request rather than masking
301            // a real error as 500. The match is exhaustive at compile
302            // time; the catch-all is here for v0.4 extension points.
303            //
304            // M-2: because this arm absorbs *unknown* future variants, we
305            // cannot vouch for what their `Display` embeds (it may carry
306            // host/internal detail). Log the full `e` server-side and
307            // return a stable, content-light message; this preserves the
308            // 400 `invalid_request` envelope without echoing arbitrary
309            // future Display strings to clients.
310            _ => {
311                tracing::warn!(
312                    target: "tensor_wasm_api::kernels",
313                    error = %e,
314                    "kernel publish failed: unmapped registry error mapped to 400",
315                );
316                ApiError::bad_request("invalid_request", "invalid kernel publish request")
317            }
318        })?;
319    Ok((
320        StatusCode::CREATED,
321        Json(serde_json::json!({
322            "name": echo_name,
323            "version": echo_version,
324        })),
325    ))
326}
327
328/// `GET /kernels?offset=N&limit=M` — list registered manifests.
329///
330/// Returns `200 OK` with `{manifests: [...], offset, limit}`. PTX text
331/// is intentionally omitted; callers that need a kernel's source must
332/// resolve it individually so a tenant listing every manifest does not
333/// transfer hundreds of megabytes of source over the wire.
334///
335/// `offset` defaults to 0, `limit` defaults to 100, and `limit` is
336/// silently clamped to 1000 server-side via
337/// [`tensor_wasm_jit::registry::DISK_REGISTRY_MAX_LIMIT`]. The response
338/// echoes the effective values so clients can drive the next-page
339/// request without a second round of math.
340///
341/// Authorization: any authenticated tenant may list. The route sits
342/// under `bearer_auth` + `tenant_scope`, so an unauthenticated caller
343/// is already 401'd before reaching this handler. Capturing the tenant
344/// extension here is belt-and-braces — it documents the routing
345/// expectation and ensures the handler will fail to mount on a router
346/// that bypasses `tenant_scope`.
347pub async fn list_kernels(
348    State(state): State<Arc<AppState>>,
349    Extension(_tenant): Extension<TenantId>,
350    Query(query): Query<ListKernelsQuery>,
351) -> Result<Json<ListKernelsResponse>, ApiError> {
352    let registry = state.kernel_registry.as_ref().ok_or_else(|| {
353        ApiError::service_unavailable(
354            "kernel_registry_not_configured",
355            "set TENSOR_WASM_API_KERNEL_HMAC_KEY to enable /kernels",
356        )
357    })?;
358    let offset = query.offset.unwrap_or(0);
359    let raw_limit = query
360        .limit
361        .unwrap_or(tensor_wasm_jit::registry::DISK_REGISTRY_DEFAULT_LIMIT);
362    let effective_limit = raw_limit.min(tensor_wasm_jit::registry::DISK_REGISTRY_MAX_LIMIT);
363    Ok(Json(ListKernelsResponse {
364        manifests: registry.list_paginated(offset, effective_limit),
365        offset,
366        limit: effective_limit,
367    }))
368}
369
370/// `GET /kernels/{name}/{version}` — resolve a manifest + PTX.
371///
372/// Returns `200 OK` with `{manifest, ptx_text}` on hit, `404 not_found`
373/// when the selector does not match any registered manifest. Path
374/// segments are accepted verbatim; the CLI must percent-encode any
375/// reserved characters in `name` or `version` before issuing the
376/// request.
377///
378/// Authorization: any authenticated tenant may resolve. See
379/// [`list_kernels`] for the routing-stack rationale on the captured
380/// tenant extension.
381pub async fn resolve_kernel(
382    State(state): State<Arc<AppState>>,
383    Extension(_tenant): Extension<TenantId>,
384    Path((name, version)): Path<(String, String)>,
385) -> Result<Json<ResolveKernelResponse>, ApiError> {
386    let registry = state.kernel_registry.as_ref().ok_or_else(|| {
387        ApiError::service_unavailable(
388            "kernel_registry_not_configured",
389            "set TENSOR_WASM_API_KERNEL_HMAC_KEY to enable /kernels",
390        )
391    })?;
392    let entry = registry
393        .get(&name, &version)
394        .map_err(|_| ApiError::not_found(format!("kernel {name}@{version} not found")))?;
395    // The registry returns Arc<(manifest, ptx_text)>; clone the inner
396    // pair into the response body rather than dragging the Arc through
397    // serde. Two String clones per resolve is well within the noise
398    // budget for an admin-class API.
399    Ok(Json(ResolveKernelResponse {
400        manifest: entry.0.clone(),
401        ptx_text: entry.1.clone(),
402    }))
403}