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}