From bbc66088b78299e3f0503518d3a6a37b0475dbb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 10:59:09 +0200 Subject: [PATCH 01/18] feat(proxy): schema + repo for per-user Anthropic keys Add user_anthropic_keys table (user_id PK, key_encrypted, key_nonce, created_at, updated_at) and UserAnthropicKeyRepo with upsert / get_ciphertext / configured_at / delete. Keys are encrypted at rest via the existing AES-256-GCM encryption.rs path (same master key as org signing keys / SSO client secrets); configured_at lets the status API report key presence without ever touching ciphertext. Refs softwaremill/tracevault#207, parent #181. --- .../migrations/024_user_anthropic_keys.sql | 14 ++ crates/tracevault-server/src/repo/mod.rs | 1 + .../src/repo/user_anthropic_keys.rs | 89 ++++++++++ .../tests/repo_user_anthropic_keys_test.rs | 167 ++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 crates/tracevault-server/migrations/024_user_anthropic_keys.sql create mode 100644 crates/tracevault-server/src/repo/user_anthropic_keys.rs create mode 100644 crates/tracevault-server/tests/repo_user_anthropic_keys_test.rs diff --git a/crates/tracevault-server/migrations/024_user_anthropic_keys.sql b/crates/tracevault-server/migrations/024_user_anthropic_keys.sql new file mode 100644 index 00000000..6d3e4f2c --- /dev/null +++ b/crates/tracevault-server/migrations/024_user_anthropic_keys.sql @@ -0,0 +1,14 @@ +-- Per-user Anthropic API keys, used by the transparent LLM proxy +-- (issue softwaremill/tracevault#207, parent #181). +-- +-- One row per user. Key stored encrypted at rest (AES-256-GCM via +-- the existing encryption.rs path; same encryption_key env var as +-- org signing keys / SSO client secrets). The plaintext key never +-- leaves the proxy hot path and is never returned through any API. +CREATE TABLE user_anthropic_keys ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + key_encrypted TEXT NOT NULL, + key_nonce TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/crates/tracevault-server/src/repo/mod.rs b/crates/tracevault-server/src/repo/mod.rs index 4d841df7..af3fec73 100644 --- a/crates/tracevault-server/src/repo/mod.rs +++ b/crates/tracevault-server/src/repo/mod.rs @@ -14,4 +14,5 @@ pub mod pricing; pub mod repos; pub mod sealing; pub mod sessions; +pub mod user_anthropic_keys; pub mod users; diff --git a/crates/tracevault-server/src/repo/user_anthropic_keys.rs b/crates/tracevault-server/src/repo/user_anthropic_keys.rs new file mode 100644 index 00000000..262a1bc9 --- /dev/null +++ b/crates/tracevault-server/src/repo/user_anthropic_keys.rs @@ -0,0 +1,89 @@ +//! Storage for per-user Anthropic API keys used by the transparent LLM proxy +//! (issue softwaremill/tracevault#207, parent #181). +//! +//! Plaintext keys are encrypted with AES-256-GCM (via `crate::encryption`) +//! before being persisted, and decrypted only inside the proxy hot path — +//! they are never returned through any HTTP response. + +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::encryption; +use crate::error::AppError; + +pub struct UserAnthropicKeyRepo; + +impl UserAnthropicKeyRepo { + /// Encrypt `plaintext_key` with the configured master `encryption_key` + /// and upsert it for `user_id`. On conflict the existing row is + /// overwritten and `updated_at` advances; `created_at` is preserved. + pub async fn upsert( + pool: &PgPool, + encryption_key: &str, + user_id: Uuid, + plaintext_key: &str, + ) -> Result<(), AppError> { + let (encrypted, nonce) = encryption::encrypt(plaintext_key, encryption_key) + .map_err(|e| AppError::Internal(format!("failed to encrypt anthropic key: {e}")))?; + + sqlx::query( + "INSERT INTO user_anthropic_keys (user_id, key_encrypted, key_nonce) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE SET + key_encrypted = EXCLUDED.key_encrypted, + key_nonce = EXCLUDED.key_nonce, + updated_at = now()", + ) + .bind(user_id) + .bind(&encrypted) + .bind(&nonce) + .execute(pool) + .await?; + + Ok(()) + } + + /// Return the encrypted ciphertext and nonce for `user_id`, or `None` + /// if no key is configured. Callers decrypt via `crate::encryption::decrypt`. + pub async fn get_ciphertext( + pool: &PgPool, + user_id: Uuid, + ) -> Result, AppError> { + let row = sqlx::query_as::<_, (String, String)>( + "SELECT key_encrypted, key_nonce FROM user_anthropic_keys WHERE user_id = $1", + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(row) + } + + /// Return `Some(updated_at)` if a key is configured for `user_id`, `None` + /// otherwise. Used by the status-only GET endpoint — never reveals key + /// material. + pub async fn configured_at( + pool: &PgPool, + user_id: Uuid, + ) -> Result>, AppError> { + let row = sqlx::query_scalar::<_, DateTime>( + "SELECT updated_at FROM user_anthropic_keys WHERE user_id = $1", + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(row) + } + + /// Remove the row for `user_id`. Idempotent — returns Ok even if no row + /// existed. + pub async fn delete(pool: &PgPool, user_id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM user_anthropic_keys WHERE user_id = $1") + .bind(user_id) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/crates/tracevault-server/tests/repo_user_anthropic_keys_test.rs b/crates/tracevault-server/tests/repo_user_anthropic_keys_test.rs new file mode 100644 index 00000000..49427772 --- /dev/null +++ b/crates/tracevault-server/tests/repo_user_anthropic_keys_test.rs @@ -0,0 +1,167 @@ +//! Integration tests for `UserAnthropicKeyRepo`. Verifies the +//! upsert / get / configured_at / delete lifecycle and that the on-disk +//! ciphertext is recoverable via `encryption::decrypt` — i.e. the layer +//! that the proxy hot path will rely on. + +mod common; + +use base64::Engine; +use tracevault_server::encryption; +use tracevault_server::repo::user_anthropic_keys::UserAnthropicKeyRepo; + +fn fixture_key() -> String { + // Deterministic 32-byte key for test reproducibility. The real + // master key comes from config; here we only need any valid value + // that `encryption::encrypt` will accept. + base64::engine::general_purpose::STANDARD.encode([0x5Au8; 32]) +} + +#[sqlx::test(migrations = "./migrations")] +async fn upsert_then_get_roundtrips_plaintext(pool: sqlx::PgPool) { + let user_id = common::seed_user(&pool).await; + let master = fixture_key(); + let plaintext = "sk-ant-test-fixture-not-a-real-key"; + + UserAnthropicKeyRepo::upsert(&pool, &master, user_id, plaintext) + .await + .expect("upsert"); + + let (ct, nonce) = UserAnthropicKeyRepo::get_ciphertext(&pool, user_id) + .await + .expect("get") + .expect("row present after upsert"); + + let recovered = encryption::decrypt(&ct, &nonce, &master).expect("decrypt"); + assert_eq!(recovered, plaintext); +} + +#[sqlx::test(migrations = "./migrations")] +async fn upsert_replaces_existing_key(pool: sqlx::PgPool) { + let user_id = common::seed_user(&pool).await; + let master = fixture_key(); + + UserAnthropicKeyRepo::upsert(&pool, &master, user_id, "sk-ant-first") + .await + .unwrap(); + UserAnthropicKeyRepo::upsert(&pool, &master, user_id, "sk-ant-second") + .await + .unwrap(); + + let (ct, nonce) = UserAnthropicKeyRepo::get_ciphertext(&pool, user_id) + .await + .unwrap() + .unwrap(); + let recovered = encryption::decrypt(&ct, &nonce, &master).unwrap(); + assert_eq!(recovered, "sk-ant-second"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn get_ciphertext_returns_none_when_missing(pool: sqlx::PgPool) { + let user_id = common::seed_user(&pool).await; + let result = UserAnthropicKeyRepo::get_ciphertext(&pool, user_id) + .await + .unwrap(); + assert!(result.is_none()); +} + +#[sqlx::test(migrations = "./migrations")] +async fn configured_at_reflects_presence(pool: sqlx::PgPool) { + let user_id = common::seed_user(&pool).await; + let master = fixture_key(); + + assert!(UserAnthropicKeyRepo::configured_at(&pool, user_id) + .await + .unwrap() + .is_none()); + + UserAnthropicKeyRepo::upsert(&pool, &master, user_id, "sk-ant-test") + .await + .unwrap(); + + let ts = UserAnthropicKeyRepo::configured_at(&pool, user_id) + .await + .unwrap(); + assert!( + ts.is_some(), + "configured_at should return Some after upsert" + ); +} + +#[sqlx::test(migrations = "./migrations")] +async fn upsert_advances_updated_at(pool: sqlx::PgPool) { + let user_id = common::seed_user(&pool).await; + let master = fixture_key(); + + UserAnthropicKeyRepo::upsert(&pool, &master, user_id, "sk-ant-first") + .await + .unwrap(); + let t1 = UserAnthropicKeyRepo::configured_at(&pool, user_id) + .await + .unwrap() + .unwrap(); + + // Sleep briefly so postgres `now()` resolves to a later timestamp. + // Postgres `now()` has microsecond resolution; 10ms is plenty. + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + UserAnthropicKeyRepo::upsert(&pool, &master, user_id, "sk-ant-second") + .await + .unwrap(); + let t2 = UserAnthropicKeyRepo::configured_at(&pool, user_id) + .await + .unwrap() + .unwrap(); + + assert!( + t2 > t1, + "updated_at should advance on re-upsert: t1={t1} t2={t2}" + ); +} + +#[sqlx::test(migrations = "./migrations")] +async fn delete_removes_row(pool: sqlx::PgPool) { + let user_id = common::seed_user(&pool).await; + let master = fixture_key(); + + UserAnthropicKeyRepo::upsert(&pool, &master, user_id, "sk-ant-test") + .await + .unwrap(); + UserAnthropicKeyRepo::delete(&pool, user_id).await.unwrap(); + + assert!(UserAnthropicKeyRepo::get_ciphertext(&pool, user_id) + .await + .unwrap() + .is_none()); + assert!(UserAnthropicKeyRepo::configured_at(&pool, user_id) + .await + .unwrap() + .is_none()); +} + +#[sqlx::test(migrations = "./migrations")] +async fn delete_is_idempotent_when_no_row_exists(pool: sqlx::PgPool) { + let user_id = common::seed_user(&pool).await; + UserAnthropicKeyRepo::delete(&pool, user_id) + .await + .expect("delete with no row should succeed"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn user_deletion_cascades_to_anthropic_key(pool: sqlx::PgPool) { + let user_id = common::seed_user(&pool).await; + let master = fixture_key(); + + UserAnthropicKeyRepo::upsert(&pool, &master, user_id, "sk-ant-test") + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); + + assert!(UserAnthropicKeyRepo::get_ciphertext(&pool, user_id) + .await + .unwrap() + .is_none()); +} From d89a9e1e1d5d1cff0102f700ca1301753b618017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 10:59:18 +0200 Subject: [PATCH 02/18] feat(proxy): /api/v1/me/anthropic-key endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET returns { configured, configured_at } — never the key itself. PUT validates sk-ant- prefix, requires AppState.encryption_key to be set, and upserts via UserAnthropicKeyRepo. DELETE is idempotent. All three reject AuthUser with nil user_id (org-scoped api_keys) with a clear 403 — the proxy is fundamentally per-user. Refs softwaremill/tracevault#207, parent #181. --- crates/tracevault-server/src/api/me.rs | 109 ++++++++++++++++++++++++ crates/tracevault-server/src/api/mod.rs | 1 + crates/tracevault-server/src/main.rs | 6 ++ 3 files changed, 116 insertions(+) create mode 100644 crates/tracevault-server/src/api/me.rs diff --git a/crates/tracevault-server/src/api/me.rs b/crates/tracevault-server/src/api/me.rs new file mode 100644 index 00000000..ec3d281b --- /dev/null +++ b/crates/tracevault-server/src/api/me.rs @@ -0,0 +1,109 @@ +//! User-scoped (`/api/v1/me/...`) endpoints that are not org-bound. +//! +//! Currently only carries the Anthropic-key management endpoints used by the +//! transparent LLM proxy (issue softwaremill/tracevault#207, parent #181). +//! Future per-user settings (preferences, personal access tokens, etc.) belong +//! here as they're added. + +use axum::{extract::State, http::StatusCode, Json}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::extractors::AuthUser; +use crate::repo::user_anthropic_keys::UserAnthropicKeyRepo; +use crate::AppState; + +#[derive(Serialize)] +pub struct AnthropicKeyStatus { + pub configured: bool, + pub configured_at: Option>, +} + +#[derive(Deserialize)] +pub struct PutAnthropicKeyRequest { + pub key: String, +} + +/// Reject the synthetic nil user_id that the AuthUser extractor returns when +/// the request was authenticated with an org-scoped api_key rather than a +/// user session token. The proxy is fundamentally per-user — there is no +/// "current user" for an api_key. +fn require_real_user(auth: &AuthUser) -> Result { + if auth.user_id.is_nil() { + Err(AppError::Forbidden( + "This endpoint requires a user session token, not an org API key".into(), + )) + } else { + Ok(auth.user_id) + } +} + +/// GET /api/v1/me/anthropic-key +/// +/// Returns whether the caller has an Anthropic key configured, plus the +/// timestamp it was last set. The key itself is never returned — there is no +/// API that surfaces decrypted key material. +pub async fn get_anthropic_key_status( + State(state): State, + auth: AuthUser, +) -> Result, AppError> { + let user_id = require_real_user(&auth)?; + let configured_at = UserAnthropicKeyRepo::configured_at(&state.pool, user_id).await?; + Ok(Json(AnthropicKeyStatus { + configured: configured_at.is_some(), + configured_at, + })) +} + +/// PUT /api/v1/me/anthropic-key +/// +/// Upserts the caller's Anthropic key, encrypted with the server's master +/// encryption key. Returns 204 on success. +pub async fn put_anthropic_key( + State(state): State, + auth: AuthUser, + Json(req): Json, +) -> Result { + let user_id = require_real_user(&auth)?; + + let key = req.key.trim(); + if key.is_empty() { + return Err(AppError::BadRequest( + "Anthropic key must not be empty".into(), + )); + } + // Anthropic API keys begin with `sk-ant-` (modern format). We reject + // anything that doesn't look like one to catch obvious paste mistakes + // (TV session token, empty string, environment variable name, etc.). + // We do *not* validate the key against api.anthropic.com here — that + // would couple this endpoint to upstream availability. + if !key.starts_with("sk-ant-") { + return Err(AppError::BadRequest( + "Anthropic key must start with 'sk-ant-'".into(), + )); + } + + let encryption_key = state.encryption_key.as_deref().ok_or_else(|| { + AppError::Internal( + "Server is not configured with an encryption key; cannot store Anthropic keys".into(), + ) + })?; + + UserAnthropicKeyRepo::upsert(&state.pool, encryption_key, user_id, key).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// DELETE /api/v1/me/anthropic-key +/// +/// Removes the caller's stored Anthropic key. Idempotent — returns 204 even +/// when no key was configured. +pub async fn delete_anthropic_key( + State(state): State, + auth: AuthUser, +) -> Result { + let user_id = require_real_user(&auth)?; + UserAnthropicKeyRepo::delete(&state.pool, user_id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/tracevault-server/src/api/mod.rs b/crates/tracevault-server/src/api/mod.rs index 6f1f2c03..3f37f452 100644 --- a/crates/tracevault-server/src/api/mod.rs +++ b/crates/tracevault-server/src/api/mod.rs @@ -12,6 +12,7 @@ pub mod dashboard; pub mod features; pub mod github; pub mod invites; +pub mod me; pub mod orgs; pub mod policies; pub mod pricing; diff --git a/crates/tracevault-server/src/main.rs b/crates/tracevault-server/src/main.rs index ed4a4c15..fef90c6b 100644 --- a/crates/tracevault-server/src/main.rs +++ b/crates/tracevault-server/src/main.rs @@ -218,6 +218,12 @@ async fn main() { .route("/api/v1/auth/me", get(api::auth::me)) // User endpoints .route("/api/v1/me/orgs", get(api::auth::list_my_orgs)) + .route( + "/api/v1/me/anthropic-key", + get(api::me::get_anthropic_key_status) + .put(api::me::put_anthropic_key) + .delete(api::me::delete_anthropic_key), + ) // Org management (create is org-agnostic) .route("/api/v1/orgs", post(api::orgs::create_org)) // Org-scoped: org details & members From 7a185eb914f7f5116e6403515495b380bf66e6be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 11:04:15 +0200 Subject: [PATCH 03/18] feat(proxy): transparent Anthropic proxy handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catch-all at /proxy/anthropic/{*path} forwarding to api.anthropic.com. Authenticates via x-api-key (TV session token resolved against auth_sessions; org api_keys explicitly rejected with a clear 401); loads + decrypts the user's stored Anthropic key; rewrites x-api-key with the upstream value; streams the response body byte-for-byte via reqwest bytes_stream() — no SSE parsing, no buffering. Allow-list header forwarding (drops host/cookie/authorization/x-forwarded-*/ set-cookie); response headers forward a fixed allow-list plus any anthropic-* prefix for forward compat. Proxy-originated errors use the Anthropic envelope shape so unmodified clients route them through their existing error paths. Mounted on its own Router so the public GovernorLayer rate-limit does not throttle agent sessions. Refs softwaremill/tracevault#207, parent #181. --- Cargo.lock | 18 +- crates/tracevault-server/Cargo.toml | 2 +- crates/tracevault-server/src/api/mod.rs | 1 + crates/tracevault-server/src/api/proxy.rs | 486 ++++++++++++++++++++++ crates/tracevault-server/src/main.rs | 12 + 5 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 crates/tracevault-server/src/api/proxy.rs diff --git a/Cargo.lock b/Cargo.lock index 5f97a7c3..65f94d67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3341,7 +3341,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", ] @@ -3355,6 +3355,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -3376,12 +3377,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", ] @@ -4984,6 +4987,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/crates/tracevault-server/Cargo.toml b/crates/tracevault-server/Cargo.toml index d2ead617..6c988234 100644 --- a/crates/tracevault-server/Cargo.toml +++ b/crates/tracevault-server/Cargo.toml @@ -30,7 +30,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } rand = "0.8" base64 = "0.22" git2 = "0.20" -reqwest = { version = "0.13", features = ["json"] } +reqwest = { version = "0.13", features = ["json", "stream"] } async-trait = "0.1" aes-gcm = "0.10" dotenvy = "0.15.7" diff --git a/crates/tracevault-server/src/api/mod.rs b/crates/tracevault-server/src/api/mod.rs index 3f37f452..f4efb2c8 100644 --- a/crates/tracevault-server/src/api/mod.rs +++ b/crates/tracevault-server/src/api/mod.rs @@ -16,6 +16,7 @@ pub mod me; pub mod orgs; pub mod policies; pub mod pricing; +pub mod proxy; pub mod repos; pub mod session_detail; pub mod sso; diff --git a/crates/tracevault-server/src/api/proxy.rs b/crates/tracevault-server/src/api/proxy.rs new file mode 100644 index 00000000..1240a81f --- /dev/null +++ b/crates/tracevault-server/src/api/proxy.rs @@ -0,0 +1,486 @@ +//! Transparent Anthropic API proxy (issue softwaremill/tracevault#207, +//! parent #181). +//! +//! Mounted as a catch-all at `/proxy/anthropic/{*path}`. Clients point their +//! tool's `ANTHROPIC_BASE_URL` at `/proxy/anthropic` and use their +//! TV `auth_sessions` token as the `x-api-key` value. The handler: +//! +//! 1. Resolves the TV session token in `x-api-key` to a user. +//! 2. Loads that user's encrypted Anthropic key from `user_anthropic_keys`, +//! decrypts it, and substitutes it into `x-api-key`. +//! 3. Forwards the request to `https://api.anthropic.com/{path}` with an +//! allow-listed set of headers. +//! 4. Streams the response body back byte-for-byte via +//! `reqwest::Response::bytes_stream()` — no SSE parsing. +//! +//! Proxy-originated errors use the Anthropic error envelope shape so that +//! unmodified Anthropic clients surface them through their existing error +//! paths. Upstream errors are passed through verbatim. +//! +//! Explicitly **not** in this slice: event capture, model routing, +//! organization-level keys, OpenAI support, dedicated long-lived proxy +//! tokens. + +use axum::{ + body::{Body, Bytes}, + extract::{OriginalUri, Path, State}, + http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use std::time::Instant; +use uuid::Uuid; + +use crate::auth::sha256_hex; +use crate::encryption; +use crate::repo::user_anthropic_keys::UserAnthropicKeyRepo; +use crate::AppState; + +const ANTHROPIC_UPSTREAM_BASE: &str = "https://api.anthropic.com"; + +/// Request headers we forward upstream. Anything not on this list is dropped +/// — including `host` (reqwest sets it correctly), `authorization`, `cookie`, +/// `x-api-key` (we inject the decrypted key), `x-forwarded-*`, `via`, and +/// hop-by-hop headers. Allow-list is more conservative than a deny-list and +/// fails closed when new client-side headers appear. +const FORWARDED_REQUEST_HEADERS: &[&str] = &[ + "accept", + "accept-encoding", + "anthropic-beta", + "anthropic-dangerous-direct-browser-access", + "anthropic-version", + "content-type", + "user-agent", +]; + +/// Response headers we forward downstream. We always forward all +/// `anthropic-*` headers (forward compat with new headers like +/// `anthropic-organization-id` or billing). Hop-by-hop headers +/// (`transfer-encoding`, `connection`, `content-length`) are dropped so that +/// Axum / hyper can re-frame the body correctly for the downstream client. +const FORWARDED_RESPONSE_HEADERS: &[&str] = &[ + "cache-control", + "content-type", + "content-encoding", + "request-id", +]; + +/// `error.type` discriminants used in the Anthropic-shaped error envelope. +/// Mirrors the documented Anthropic API error types so unmodified clients +/// route these the same way they'd route a real api.anthropic.com error. +#[derive(Debug, Clone, Copy)] +enum ProxyErrorKind { + AuthenticationError, + ApiError, +} + +impl ProxyErrorKind { + fn as_str(self) -> &'static str { + match self { + ProxyErrorKind::AuthenticationError => "authentication_error", + ProxyErrorKind::ApiError => "api_error", + } + } +} + +fn anthropic_error(status: StatusCode, kind: ProxyErrorKind, message: &str) -> Response { + ( + status, + Json(json!({ + "type": "error", + "error": { + "type": kind.as_str(), + "message": message, + } + })), + ) + .into_response() +} + +/// Catch-all proxy handler. Mounted at `/proxy/anthropic/{*path}`. +/// +/// Path layout: `path` is everything after `/proxy/anthropic/` (no leading +/// slash). Query string is forwarded verbatim from the original URI. +pub async fn anthropic_proxy( + State(state): State, + Path(path): Path, + OriginalUri(original_uri): OriginalUri, + method: Method, + headers: HeaderMap, + body: Bytes, +) -> Response { + let start = Instant::now(); + + // --- Step 1: extract and resolve the TV token in x-api-key --- + let tv_token = match headers.get("x-api-key").and_then(|v| v.to_str().ok()) { + Some(t) if !t.is_empty() => t, + _ => { + tracing::warn!( + error_type = "authentication_error", + reason = "missing_x_api_key", + path = %path, + "proxy auth failed" + ); + return anthropic_error( + StatusCode::UNAUTHORIZED, + ProxyErrorKind::AuthenticationError, + "Missing x-api-key header", + ); + } + }; + + let token_hash = sha256_hex(tv_token); + let user_id = match resolve_token(&state, &token_hash).await { + Ok(uid) => uid, + Err(resp) => return resp, + }; + + // --- Step 2: load + decrypt the user's Anthropic key --- + let upstream_key = match load_anthropic_key(&state, user_id).await { + Ok(k) => k, + Err(resp) => return resp, + }; + + // --- Step 3: build the upstream request --- + let query = original_uri.query().unwrap_or(""); + let upstream_url = if query.is_empty() { + format!("{ANTHROPIC_UPSTREAM_BASE}/{path}") + } else { + format!("{ANTHROPIC_UPSTREAM_BASE}/{path}?{query}") + }; + + let mut upstream_req = state + .http_client + .request(method.clone(), &upstream_url) + .body(body); + + for header_name in FORWARDED_REQUEST_HEADERS { + if let Some(value) = headers.get(*header_name) { + upstream_req = upstream_req.header(*header_name, value); + } + } + // Inject the decrypted upstream key. Done after the allow-list loop so + // a client-sent x-api-key cannot bleed through even if the allow-list + // is ever broadened by mistake. + upstream_req = upstream_req.header("x-api-key", &upstream_key); + + // --- Step 4: dispatch and capture upstream response --- + let upstream_resp = match upstream_req.send().await { + Ok(r) => r, + Err(e) => { + tracing::warn!( + user_id = %user_id, + path = %path, + error_type = "api_error", + duration_ms = start.elapsed().as_millis() as u64, + err = %e, + "upstream request to Anthropic failed" + ); + return anthropic_error( + StatusCode::BAD_GATEWAY, + ProxyErrorKind::ApiError, + "Upstream Anthropic API unreachable", + ); + } + }; + + let upstream_status = upstream_resp.status(); + let upstream_headers = upstream_resp.headers().clone(); + + tracing::info!( + user_id = %user_id, + path = %path, + upstream_status = upstream_status.as_u16(), + duration_ms = start.elapsed().as_millis() as u64, + "proxied request" + ); + + // --- Step 5: stream the response body back --- + let body_stream = upstream_resp.bytes_stream(); + let mut downstream = Response::builder().status(upstream_status); + + if let Some(hdrs) = downstream.headers_mut() { + copy_response_headers(&upstream_headers, hdrs); + } + + downstream + .body(Body::from_stream(body_stream)) + .unwrap_or_else(|e| { + tracing::error!(err = %e, "failed to build downstream response"); + anthropic_error( + StatusCode::INTERNAL_SERVER_ERROR, + ProxyErrorKind::ApiError, + "Failed to construct downstream response", + ) + }) +} + +/// Resolve a sha256'd TV token to a user_id. Returns: +/// - Ok(user_id) when the token is a valid, non-expired `auth_sessions` row +/// - Err(401 envelope) when the token is missing or matches an org +/// `api_keys` row (the proxy is per-user; org-scoped api_keys have no +/// user context) +/// - Err(401 envelope) when the token does not match anything +/// - Err(502 envelope) on database error so unmodified clients route it +/// through their existing "upstream error" path +async fn resolve_token(state: &AppState, token_hash: &str) -> Result { + // Try auth_sessions first (the user-session path). + let session_row = sqlx::query_as::<_, (Uuid,)>( + "SELECT user_id FROM auth_sessions WHERE token_hash = $1 AND expires_at > NOW()", + ) + .bind(token_hash) + .fetch_optional(&state.pool) + .await; + + match session_row { + Ok(Some((user_id,))) => return Ok(user_id), + Err(e) => { + tracing::warn!(error_type = "api_error", err = %e, "auth_sessions lookup failed"); + return Err(anthropic_error( + StatusCode::BAD_GATEWAY, + ProxyErrorKind::ApiError, + "Upstream Anthropic API unreachable", + )); + } + Ok(None) => { /* fall through to api_keys check for a clearer error */ } + } + + // Fall back to api_keys so we can give a precise error message when the + // user accidentally pastes an org-scoped ingestion API key. + let api_key_row = + sqlx::query_scalar::<_, Uuid>("SELECT org_id FROM api_keys WHERE key_hash = $1") + .bind(token_hash) + .fetch_optional(&state.pool) + .await; + + match api_key_row { + Ok(Some(_)) => { + tracing::warn!( + error_type = "authentication_error", + reason = "org_api_key_used", + "proxy auth failed" + ); + Err(anthropic_error( + StatusCode::UNAUTHORIZED, + ProxyErrorKind::AuthenticationError, + "Proxy requires a user session token, not an org API key", + )) + } + Ok(None) => { + tracing::warn!( + error_type = "authentication_error", + reason = "unknown_token", + "proxy auth failed" + ); + Err(anthropic_error( + StatusCode::UNAUTHORIZED, + ProxyErrorKind::AuthenticationError, + "Invalid or expired TraceVault session token", + )) + } + Err(e) => { + tracing::warn!(error_type = "api_error", err = %e, "api_keys lookup failed"); + Err(anthropic_error( + StatusCode::BAD_GATEWAY, + ProxyErrorKind::ApiError, + "Upstream Anthropic API unreachable", + )) + } + } +} + +/// Fetch the user's encrypted Anthropic key from `user_anthropic_keys` and +/// decrypt it with the server's master `encryption_key`. Returns the +/// plaintext on success or an Anthropic-shaped error envelope on any +/// failure (no key configured, no master key on this server, ciphertext +/// corrupted, DB error). +async fn load_anthropic_key(state: &AppState, user_id: Uuid) -> Result { + let row = UserAnthropicKeyRepo::get_ciphertext(&state.pool, user_id) + .await + .map_err(|e| { + tracing::warn!( + user_id = %user_id, + error_type = "api_error", + err = %e, + "failed to load user_anthropic_keys row" + ); + anthropic_error( + StatusCode::INTERNAL_SERVER_ERROR, + ProxyErrorKind::ApiError, + "Failed to load upstream credentials", + ) + })?; + + let (encrypted, nonce) = match row { + Some(r) => r, + None => { + tracing::warn!( + user_id = %user_id, + error_type = "authentication_error", + reason = "no_anthropic_key_configured", + "proxy auth failed" + ); + return Err(anthropic_error( + StatusCode::UNAUTHORIZED, + ProxyErrorKind::AuthenticationError, + "No Anthropic API key configured — set one at /me/proxy", + )); + } + }; + + let master_key = state.encryption_key.as_deref().ok_or_else(|| { + tracing::error!( + user_id = %user_id, + error_type = "api_error", + "server has no encryption_key configured but a row exists in user_anthropic_keys" + ); + anthropic_error( + StatusCode::INTERNAL_SERVER_ERROR, + ProxyErrorKind::ApiError, + "Server is not configured with an encryption key", + ) + })?; + + encryption::decrypt(&encrypted, &nonce, master_key).map_err(|e| { + tracing::error!( + user_id = %user_id, + error_type = "api_error", + err = %e, + "failed to decrypt stored Anthropic key" + ); + anthropic_error( + StatusCode::INTERNAL_SERVER_ERROR, + ProxyErrorKind::ApiError, + "Failed to decrypt upstream credentials", + ) + }) +} + +/// Copy allow-listed and `anthropic-*` headers from `src` into `dst`. +fn copy_response_headers(src: &reqwest::header::HeaderMap, dst: &mut HeaderMap) { + for (name, value) in src.iter() { + let name_str = name.as_str(); + let allow = FORWARDED_RESPONSE_HEADERS + .iter() + .any(|h| h.eq_ignore_ascii_case(name_str)) + || name_str.to_ascii_lowercase().starts_with("anthropic-"); + if !allow { + continue; + } + if let (Ok(hname), Ok(hval)) = ( + HeaderName::from_bytes(name.as_str().as_bytes()), + HeaderValue::from_bytes(value.as_bytes()), + ) { + dst.insert(hname, hval); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allow_list_forwards_expected_request_headers() { + for h in [ + "content-type", + "accept", + "anthropic-version", + "anthropic-beta", + "user-agent", + ] { + assert!( + FORWARDED_REQUEST_HEADERS + .iter() + .any(|x| x.eq_ignore_ascii_case(h)), + "expected {h} to be in the request allow-list" + ); + } + } + + #[test] + fn allow_list_excludes_dangerous_request_headers() { + for h in [ + "host", + "authorization", + "cookie", + "x-api-key", + "x-forwarded-for", + "x-forwarded-proto", + "x-real-ip", + "via", + "transfer-encoding", + "content-length", + ] { + assert!( + !FORWARDED_REQUEST_HEADERS + .iter() + .any(|x| x.eq_ignore_ascii_case(h)), + "{h} must not be in the request allow-list" + ); + } + } + + #[test] + fn copy_response_headers_forwards_allow_list_and_anthropic_star() { + let mut src = reqwest::header::HeaderMap::new(); + src.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("application/json"), + ); + src.insert( + reqwest::header::HeaderName::from_static("anthropic-request-id"), + reqwest::header::HeaderValue::from_static("req_abc123"), + ); + src.insert( + reqwest::header::HeaderName::from_static("anthropic-organization-id"), + reqwest::header::HeaderValue::from_static("org_xyz"), + ); + src.insert( + reqwest::header::HeaderName::from_static("x-internal-secret"), + reqwest::header::HeaderValue::from_static("must-not-leak"), + ); + src.insert( + reqwest::header::HeaderName::from_static("set-cookie"), + reqwest::header::HeaderValue::from_static("session=must-not-leak"), + ); + + let mut dst = HeaderMap::new(); + copy_response_headers(&src, &mut dst); + + assert_eq!( + dst.get("content-type").and_then(|v| v.to_str().ok()), + Some("application/json") + ); + assert_eq!( + dst.get("anthropic-request-id") + .and_then(|v| v.to_str().ok()), + Some("req_abc123") + ); + // Forward-compat: anthropic-* headers we have not heard of must + // still pass through so future Anthropic features keep working. + assert_eq!( + dst.get("anthropic-organization-id") + .and_then(|v| v.to_str().ok()), + Some("org_xyz") + ); + assert!( + dst.get("x-internal-secret").is_none(), + "non-allow-listed header must not be forwarded" + ); + assert!( + dst.get("set-cookie").is_none(), + "set-cookie must never leak downstream" + ); + } + + #[test] + fn proxy_error_kind_strings_match_anthropic_vocabulary() { + assert_eq!( + ProxyErrorKind::AuthenticationError.as_str(), + "authentication_error" + ); + assert_eq!(ProxyErrorKind::ApiError.as_str(), "api_error"); + } +} diff --git a/crates/tracevault-server/src/main.rs b/crates/tracevault-server/src/main.rs index fef90c6b..d052337b 100644 --- a/crates/tracevault-server/src/main.rs +++ b/crates/tracevault-server/src/main.rs @@ -576,10 +576,22 @@ async fn main() { post(api::ci::verify_commits), ); + // Anthropic LLM proxy — authenticates via x-api-key inside the handler + // (not the standard Authorization-bearer extractor), so it is its own + // router with no rate-limiting layer. Issue #207 / parent #181. + let proxy_routes = Router::new().route( + "/proxy/anthropic/{*path}", + get(api::proxy::anthropic_proxy) + .post(api::proxy::anthropic_proxy) + .put(api::proxy::anthropic_proxy) + .delete(api::proxy::anthropic_proxy), + ); + let app = Router::new() .merge(auth_routes) .merge(public_routes) .merge(authenticated_routes) + .merge(proxy_routes) .layer(TraceLayer::new_for_http()) .layer(cors) .with_state(AppState { From 2fe30dbb9170f0d8050fe6a5083656db65554d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 11:11:38 +0200 Subject: [PATCH 04/18] test(proxy): end-to-end integration tests against wiremock stub 11 tests covering the full proxy lifecycle: non-streaming JSON passthrough (with header-swap assertion proving the TV token is replaced by the upstream Anthropic key before reaching the wire), SSE byte-for-byte streaming, upstream 4xx/5xx verbatim passthrough, upstream-unreachable -> 502 api_error, missing/invalid/org-key/ no-key-configured auth failures with the Anthropic-shaped envelope, forbidden-header stripping verified via MockServer.received_requests(), and the deferred /me/anthropic-key HTTP lifecycle (empty -> PUT-bad -> PUT-good -> GET-configured -> DELETE -> GET-empty). Adds AppState.anthropic_upstream_base so tests can redirect to wiremock without env-var mutation, plus seed_auth_session and fixture_encryption_key helpers in tests/common. Refs softwaremill/tracevault#207, parent #181. --- crates/tracevault-server/src/api/proxy.rs | 9 +- crates/tracevault-server/src/lib.rs | 4 + crates/tracevault-server/src/main.rs | 1 + crates/tracevault-server/tests/common/mod.rs | 30 + .../tests/proxy_integration.rs | 679 ++++++++++++++++++ 5 files changed, 720 insertions(+), 3 deletions(-) create mode 100644 crates/tracevault-server/tests/proxy_integration.rs diff --git a/crates/tracevault-server/src/api/proxy.rs b/crates/tracevault-server/src/api/proxy.rs index 1240a81f..acadfb8d 100644 --- a/crates/tracevault-server/src/api/proxy.rs +++ b/crates/tracevault-server/src/api/proxy.rs @@ -37,7 +37,9 @@ use crate::encryption; use crate::repo::user_anthropic_keys::UserAnthropicKeyRepo; use crate::AppState; -const ANTHROPIC_UPSTREAM_BASE: &str = "https://api.anthropic.com"; +/// Default Anthropic API base URL used in production. Overridden in tests +/// via `AppState.anthropic_upstream_base`. +pub const DEFAULT_ANTHROPIC_UPSTREAM_BASE: &str = "https://api.anthropic.com"; /// Request headers we forward upstream. Anything not on this list is dropped /// — including `host` (reqwest sets it correctly), `authorization`, `cookie`, @@ -144,10 +146,11 @@ pub async fn anthropic_proxy( // --- Step 3: build the upstream request --- let query = original_uri.query().unwrap_or(""); + let base = state.anthropic_upstream_base.trim_end_matches('/'); let upstream_url = if query.is_empty() { - format!("{ANTHROPIC_UPSTREAM_BASE}/{path}") + format!("{base}/{path}") } else { - format!("{ANTHROPIC_UPSTREAM_BASE}/{path}?{query}") + format!("{base}/{path}?{query}") }; let mut upstream_req = state diff --git a/crates/tracevault-server/src/lib.rs b/crates/tracevault-server/src/lib.rs index 7bc67b03..47f91a45 100644 --- a/crates/tracevault-server/src/lib.rs +++ b/crates/tracevault-server/src/lib.rs @@ -47,4 +47,8 @@ pub struct AppState { pub invite_expiry_minutes: u64, pub embedding_service: Option>, + /// Base URL the Anthropic proxy forwards requests to. Defaults to + /// `https://api.anthropic.com` in production; overridden in tests so a + /// wiremock stub upstream can stand in for the real Anthropic API. + pub anthropic_upstream_base: String, } diff --git a/crates/tracevault-server/src/main.rs b/crates/tracevault-server/src/main.rs index d052337b..b14377c3 100644 --- a/crates/tracevault-server/src/main.rs +++ b/crates/tracevault-server/src/main.rs @@ -602,6 +602,7 @@ async fn main() { http_client: http_client.clone(), cors_origin: cfg.cors_origin.clone(), invite_expiry_minutes: cfg.invite_expiry_minutes, + anthropic_upstream_base: api::proxy::DEFAULT_ANTHROPIC_UPSTREAM_BASE.to_string(), embedding_service, }); diff --git a/crates/tracevault-server/tests/common/mod.rs b/crates/tracevault-server/tests/common/mod.rs index a38c9e9f..d255e1df 100644 --- a/crates/tracevault-server/tests/common/mod.rs +++ b/crates/tracevault-server/tests/common/mod.rs @@ -140,3 +140,33 @@ pub async fn seed_api_key(pool: &PgPool, org_id: Uuid) -> (Uuid, String) { .unwrap(); (id, hash) } + +/// Insert an auth_sessions row for the given user with the given token's +/// sha256 hash and a far-future expiry. Returns the *raw* token so callers +/// can use it directly in an Authorization header or x-api-key header. +/// +/// Distinct from `seed_session`, which seeds the trace `sessions` table. +#[allow(dead_code)] +pub async fn seed_auth_session(pool: &PgPool, user_id: Uuid) -> String { + let (raw, hash) = tracevault_server::auth::generate_session_token(); + let expires_at = chrono::Utc::now() + chrono::Duration::days(30); + sqlx::query( + "INSERT INTO auth_sessions (user_id, token_hash, expires_at) \ + VALUES ($1, $2, $3)", + ) + .bind(user_id) + .bind(&hash) + .bind(expires_at) + .execute(pool) + .await + .unwrap(); + raw +} + +/// A deterministic 32-byte base64 encryption key suitable for `encrypt`/ +/// `decrypt`. Fixture only — never use in production. +#[allow(dead_code)] +pub fn fixture_encryption_key() -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode([0x5Au8; 32]) +} diff --git a/crates/tracevault-server/tests/proxy_integration.rs b/crates/tracevault-server/tests/proxy_integration.rs new file mode 100644 index 00000000..d4911c64 --- /dev/null +++ b/crates/tracevault-server/tests/proxy_integration.rs @@ -0,0 +1,679 @@ +//! End-to-end integration tests for the transparent Anthropic proxy +//! (issue softwaremill/tracevault#207, parent #181). +//! +//! Spins up: +//! * a real Postgres pool via `sqlx::test` with all migrations applied +//! * a `wiremock::MockServer` standing in for `api.anthropic.com` +//! * an in-process `axum::Router` carrying the proxy handler and the +//! `/api/v1/me/anthropic-key` endpoints, with `AppState` pointing at the +//! two above +//! +//! Verifies the full request/response lifecycle including auth failures, +//! header forwarding, byte-level streaming, error envelope shape, and the +//! deferred-from-T02 `/me/anthropic-key` HTTP lifecycle. + +mod common; + +use axum::{ + body::{to_bytes, Body, Bytes}, + http::{Request, StatusCode}, + routing::get, + Router, +}; +use serde_json::{json, Value}; +use tower::ServiceExt; +use tracevault_server::{api, repo_manager, AppState}; +use uuid::Uuid; +use wiremock::{ + matchers::{header, method, path as wm_path}, + Mock, MockServer, ResponseTemplate, +}; + +// --- Test harness --------------------------------------------------------- + +struct Harness { + app: Router, + upstream: MockServer, + /// Raw TV session token to send in x-api-key. Test user has a stored + /// Anthropic key of `sk-ant-test-upstream-key`. + user_session_token: String, + /// Raw TV session token belonging to a user with NO Anthropic key stored. + user_no_key_session_token: String, + /// Raw org API key hash — sent in x-api-key should be rejected with the + /// "use a user session token" error. + org_api_key_token: String, +} + +async fn build_harness(pool: sqlx::PgPool) -> Harness { + let upstream = MockServer::start().await; + + // Seed: org, two users, two sessions, one user_anthropic_keys row, one + // org api_key. + let org_id = common::seed_org(&pool).await; + let user_with_key = common::seed_user(&pool).await; + let user_without_key = common::seed_user(&pool).await; + let user_session_token = common::seed_auth_session(&pool, user_with_key).await; + let user_no_key_session_token = common::seed_auth_session(&pool, user_without_key).await; + + // Org api_key. `seed_api_key` returns (id, hash) — we want the raw token + // form, but the codebase stores only the hash. For test purposes we can + // insert a known raw+hash pair directly. + let raw_org_token = format!("tv_ak_{}", Uuid::new_v4()); + let org_token_hash = tracevault_server::auth::sha256_hex(&raw_org_token); + sqlx::query("INSERT INTO api_keys (org_id, key_hash, name) VALUES ($1, $2, $3)") + .bind(org_id) + .bind(&org_token_hash) + .bind("test-org-key") + .execute(&pool) + .await + .unwrap(); + + let encryption_key = common::fixture_encryption_key(); + + tracevault_server::repo::user_anthropic_keys::UserAnthropicKeyRepo::upsert( + &pool, + &encryption_key, + user_with_key, + "sk-ant-test-upstream-key", + ) + .await + .unwrap(); + + let state = AppState { + pool: pool.clone(), + repo_manager: repo_manager::RepoManager::new("/tmp"), + extensions: tracevault_server::extensions::community_registry(), + encryption_key: Some(encryption_key), + http_client: reqwest::Client::new(), + cors_origin: "*".to_string(), + invite_expiry_minutes: 60, + embedding_service: None, + anthropic_upstream_base: upstream.uri(), + }; + + let app = Router::new() + .route( + "/proxy/anthropic/{*path}", + get(api::proxy::anthropic_proxy) + .post(api::proxy::anthropic_proxy) + .put(api::proxy::anthropic_proxy) + .delete(api::proxy::anthropic_proxy), + ) + .route( + "/api/v1/me/anthropic-key", + get(api::me::get_anthropic_key_status) + .put(api::me::put_anthropic_key) + .delete(api::me::delete_anthropic_key), + ) + .with_state(state); + + Harness { + app, + upstream, + user_session_token, + user_no_key_session_token, + org_api_key_token: raw_org_token, + } +} + +async fn read_body_to_value(body: Body) -> Value { + let bytes = to_bytes(body, 16 * 1024 * 1024).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +async fn read_body_to_bytes(body: Body) -> Bytes { + to_bytes(body, 16 * 1024 * 1024).await.unwrap() +} + +// --- Proxy: success / passthrough ----------------------------------------- + +#[sqlx::test(migrations = "./migrations")] +async fn proxy_forwards_non_streaming_request_and_returns_upstream_json(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + let upstream_payload = json!({ + "id": "msg_01abc", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + }); + + Mock::given(method("POST")) + .and(wm_path("/v1/messages")) + // Critical: upstream must see the upstream-Anthropic key, not the + // TV session token. This is the central security property of the + // proxy's auth-swap. + .and(header("x-api-key", "sk-ant-test-upstream-key")) + .and(header("anthropic-version", "2023-06-01")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("anthropic-request-id", "req_abc123") + .set_body_json(&upstream_payload), + ) + .expect(1) + .mount(&h.upstream) + .await; + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &h.user_session_token) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ "model": "claude-haiku", "max_tokens": 1 })).unwrap(), + )) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers() + .get("anthropic-request-id") + .and_then(|v| v.to_str().ok()), + Some("req_abc123"), + "anthropic-request-id must be forwarded for client correlation" + ); + + let body = read_body_to_value(resp.into_body()).await; + assert_eq!(body, upstream_payload); +} + +#[sqlx::test(migrations = "./migrations")] +async fn proxy_streams_sse_response_byte_for_byte(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + // Three SSE events in the format Anthropic emits. We assert the + // downstream client sees the exact same bytes back, verifying that + // bytes_stream() passthrough does not parse or re-frame the SSE body. + let sse_payload = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n", + ); + + Mock::given(method("POST")) + .and(wm_path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + // set_body_raw lets us set both the bytes and the + // content-type explicitly (set_body_string defaults to + // text/plain and ignores insert_header overrides). + .set_body_raw(sse_payload.as_bytes().to_vec(), "text/event-stream"), + ) + .expect(1) + .mount(&h.upstream) + .await; + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &h.user_session_token) + .header("content-type", "application/json") + .body(Body::from(r#"{"stream":true}"#)) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers() + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("text/event-stream"), + ); + + let downstream_bytes = read_body_to_bytes(resp.into_body()).await; + assert_eq!( + downstream_bytes.as_ref(), + sse_payload.as_bytes(), + "SSE payload must be forwarded byte-for-byte" + ); +} + +#[sqlx::test(migrations = "./migrations")] +async fn proxy_passes_upstream_4xx_through_verbatim(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + let upstream_error = json!({ + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "max_tokens is required" + } + }); + + Mock::given(method("POST")) + .and(wm_path("/v1/messages")) + .respond_with(ResponseTemplate::new(400).set_body_json(&upstream_error)) + .expect(1) + .mount(&h.upstream) + .await; + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &h.user_session_token) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = read_body_to_value(resp.into_body()).await; + assert_eq!(body, upstream_error); +} + +#[sqlx::test(migrations = "./migrations")] +async fn proxy_passes_upstream_5xx_through_verbatim(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + let upstream_error = json!({ + "type": "error", + "error": { + "type": "overloaded_error", + "message": "Overloaded" + } + }); + + Mock::given(method("POST")) + .and(wm_path("/v1/messages")) + .respond_with(ResponseTemplate::new(529).set_body_json(&upstream_error)) + .expect(1) + .mount(&h.upstream) + .await; + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &h.user_session_token) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status().as_u16(), 529); + let body = read_body_to_value(resp.into_body()).await; + assert_eq!(body, upstream_error); +} + +// --- Proxy: auth + missing-key error envelope ----------------------------- + +async fn assert_anthropic_error_envelope( + resp: axum::response::Response, + expected_status: StatusCode, + expected_kind: &str, + expected_message_contains: &str, +) { + assert_eq!(resp.status(), expected_status); + let body = read_body_to_value(resp.into_body()).await; + assert_eq!(body["type"], "error", "envelope must use Anthropic shape"); + assert_eq!( + body["error"]["type"], expected_kind, + "error.type must use Anthropic vocabulary" + ); + let msg = body["error"]["message"] + .as_str() + .expect("error.message must be a string"); + assert!( + msg.contains(expected_message_contains), + "error.message {msg:?} should contain {expected_message_contains:?}" + ); +} + +#[sqlx::test(migrations = "./migrations")] +async fn proxy_rejects_missing_x_api_key(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .body(Body::empty()) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_anthropic_error_envelope( + resp, + StatusCode::UNAUTHORIZED, + "authentication_error", + "Missing x-api-key", + ) + .await; +} + +#[sqlx::test(migrations = "./migrations")] +async fn proxy_rejects_invalid_token(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", "not-a-real-token") + .body(Body::empty()) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_anthropic_error_envelope( + resp, + StatusCode::UNAUTHORIZED, + "authentication_error", + "Invalid or expired", + ) + .await; +} + +#[sqlx::test(migrations = "./migrations")] +async fn proxy_rejects_org_api_key_with_specific_message(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &h.org_api_key_token) + .body(Body::empty()) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_anthropic_error_envelope( + resp, + StatusCode::UNAUTHORIZED, + "authentication_error", + "org API key", + ) + .await; +} + +#[sqlx::test(migrations = "./migrations")] +async fn proxy_returns_502_when_upstream_unreachable(pool: sqlx::PgPool) { + // Build a state pointing at a port nothing is listening on — the + // outgoing reqwest must fail at the TCP layer and the handler must + // surface that as a 502 api_error (NOT a 500). Port 1 is reserved + // and effectively never has a listener on it; reqwest hits ECONNREFUSED + // immediately. + let user = common::seed_user(&pool).await; + let session = common::seed_auth_session(&pool, user).await; + let encryption_key = common::fixture_encryption_key(); + tracevault_server::repo::user_anthropic_keys::UserAnthropicKeyRepo::upsert( + &pool, + &encryption_key, + user, + "sk-ant-doesnt-matter", + ) + .await + .unwrap(); + + let state = AppState { + pool: pool.clone(), + repo_manager: repo_manager::RepoManager::new("/tmp"), + extensions: tracevault_server::extensions::community_registry(), + encryption_key: Some(encryption_key), + http_client: reqwest::Client::builder() + // Tight timeout so we don't sit for 30s on the OS default. + .connect_timeout(std::time::Duration::from_millis(500)) + .build() + .unwrap(), + cors_origin: "*".to_string(), + invite_expiry_minutes: 60, + embedding_service: None, + anthropic_upstream_base: "http://127.0.0.1:1".to_string(), + }; + + let app = Router::new() + .route( + "/proxy/anthropic/{*path}", + get(api::proxy::anthropic_proxy) + .post(api::proxy::anthropic_proxy) + .put(api::proxy::anthropic_proxy) + .delete(api::proxy::anthropic_proxy), + ) + .with_state(state); + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &session) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_anthropic_error_envelope( + resp, + StatusCode::BAD_GATEWAY, + "api_error", + "Upstream Anthropic API unreachable", + ) + .await; +} + +#[sqlx::test(migrations = "./migrations")] +async fn proxy_rejects_user_with_no_key_configured(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &h.user_no_key_session_token) + .body(Body::empty()) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_anthropic_error_envelope( + resp, + StatusCode::UNAUTHORIZED, + "authentication_error", + "No Anthropic API key configured", + ) + .await; +} + +// --- Proxy: header allow-list assertion ----------------------------------- + +#[sqlx::test(migrations = "./migrations")] +async fn proxy_strips_forbidden_request_headers(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + Mock::given(method("POST")) + .and(wm_path("/v1/messages")) + // Upstream should see anthropic-version and content-type forwarded. + .and(header("anthropic-version", "2023-06-01")) + .and(header("content-type", "application/json")) + // Upstream must see x-api-key swapped to the upstream key, NOT + // the TV session token. + .and(header("x-api-key", "sk-ant-test-upstream-key")) + // Custom matcher: any header NOT on our allow-list must be absent. + .and(wiremock::matchers::header_exists("anthropic-version")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(1) + .mount(&h.upstream) + .await; + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &h.user_session_token) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + // These three headers must NOT reach upstream: + .header("cookie", "session=must-not-leak") + .header("authorization", "Bearer must-not-leak") + .header("x-forwarded-for", "192.0.2.1") + .header("x-internal-secret", "must-not-leak") + .body(Body::from("{}")) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // Verify the four forbidden headers really did not make it upstream. + // wiremock records received requests; we can inspect them after the + // call to assert absence. + let recv = h.upstream.received_requests().await.unwrap(); + assert_eq!(recv.len(), 1); + let upstream_req = &recv[0]; + for forbidden in [ + "cookie", + "authorization", + "x-forwarded-for", + "x-internal-secret", + ] { + assert!( + !upstream_req.headers.contains_key(forbidden), + "header {forbidden} must not be forwarded to upstream" + ); + } +} + +// --- /api/v1/me/anthropic-key HTTP lifecycle (deferred from T02) --------- + +#[sqlx::test(migrations = "./migrations")] +async fn me_anthropic_key_lifecycle(pool: sqlx::PgPool) { + // Use a clean pool for this test (no key pre-seeded) — `build_harness` + // seeds one, so we make a parallel handcrafted state instead. + let upstream = MockServer::start().await; + let user = common::seed_user(&pool).await; + let session = common::seed_auth_session(&pool, user).await; + let encryption_key = common::fixture_encryption_key(); + + let state = AppState { + pool: pool.clone(), + repo_manager: repo_manager::RepoManager::new("/tmp"), + extensions: tracevault_server::extensions::community_registry(), + encryption_key: Some(encryption_key), + http_client: reqwest::Client::new(), + cors_origin: "*".to_string(), + invite_expiry_minutes: 60, + embedding_service: None, + anthropic_upstream_base: upstream.uri(), + }; + + let app = Router::new() + .route( + "/api/v1/me/anthropic-key", + get(api::me::get_anthropic_key_status) + .put(api::me::put_anthropic_key) + .delete(api::me::delete_anthropic_key), + ) + .with_state(state); + + let bearer = format!("Bearer {session}"); + + // GET before PUT -> configured=false + let resp = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/me/anthropic-key") + .header("authorization", &bearer) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = read_body_to_value(resp.into_body()).await; + assert_eq!(body["configured"], false); + assert!(body["configured_at"].is_null()); + + // PUT empty key -> 400 + let resp = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/v1/me/anthropic-key") + .header("authorization", &bearer) + .header("content-type", "application/json") + .body(Body::from(r#"{"key":""}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // PUT key with wrong prefix -> 400 + let resp = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/v1/me/anthropic-key") + .header("authorization", &bearer) + .header("content-type", "application/json") + .body(Body::from(r#"{"key":"not-an-anthropic-key"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // PUT valid key -> 204 + let resp = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/v1/me/anthropic-key") + .header("authorization", &bearer) + .header("content-type", "application/json") + .body(Body::from(r#"{"key":"sk-ant-test-12345"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // GET after PUT -> configured=true with timestamp + let resp = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/me/anthropic-key") + .header("authorization", &bearer) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = read_body_to_value(resp.into_body()).await; + assert_eq!(body["configured"], true); + assert!(body["configured_at"].is_string()); + + // DELETE -> 204 + let resp = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/api/v1/me/anthropic-key") + .header("authorization", &bearer) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // GET after DELETE -> configured=false again + let resp = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/me/anthropic-key") + .header("authorization", &bearer) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = read_body_to_value(resp.into_body()).await; + assert_eq!(body["configured"], false); +} From d3987ae5db8f03025c1a47db9012a6568b0d1bef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 11:17:25 +0200 Subject: [PATCH 05/18] test(proxy): real-Anthropic integration tests (#[ignore]-d) Two tests against api.anthropic.com that verify byte-fidelity in the real-network case wiremock cannot model (TLS, HTTP/2, real SSE event vocabulary). Both are #[ignore]-d so they do not run in CI. Run with: ANTHROPIC_API_KEY=sk-ant-... \ cargo test -p tracevault-server --test proxy_real_anthropic \ -- --ignored --nocapture Non-streaming asserts type=message + non-empty content[0].text + stop_reason. Streaming asserts the body contains message_start and content_block_delta SSE event markers. Refs softwaremill/tracevault#207, parent #181. --- .../tests/proxy_real_anthropic.rs | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 crates/tracevault-server/tests/proxy_real_anthropic.rs diff --git a/crates/tracevault-server/tests/proxy_real_anthropic.rs b/crates/tracevault-server/tests/proxy_real_anthropic.rs new file mode 100644 index 00000000..f2dd6639 --- /dev/null +++ b/crates/tracevault-server/tests/proxy_real_anthropic.rs @@ -0,0 +1,165 @@ +//! Real-network integration tests against `api.anthropic.com`. Both tests +//! are `#[ignore]` so they do not run in CI or on every `cargo test`. Run +//! locally before merging the proxy slice with: +//! +//! ```sh +//! ANTHROPIC_API_KEY=sk-ant-... \ +//! cargo test -p tracevault-server --test proxy_real_anthropic \ +//! -- --ignored --nocapture +//! ``` +//! +//! These verify byte-fidelity against the real upstream that wiremock can +//! only approximate: TLS, HTTP/2, real anthropic-version negotiation, and +//! the actual SSE event vocabulary Anthropic emits today. + +mod common; + +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + routing::get, + Router, +}; +use serde_json::{json, Value}; +use tower::ServiceExt; +use tracevault_server::{api, repo_manager, AppState}; + +const MODEL: &str = "claude-haiku-4-5"; + +fn require_anthropic_key() -> String { + std::env::var("ANTHROPIC_API_KEY") + .expect("ANTHROPIC_API_KEY env var must be set to run #[ignore]-d real-Anthropic tests") +} + +async fn build_real_state(pool: &sqlx::PgPool, upstream_key: &str) -> (AppState, String) { + let user = common::seed_user(pool).await; + let session_token = common::seed_auth_session(pool, user).await; + let encryption_key = common::fixture_encryption_key(); + + tracevault_server::repo::user_anthropic_keys::UserAnthropicKeyRepo::upsert( + pool, + &encryption_key, + user, + upstream_key, + ) + .await + .unwrap(); + + let state = AppState { + pool: pool.clone(), + repo_manager: repo_manager::RepoManager::new("/tmp"), + extensions: tracevault_server::extensions::community_registry(), + encryption_key: Some(encryption_key), + http_client: reqwest::Client::new(), + cors_origin: "*".to_string(), + invite_expiry_minutes: 60, + embedding_service: None, + // Defaults to the real api.anthropic.com — exactly what we want here. + anthropic_upstream_base: api::proxy::DEFAULT_ANTHROPIC_UPSTREAM_BASE.to_string(), + }; + (state, session_token) +} + +fn build_proxy_app(state: AppState) -> Router { + Router::new() + .route( + "/proxy/anthropic/{*path}", + get(api::proxy::anthropic_proxy) + .post(api::proxy::anthropic_proxy) + .put(api::proxy::anthropic_proxy) + .delete(api::proxy::anthropic_proxy), + ) + .with_state(state) +} + +#[sqlx::test(migrations = "./migrations")] +#[ignore = "hits api.anthropic.com — requires ANTHROPIC_API_KEY"] +async fn real_anthropic_non_streaming_messages(pool: sqlx::PgPool) { + let upstream_key = require_anthropic_key(); + let (state, session) = build_real_state(&pool, &upstream_key).await; + let app = build_proxy_app(state); + + let req_body = json!({ + "model": MODEL, + "max_tokens": 16, + "messages": [ + { "role": "user", "content": "Say hi in one word." } + ] + }); + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &session) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&req_body).unwrap())) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let body_bytes = to_bytes(resp.into_body(), 16 * 1024 * 1024).await.unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap_or_else(|e| { + panic!( + "non-JSON body from upstream (status {status}): {:?}\n{}", + e, + String::from_utf8_lossy(&body_bytes) + ) + }); + + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert_eq!(body["type"], "message"); + assert!( + body["content"] + .as_array() + .and_then(|a| a.first()) + .and_then(|c| c["text"].as_str()) + .is_some_and(|t| !t.is_empty()), + "expected non-empty content[0].text; got {body}" + ); + assert!( + body["stop_reason"].is_string(), + "expected stop_reason; got {body}" + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[ignore = "hits api.anthropic.com — requires ANTHROPIC_API_KEY"] +async fn real_anthropic_streaming_messages(pool: sqlx::PgPool) { + let upstream_key = require_anthropic_key(); + let (state, session) = build_real_state(&pool, &upstream_key).await; + let app = build_proxy_app(state); + + let req_body = json!({ + "model": MODEL, + "max_tokens": 16, + "stream": true, + "messages": [ + { "role": "user", "content": "Count to 3." } + ] + }); + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &session) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&req_body).unwrap())) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let body_bytes = to_bytes(resp.into_body(), 16 * 1024 * 1024).await.unwrap(); + let body_text = String::from_utf8_lossy(&body_bytes); + + assert_eq!(status, StatusCode::OK, "non-200 from upstream: {body_text}"); + assert!( + body_text.contains("event: message_start"), + "expected message_start SSE event in stream; got:\n{body_text}" + ); + assert!( + body_text.contains("event: content_block_delta"), + "expected content_block_delta SSE event in stream; got:\n{body_text}" + ); +} From 76783a99cdf342089303cb9615bbc1077972bb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 11:17:25 +0200 Subject: [PATCH 06/18] feat(proxy-web): /me/proxy/ page for configuring Anthropic key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status / Save / Replace / Remove (with inline confirm) / copy-block with the proxy base URL and env-var snippet. Saved keys are never shown again — the GET endpoint returns only { configured, configured_at } and the form clears after a successful PUT. Refs softwaremill/tracevault#207, parent #181. --- web/src/routes/me/proxy/+page.svelte | 267 +++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 web/src/routes/me/proxy/+page.svelte diff --git a/web/src/routes/me/proxy/+page.svelte b/web/src/routes/me/proxy/+page.svelte new file mode 100644 index 00000000..ab72ec5d --- /dev/null +++ b/web/src/routes/me/proxy/+page.svelte @@ -0,0 +1,267 @@ + + + + Proxy - TraceVault + + +
+
+

LLM Proxy

+

+ Route AI coding tools (Claude Code, GSD2, Cursor, Codex CLI) through TraceVault by + pointing them at the proxy URL below. Your stored Anthropic API key is used internally + — it is never returned to the browser after saving. +

+
+ + {#if error} + + {/if} + + {#if success} + + Success + {success} + + {/if} + + {#if loading} +
+ + Loading... +
+ {:else} + +
+
+ Anthropic API Key +

+ Used by the proxy to authenticate with api.anthropic.com on your behalf. +

+
+
+ {#if status?.configured} +
+ + Configured + {#if status.configured_at} + last set {formatTimestamp(status.configured_at)} + {/if} +
+ {:else} +
+ + Not configured +
+ {/if} + +
+
+ + +

+ Saved keys are never displayed again. Get one from + console.anthropic.com. +

+
+ +
+ + {#if status?.configured} + {#if confirmingRemove} + + + {:else} + + {/if} + {/if} +
+
+
+
+ + +
+
+ How to use +

+ Configure your tool to send Anthropic requests through TraceVault. +

+
+
+
+ +
+ + {proxyBaseUrl || '(loading…)'} + + +
+
+
+

Set these environment variables for your AI tool:

+
ANTHROPIC_BASE_URL={proxyBaseUrl}
+ANTHROPIC_API_KEY=<your TraceVault session token>
+

+ Your TraceVault session token is in + ~/.tracevault/credentials.json after running + tracevault login, or run tracevault proxy info for the + full configuration snippet. +

+
+
+
+ {/if} +
From d376a4a86fdfa2192ce68292e7076131d4cc44f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 11:17:25 +0200 Subject: [PATCH 07/18] feat(cli): tracevault proxy info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New nested subcommand that prints the proxy base URL, the configured TraceVault server, the credentials path, and a step-by- step env-var snippet for setting ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY in Claude Code / GSD2 / Cursor / Codex CLI. Read-only — no network call. Refs softwaremill/tracevault#207, parent #181. --- crates/tracevault-cli/src/commands/mod.rs | 1 + crates/tracevault-cli/src/commands/proxy.rs | 64 +++++++++++++++++++++ crates/tracevault-cli/src/main.rs | 20 +++++++ 3 files changed, 85 insertions(+) create mode 100644 crates/tracevault-cli/src/commands/proxy.rs diff --git a/crates/tracevault-cli/src/commands/mod.rs b/crates/tracevault-cli/src/commands/mod.rs index 8a3adbc0..9f0ba383 100644 --- a/crates/tracevault-cli/src/commands/mod.rs +++ b/crates/tracevault-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod flush; pub mod init; pub mod login; pub mod logout; +pub mod proxy; pub mod stats; pub mod status; pub mod stream; diff --git a/crates/tracevault-cli/src/commands/proxy.rs b/crates/tracevault-cli/src/commands/proxy.rs new file mode 100644 index 00000000..f7d02350 --- /dev/null +++ b/crates/tracevault-cli/src/commands/proxy.rs @@ -0,0 +1,64 @@ +//! `tracevault proxy info` — print the TraceVault LLM proxy configuration +//! a user needs to point their AI tool (Claude Code, GSD2, Cursor, etc.) at +//! the proxy. +//! +//! Read-only and purely local: never calls the network. Output is intended +//! to be copy-pasted directly into a shell or tool config. + +use crate::credentials::Credentials; + +const ANSI_BOLD: &str = "\x1b[1m"; +const ANSI_DIM: &str = "\x1b[2m"; +const ANSI_RESET: &str = "\x1b[0m"; + +/// Print the proxy configuration. Returns process exit code: 0 on success, +/// 1 when no credentials are available (user has not logged in). +pub fn run_proxy_info() -> i32 { + let creds = match Credentials::load() { + Some(c) => c, + None => { + eprintln!( + "Not logged in. Run `tracevault login --server-url ` first \ + to obtain a TraceVault session token, then try again." + ); + eprintln!( + "Credentials file expected at: {}", + Credentials::path().display() + ); + return 1; + } + }; + + let server_url = creds.server_url.trim_end_matches('/'); + let proxy_url = format!("{server_url}/proxy/anthropic"); + let creds_path = Credentials::path(); + + println!("{ANSI_BOLD}TraceVault LLM Proxy{ANSI_RESET}"); + println!(); + println!(" Server: {server_url}"); + println!(" Proxy base URL: {ANSI_BOLD}{proxy_url}{ANSI_RESET}"); + println!(" Credentials file: {}", creds_path.display()); + println!(); + println!("{ANSI_BOLD}Setup{ANSI_RESET}"); + println!(); + println!(" 1. Configure your Anthropic API key once at:"); + println!(" {server_url}/me/proxy"); + println!(); + println!(" 2. Set these environment variables for your AI tool:"); + println!(); + println!(" {ANSI_BOLD}export ANTHROPIC_BASE_URL=\"{proxy_url}\"{ANSI_RESET}"); + println!( + " {ANSI_BOLD}export ANTHROPIC_API_KEY=\"\"{ANSI_RESET}" + ); + println!(); + println!( + " {ANSI_DIM}Your TraceVault session token lives in {} as the \"token\" field.{ANSI_RESET}", + creds_path.display() + ); + println!(); + println!(" 3. Run your AI tool as usual. Requests go through TraceVault and are"); + println!(" forwarded to api.anthropic.com using the Anthropic key you stored"); + println!(" in step 1."); + + 0 +} diff --git a/crates/tracevault-cli/src/main.rs b/crates/tracevault-cli/src/main.rs index b4a69907..9f33706b 100644 --- a/crates/tracevault-cli/src/main.rs +++ b/crates/tracevault-cli/src/main.rs @@ -94,6 +94,18 @@ enum Cli { /// policies configured on the TraceVault server. #[command(name = "agent-policies")] AgentPolicies, + /// LLM proxy commands. + Proxy { + #[command(subcommand)] + cmd: ProxyCmd, + }, +} + +#[derive(clap::Subcommand)] +enum ProxyCmd { + /// Print the proxy URL and setup instructions for AI tools (Claude Code, + /// GSD2, Cursor, Codex CLI, etc.). + Info, } #[tokio::main] @@ -212,5 +224,13 @@ async fn main() { std::process::exit(1); } } + Cli::Proxy { cmd } => match cmd { + ProxyCmd::Info => { + let code = commands::proxy::run_proxy_info(); + if code != 0 { + std::process::exit(code); + } + } + }, } } From 667846a88d5feaa404162441c5304c10c327ba23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 12:10:50 +0200 Subject: [PATCH 08/18] test(proxy): load .env in real-Anthropic tests dotenvy::dotenv() walks up from the test CWD to the workspace root and loads any .env present, so users can put ANTHROPIC_API_KEY in .env instead of exporting it per-shell. Note: DATABASE_URL still has to be set externally because sqlx::test constructs the pool before the test body runs. Refs softwaremill/tracevault#207, parent #181. --- crates/tracevault-server/tests/proxy_real_anthropic.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/tracevault-server/tests/proxy_real_anthropic.rs b/crates/tracevault-server/tests/proxy_real_anthropic.rs index f2dd6639..6a0c45d0 100644 --- a/crates/tracevault-server/tests/proxy_real_anthropic.rs +++ b/crates/tracevault-server/tests/proxy_real_anthropic.rs @@ -27,8 +27,14 @@ use tracevault_server::{api, repo_manager, AppState}; const MODEL: &str = "claude-haiku-4-5"; fn require_anthropic_key() -> String { - std::env::var("ANTHROPIC_API_KEY") - .expect("ANTHROPIC_API_KEY env var must be set to run #[ignore]-d real-Anthropic tests") + // Walk up from the test binary's CWD looking for a .env file (workspace + // root in most layouts). Silently no-op if no .env is present — the + // env var may still be set externally. + let _ = dotenvy::dotenv(); + std::env::var("ANTHROPIC_API_KEY").expect( + "ANTHROPIC_API_KEY env var must be set to run #[ignore]-d real-Anthropic tests \ + (export it, or add it to .env at the workspace root)", + ) } async fn build_real_state(pool: &sqlx::PgPool, upstream_key: &str) -> (AppState, String) { From 90310a82a0749c8c63c311311d70bd5a28e71496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 12:30:43 +0200 Subject: [PATCH 09/18] test(cli): make init_test tests TTY-independent resolve_claude_target() in init.rs branches on io::stdin().is_terminal(): non-TTY -> Shared default, TTY -> interactive prompt. Tests passing claude_settings=None were taking the interactive branch when run from a real terminal, which raced on stdin under parallel execution and caused init_installs_claude_hooks / init_merges_into_existing_settings / init_no_gitignore_skips_gitignore_update to fail intermittently depending on test scheduling. Pass Some(ClaudeSettingsTarget::Shared) explicitly from every test that previously passed None. This pins behavior independent of the runner's stdin shape and matches what the tests actually want to verify. Follow-up: init_in_directory itself should take an interactive: bool arg so production callers can't accidentally fall into the same trap. File a separate issue. Refs softwaremill/tracevault#207, parent #181. --- crates/tracevault-cli/tests/init_test.rs | 143 ++++++++++++++++------- 1 file changed, 104 insertions(+), 39 deletions(-) diff --git a/crates/tracevault-cli/tests/init_test.rs b/crates/tracevault-cli/tests/init_test.rs index 850b04e7..10cf8fbc 100644 --- a/crates/tracevault-cli/tests/init_test.rs +++ b/crates/tracevault-cli/tests/init_test.rs @@ -11,8 +11,13 @@ fn tmp_git_repo() -> TempDir { #[tokio::test] async fn init_fails_without_git() { let tmp = TempDir::new().unwrap(); - let result = - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false).await; + let result = tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await; assert!(result.is_err()); assert!(result .unwrap_err() @@ -25,9 +30,14 @@ async fn init_creates_tracevault_config() { let tmp = tmp_git_repo(); let config_path = tmp.path().join(".tracevault").join("config.toml"); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); assert!(config_path.exists()); let content = fs::read_to_string(&config_path).unwrap(); @@ -38,9 +48,14 @@ async fn init_creates_tracevault_config() { async fn init_creates_directory_structure() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); assert!(tmp.path().join(".tracevault").exists()); assert!(tmp.path().join(".tracevault/sessions").exists()); @@ -56,9 +71,14 @@ async fn init_creates_directory_structure() { async fn init_installs_claude_hooks() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); let settings_path = tmp.path().join(".claude/settings.json"); assert!(settings_path.exists()); @@ -80,9 +100,14 @@ async fn init_merges_into_existing_settings() { fs::create_dir_all(&claude_dir).unwrap(); fs::write(claude_dir.join("settings.json"), r#"{"model": "opus"}"#).unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap(); let settings: serde_json::Value = serde_json::from_str(&content).unwrap(); @@ -105,9 +130,14 @@ fn tracevault_hooks_has_pre_post_and_notification() { async fn init_installs_git_pre_push_hook() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); let hook_path = tmp.path().join(".git/hooks/pre-push"); assert!(hook_path.exists()); @@ -133,9 +163,14 @@ async fn init_preserves_existing_pre_push_hook() { ) .unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); let content = fs::read_to_string(hooks_dir.join("pre-push")).unwrap(); // Existing content preserved @@ -150,12 +185,22 @@ async fn init_preserves_existing_pre_push_hook() { async fn init_does_not_duplicate_hook_on_reinit() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); let content = fs::read_to_string(tmp.path().join(".git/hooks/pre-push")).unwrap(); let marker_count = content.matches("# tracevault:enforce").count(); @@ -169,9 +214,14 @@ async fn init_does_not_duplicate_hook_on_reinit() { async fn init_installs_post_commit_hook() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); let hook_path = tmp.path().join(".git/hooks/post-commit"); assert!(hook_path.exists()); @@ -186,12 +236,22 @@ async fn init_installs_post_commit_hook() { async fn init_does_not_duplicate_post_commit_hook_on_reinit() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + false, + ) + .await + .unwrap(); let content = fs::read_to_string(tmp.path().join(".git/hooks/post-commit")).unwrap(); let marker_count = content.matches("# tracevault:post-commit").count(); @@ -280,7 +340,7 @@ async fn init_writes_server_url_to_config() { tracevault_cli::commands::init::init_in_directory( tmp.path(), Some("https://tv.example.com"), - None, + Some(ClaudeSettingsTarget::Shared), false, ) .await @@ -295,9 +355,14 @@ async fn init_writes_server_url_to_config() { async fn init_no_gitignore_skips_gitignore_update() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, true) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Shared), + true, + ) + .await + .unwrap(); // .gitignore should not exist (tmp_git_repo creates a bare repo without one) // or should not contain any tracevault entries if it already existed From 7fb38302da8689fc06a2f5bb2ed044471e9350a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 13:03:19 +0200 Subject: [PATCH 10/18] feat(proxy-web): add LLM Proxy nav entry in sidebar footer Without a link, /me/proxy/ was reachable only by typing the URL. Adds a Cable-icon button in the sidebar footer (both expanded and collapsed variants) above the Log out button, since the Anthropic key is user-scoped and the footer is where per-user controls live. Refs softwaremill/tracevault#207, parent #181. --- .../components/sidebar/SidebarFooter.svelte | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/sidebar/SidebarFooter.svelte b/web/src/lib/components/sidebar/SidebarFooter.svelte index 0c81fdb0..f7a58441 100644 --- a/web/src/lib/components/sidebar/SidebarFooter.svelte +++ b/web/src/lib/components/sidebar/SidebarFooter.svelte @@ -1,6 +1,6 @@ + +{#if loaded} + + {@render children()} + +{:else} +
+ Loading... +
+{/if} From 95a8ab8d198eaa21f38a5e9c5355eefe42da623e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 13:28:35 +0200 Subject: [PATCH 12/18] fix(proxy-web): address PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * me/+layout.svelte: svelte/store calls the subscriber synchronously on .subscribe(), so the previous in-callback unsub() was running while `unsub` was still in its TDZ. Stash the unsubscribe in an outer binding and call it after the Promise resolves. * me/proxy/+page.svelte: log clipboard-write failures via console.warn instead of swallowing silently — easier to debug, still no page-level error since the user can copy manually. Refs softwaremill/tracevault#207, parent #181. --- web/src/routes/me/+layout.svelte | 12 ++++++++---- web/src/routes/me/proxy/+page.svelte | 8 ++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/web/src/routes/me/+layout.svelte b/web/src/routes/me/+layout.svelte index aeef0c3a..c877da99 100644 --- a/web/src/routes/me/+layout.svelte +++ b/web/src/routes/me/+layout.svelte @@ -25,12 +25,16 @@ // previous navigation we just use that; otherwise default to the // first membership. const orgs: OrgInfo[] = await orgStore.loadOrgs(); + // Read the current store snapshot. svelte/store invokes the + // subscriber synchronously with the current value on `.subscribe()`, + // so we cannot call `unsub` from inside that first callback — + // at that point `unsub` is still in its TDZ. Stash the unsubscribe + // in an outer binding and call it after the Promise settles. + let unsub: (() => void) | undefined; const state = await new Promise<{ current: OrgInfo | null }>((resolve) => { - const unsub = orgStore.subscribe((s) => { - resolve(s); - unsub(); - }); + unsub = orgStore.subscribe((s) => resolve(s)); }); + unsub?.(); if (!state.current && orgs.length > 0) { orgStore.setCurrent(orgs[0]); } diff --git a/web/src/routes/me/proxy/+page.svelte b/web/src/routes/me/proxy/+page.svelte index ab72ec5d..9b07c1e6 100644 --- a/web/src/routes/me/proxy/+page.svelte +++ b/web/src/routes/me/proxy/+page.svelte @@ -81,8 +81,12 @@ await navigator.clipboard.writeText(proxyBaseUrl); copied = true; setTimeout(() => (copied = false), 1500); - } catch { - // Clipboard API can fail in some browsers / contexts; ignore silently. + } catch (err) { + // Clipboard API can fail in some browsers / contexts (e.g. when the + // page is not focused, or in non-secure-context iframes). Log so + // it's debuggable but don't surface as a page-level error — the + // user can still copy manually. + console.warn('Failed to copy proxy URL to clipboard:', err); } } From 40cb68af03756dd7dac9f8ba387d155f6be484ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 13:55:03 +0200 Subject: [PATCH 13/18] refactor(proxy): split anthropic_proxy into 3 concern-scoped fns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review: extract `authenticate`, `forward_to_upstream`, and `build_downstream_response` so the handler is a thin orchestration shell and each concern (auth+credential loading, upstream request construction, downstream response streaming) lives in its own private function. Behavior is identical — all 11 proxy_integration tests still pass. Refs softwaremill/tracevault#207, parent #181. --- crates/tracevault-server/src/api/proxy.rs | 144 +++++++++++++++------- 1 file changed, 98 insertions(+), 46 deletions(-) diff --git a/crates/tracevault-server/src/api/proxy.rs b/crates/tracevault-server/src/api/proxy.rs index acadfb8d..ad32bc58 100644 --- a/crates/tracevault-server/src/api/proxy.rs +++ b/crates/tracevault-server/src/api/proxy.rs @@ -104,6 +104,17 @@ fn anthropic_error(status: StatusCode, kind: ProxyErrorKind, message: &str) -> R /// /// Path layout: `path` is everything after `/proxy/anthropic/` (no leading /// slash). Query string is forwarded verbatim from the original URI. +/// +/// This is a thin orchestration shell: it sequences three concerns that +/// live in their own private functions so the responsibilities are easy +/// to audit independently: +/// +/// 1. `authenticate` — resolve `x-api-key` to a user_id and load the +/// user's decrypted upstream credential. +/// 2. `forward_to_upstream` — construct the upstream request (URL, +/// header allow-list, key injection) and dispatch it. +/// 3. `build_downstream_response` — stream the upstream body back to +/// the client with response-header forwarding. pub async fn anthropic_proxy( State(state): State, Path(path): Path, @@ -114,7 +125,49 @@ pub async fn anthropic_proxy( ) -> Response { let start = Instant::now(); - // --- Step 1: extract and resolve the TV token in x-api-key --- + let (user_id, upstream_key) = match authenticate(&state, &headers, &path).await { + Ok(pair) => pair, + Err(resp) => return resp, + }; + + let upstream_resp = match forward_to_upstream( + &state, + &method, + &path, + original_uri.query().unwrap_or(""), + &headers, + body, + &upstream_key, + user_id, + start, + ) + .await + { + Ok(r) => r, + Err(resp) => return resp, + }; + + let upstream_status = upstream_resp.status(); + tracing::info!( + user_id = %user_id, + path = %path, + upstream_status = upstream_status.as_u16(), + duration_ms = start.elapsed().as_millis() as u64, + "proxied request" + ); + + build_downstream_response(upstream_resp) +} + +/// Concern 1: extract `x-api-key`, resolve it to a user, and load that +/// user's decrypted Anthropic credential. Returns the +/// `(user_id, upstream_plaintext_key)` pair on success, or an +/// Anthropic-shaped error envelope on any auth/credential failure. +async fn authenticate( + state: &AppState, + headers: &HeaderMap, + path: &str, +) -> Result<(Uuid, String), Response> { let tv_token = match headers.get("x-api-key").and_then(|v| v.to_str().ok()) { Some(t) if !t.is_empty() => t, _ => { @@ -124,28 +177,35 @@ pub async fn anthropic_proxy( path = %path, "proxy auth failed" ); - return anthropic_error( + return Err(anthropic_error( StatusCode::UNAUTHORIZED, ProxyErrorKind::AuthenticationError, "Missing x-api-key header", - ); + )); } }; let token_hash = sha256_hex(tv_token); - let user_id = match resolve_token(&state, &token_hash).await { - Ok(uid) => uid, - Err(resp) => return resp, - }; - - // --- Step 2: load + decrypt the user's Anthropic key --- - let upstream_key = match load_anthropic_key(&state, user_id).await { - Ok(k) => k, - Err(resp) => return resp, - }; + let user_id = resolve_token(state, &token_hash).await?; + let upstream_key = load_anthropic_key(state, user_id).await?; + Ok((user_id, upstream_key)) +} - // --- Step 3: build the upstream request --- - let query = original_uri.query().unwrap_or(""); +/// Concern 2: build the upstream request from the user's downstream +/// request — URL composition, header allow-list, decrypted-key injection — +/// then dispatch it. +#[allow(clippy::too_many_arguments)] +async fn forward_to_upstream( + state: &AppState, + method: &Method, + path: &str, + query: &str, + headers: &HeaderMap, + body: Bytes, + upstream_key: &str, + user_id: Uuid, + start: Instant, +) -> Result { let base = state.anthropic_upstream_base.trim_end_matches('/'); let upstream_url = if query.is_empty() { format!("{base}/{path}") @@ -166,43 +226,35 @@ pub async fn anthropic_proxy( // Inject the decrypted upstream key. Done after the allow-list loop so // a client-sent x-api-key cannot bleed through even if the allow-list // is ever broadened by mistake. - upstream_req = upstream_req.header("x-api-key", &upstream_key); + upstream_req = upstream_req.header("x-api-key", upstream_key); - // --- Step 4: dispatch and capture upstream response --- - let upstream_resp = match upstream_req.send().await { - Ok(r) => r, - Err(e) => { - tracing::warn!( - user_id = %user_id, - path = %path, - error_type = "api_error", - duration_ms = start.elapsed().as_millis() as u64, - err = %e, - "upstream request to Anthropic failed" - ); - return anthropic_error( - StatusCode::BAD_GATEWAY, - ProxyErrorKind::ApiError, - "Upstream Anthropic API unreachable", - ); - } - }; + upstream_req.send().await.map_err(|e| { + tracing::warn!( + user_id = %user_id, + path = %path, + error_type = "api_error", + duration_ms = start.elapsed().as_millis() as u64, + err = %e, + "upstream request to Anthropic failed" + ); + anthropic_error( + StatusCode::BAD_GATEWAY, + ProxyErrorKind::ApiError, + "Upstream Anthropic API unreachable", + ) + }) +} +/// Concern 3: turn the upstream `reqwest::Response` into an axum +/// `Response` — copies status + allow-listed response headers and streams +/// the body byte-for-byte via `bytes_stream()` so SSE responses pass +/// through without buffering. +fn build_downstream_response(upstream_resp: reqwest::Response) -> Response { let upstream_status = upstream_resp.status(); let upstream_headers = upstream_resp.headers().clone(); - - tracing::info!( - user_id = %user_id, - path = %path, - upstream_status = upstream_status.as_u16(), - duration_ms = start.elapsed().as_millis() as u64, - "proxied request" - ); - - // --- Step 5: stream the response body back --- let body_stream = upstream_resp.bytes_stream(); - let mut downstream = Response::builder().status(upstream_status); + let mut downstream = Response::builder().status(upstream_status); if let Some(hdrs) = downstream.headers_mut() { copy_response_headers(&upstream_headers, hdrs); } From e2bcc28d6099e161ec0993eb71beb274e766d01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 17:04:16 +0200 Subject: [PATCH 14/18] fix(me): cap Anthropic key length at 256 chars Reject keys longer than 256 chars at the PUT /api/v1/me/anthropic-key endpoint so a malformed paste cannot persist megabytes of junk on the user_anthropic_keys row. Real Anthropic keys are ~110 chars. Self-review minor #2 on PR #208. --- crates/tracevault-server/src/api/me.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/tracevault-server/src/api/me.rs b/crates/tracevault-server/src/api/me.rs index ec3d281b..f488818b 100644 --- a/crates/tracevault-server/src/api/me.rs +++ b/crates/tracevault-server/src/api/me.rs @@ -74,6 +74,15 @@ pub async fn put_anthropic_key( "Anthropic key must not be empty".into(), )); } + // Real Anthropic keys are ~110 chars; cap at 256 to leave generous + // headroom for future formats while preventing the endpoint from + // accepting a ~2 MB junk string and persisting it encrypted on the + // user_anthropic_keys row. + if key.len() > 256 { + return Err(AppError::BadRequest( + "Anthropic key is unreasonably long (max 256 chars)".into(), + )); + } // Anthropic API keys begin with `sk-ant-` (modern format). We reject // anything that doesn't look like one to catch obvious paste mistakes // (TV session token, empty string, environment variable name, etc.). From e18cdbedbf8e9ee289412f6860b87f60687186b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 17:04:30 +0200 Subject: [PATCH 15/18] fix(proxy): reject `..` segments + simplify response-header copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reject path segments equal to `..` at the proxy entry with an Anthropic-shaped 400. Today reqwest/url normalize `..` so no cross-host escape is possible, but a future regional Anthropic base URL with a path prefix could be escaped — guard at the entry point. * Replace round-tripped `HeaderName::from_bytes` / `HeaderValue::from_bytes` in copy_response_headers with a direct `name.clone() / value.clone()` — the upstream-validated headers don't need re-validation, and the silent-drop branch on validation failure was a confusing prod debug story waiting to happen. * Drop the redundant `to_ascii_lowercase()` in the anthropic-* prefix check (http::HeaderName names are lowercase by construction). * New integration test: `proxy_rejects_path_traversal_segments`. Self-review minor #1 + minor #3 + nit #1 on PR #208. --- crates/tracevault-server/src/api/proxy.rs | 44 ++++++++--- .../tests/proxy_integration.rs | 76 ++++++++++++++++++- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/crates/tracevault-server/src/api/proxy.rs b/crates/tracevault-server/src/api/proxy.rs index ad32bc58..f380aff0 100644 --- a/crates/tracevault-server/src/api/proxy.rs +++ b/crates/tracevault-server/src/api/proxy.rs @@ -24,7 +24,7 @@ use axum::{ body::{Body, Bytes}, extract::{OriginalUri, Path, State}, - http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, + http::{HeaderMap, Method, StatusCode}, response::{IntoResponse, Response}, Json, }; @@ -125,6 +125,28 @@ pub async fn anthropic_proxy( ) -> Response { let start = Instant::now(); + // Defense in depth: reject `..` segments in the captured path before + // composing the upstream URL. `reqwest`/`url` normalize `..` before + // sending, so today this only collapses paths within api.anthropic.com + // (no host escape is possible). But `anthropic_upstream_base` is a + // configurable string — if it ever carries a path prefix (e.g. a + // future Anthropic regional endpoint with `/v1/` baked in), `..` could + // escape that prefix. Rejecting at the entry point keeps this safe + // regardless of how the base URL is configured later. + if path.split(['/', '\\']).any(|seg| seg == "..") { + tracing::warn!( + error_type = "authentication_error", + reason = "path_traversal_segment", + path = %path, + "proxy rejected path containing '..'" + ); + return anthropic_error( + StatusCode::BAD_REQUEST, + ProxyErrorKind::ApiError, + "Invalid path", + ); + } + let (user_id, upstream_key) = match authenticate(&state, &headers, &path).await { Ok(pair) => pair, Err(resp) => return resp, @@ -214,7 +236,7 @@ async fn forward_to_upstream( }; let mut upstream_req = state - .http_client + .proxy_http_client .request(method.clone(), &upstream_url) .body(body); @@ -413,22 +435,26 @@ async fn load_anthropic_key(state: &AppState, user_id: Uuid) -> Result Harness { extensions: tracevault_server::extensions::community_registry(), encryption_key: Some(encryption_key), http_client: reqwest::Client::new(), + proxy_http_client: reqwest::Client::new(), cors_origin: "*".to_string(), invite_expiry_minutes: 60, embedding_service: None, @@ -99,6 +101,9 @@ async fn build_harness(pool: sqlx::PgPool) -> Harness { .put(api::proxy::anthropic_proxy) .delete(api::proxy::anthropic_proxy), ) + // Mirror the production body limit so integration tests exercise the + // same envelope as live traffic. + .layer(DefaultBodyLimit::max(32 * 1024 * 1024)) .route( "/api/v1/me/anthropic-key", get(api::me::get_anthropic_key_status) @@ -410,7 +415,8 @@ async fn proxy_returns_502_when_upstream_unreachable(pool: sqlx::PgPool) { repo_manager: repo_manager::RepoManager::new("/tmp"), extensions: tracevault_server::extensions::community_registry(), encryption_key: Some(encryption_key), - http_client: reqwest::Client::builder() + http_client: reqwest::Client::new(), + proxy_http_client: reqwest::Client::builder() // Tight timeout so we don't sit for 30s on the OS default. .connect_timeout(std::time::Duration::from_millis(500)) .build() @@ -527,6 +533,73 @@ async fn proxy_strips_forbidden_request_headers(pool: sqlx::PgPool) { } } +// --- Proxy: body size + path-traversal hardening -------------------------- + +/// A request body comfortably larger than Axum's 2 MB `Bytes` default must +/// reach upstream when the proxy router raises `DefaultBodyLimit`. This +/// catches regressions where the body cap is removed or shrunk back to the +/// default and silently breaks vision / long-context Anthropic requests. +#[sqlx::test(migrations = "./migrations")] +async fn proxy_accepts_large_body_within_raised_limit(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + Mock::given(method("POST")) + .and(wm_path("/v1/messages")) + .and(header("x-api-key", "sk-ant-test-upstream-key")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(1) + .mount(&h.upstream) + .await; + + // 4 MB body — 2× Axum's default cap, well within our 32 MB limit. + let payload = vec![b'a'; 4 * 1024 * 1024]; + + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages") + .header("x-api-key", &h.user_session_token) + .header("content-type", "application/octet-stream") + .body(Body::from(payload)) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "4 MB body must pass through with the raised body limit" + ); +} + +/// `..` segments in the proxy path must be rejected at the router entry +/// with an Anthropic-shaped error envelope. Belt-and-braces against future +/// reconfiguration of `anthropic_upstream_base` to a path-prefixed URL. +#[sqlx::test(migrations = "./migrations")] +async fn proxy_rejects_path_traversal_segments(pool: sqlx::PgPool) { + let h = build_harness(pool).await; + + // No mock mounted — the request must never reach upstream. + let req = Request::builder() + .method("POST") + .uri("/proxy/anthropic/v1/messages/..%2F..%2Fadmin") + .header("x-api-key", &h.user_session_token) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + + let resp = h.app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = read_body_to_value(resp.into_body()).await; + assert_eq!(body["type"], "error"); + assert_eq!(body["error"]["type"], "api_error"); + + // And confirm upstream really was never called. + let recv = h.upstream.received_requests().await.unwrap(); + assert!( + recv.is_empty(), + "upstream must not receive a `..`-bearing path" + ); +} + // --- /api/v1/me/anthropic-key HTTP lifecycle (deferred from T02) --------- #[sqlx::test(migrations = "./migrations")] @@ -544,6 +617,7 @@ async fn me_anthropic_key_lifecycle(pool: sqlx::PgPool) { extensions: tracevault_server::extensions::community_registry(), encryption_key: Some(encryption_key), http_client: reqwest::Client::new(), + proxy_http_client: reqwest::Client::new(), cors_origin: "*".to_string(), invite_expiry_minutes: 60, embedding_service: None, From 7a36be5dda725bd91bd3749700143326ad5c44be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 17:04:56 +0200 Subject: [PATCH 16/18] fix(proxy): raise body limit to 32MB and bound connect_timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related production-hardening fixes for the Anthropic proxy router: 1. `DefaultBodyLimit::max(32 * 1024 * 1024)` on the proxy router. Axum's default `Bytes` cap of 2 MB silently rejects legitimate Anthropic requests (vision inputs, long conversations, large `system` prompts) with a 413 that does NOT match the Anthropic error envelope, breaking the transparent-proxy contract. 32 MB matches the Anthropic request envelope while still bounding worst-case in-flight memory. 2. Dedicated `proxy_http_client` on AppState with `connect_timeout: 10s`. The shared `reqwest::Client::new()` used by pricing sync has no timeout configured; using it for the proxy meant a stalled TCP/TLS handshake on api.anthropic.com would park the proxy task indefinitely, holding a DB connection and a router slot — exhausting pools under partial Anthropic outage. Intentionally no overall `timeout()` because the proxy carries open-ended SSE streams. Self-review major #1 + major #2 on PR #208. --- crates/tracevault-server/src/lib.rs | 9 +++++ crates/tracevault-server/src/main.rs | 34 +++++++++++++++---- .../tests/proxy_real_anthropic.rs | 4 +++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/crates/tracevault-server/src/lib.rs b/crates/tracevault-server/src/lib.rs index 47f91a45..1b1d0bdc 100644 --- a/crates/tracevault-server/src/lib.rs +++ b/crates/tracevault-server/src/lib.rs @@ -42,7 +42,16 @@ pub struct AppState { pub repo_manager: repo_manager::RepoManager, pub extensions: extensions::ExtensionRegistry, pub encryption_key: Option, + /// General-purpose HTTP client (pricing sync, future short-lived + /// outbound calls). Built with reqwest defaults — no per-request + /// timeout, suitable for one-shot non-streaming calls. pub http_client: reqwest::Client, + /// HTTP client dedicated to the Anthropic proxy. Has a bounded + /// `connect_timeout` so a stalled TCP handshake on api.anthropic.com + /// cannot park the proxy task indefinitely; intentionally has no + /// overall `timeout()` because the proxy carries long-lived SSE + /// streams whose total duration depends on the model's output. + pub proxy_http_client: reqwest::Client, pub cors_origin: String, pub invite_expiry_minutes: u64, pub embedding_service: diff --git a/crates/tracevault-server/src/main.rs b/crates/tracevault-server/src/main.rs index b14377c3..d49b641d 100644 --- a/crates/tracevault-server/src/main.rs +++ b/crates/tracevault-server/src/main.rs @@ -1,4 +1,5 @@ use axum::{ + extract::DefaultBodyLimit, routing::{delete, get, post, put}, Router, }; @@ -60,6 +61,16 @@ async fn main() { let repo_manager = repo_manager::RepoManager::new(&cfg.repos_dir); let extensions = build_extensions(&cfg); let http_client = reqwest::Client::new(); + // Dedicated client for the Anthropic proxy. `connect_timeout` bounds + // how long a stalled TCP/TLS handshake can park the proxy task; we + // intentionally do *not* set an overall `timeout()` because the proxy + // carries SSE streams whose total duration is bounded by the model's + // output, not by the wall clock. + let proxy_http_client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .pool_idle_timeout(Some(std::time::Duration::from_secs(90))) + .build() + .expect("Failed to build proxy reqwest client"); // Auto-sync repos that are in 'ready' state on startup sync_repos_on_startup(&pool, &repo_manager, &extensions).await; @@ -579,13 +590,21 @@ async fn main() { // Anthropic LLM proxy — authenticates via x-api-key inside the handler // (not the standard Authorization-bearer extractor), so it is its own // router with no rate-limiting layer. Issue #207 / parent #181. - let proxy_routes = Router::new().route( - "/proxy/anthropic/{*path}", - get(api::proxy::anthropic_proxy) - .post(api::proxy::anthropic_proxy) - .put(api::proxy::anthropic_proxy) - .delete(api::proxy::anthropic_proxy), - ); + // + // Body limit: Axum's default `Bytes` cap is 2 MB, which silently rejects + // legitimate Anthropic requests (vision inputs, long conversations, + // large `system` prompts). Raise to 32 MB to match Anthropic's own + // request size envelope while still bounding worst-case server memory + // per in-flight request. + let proxy_routes = Router::new() + .route( + "/proxy/anthropic/{*path}", + get(api::proxy::anthropic_proxy) + .post(api::proxy::anthropic_proxy) + .put(api::proxy::anthropic_proxy) + .delete(api::proxy::anthropic_proxy), + ) + .layer(DefaultBodyLimit::max(32 * 1024 * 1024)); let app = Router::new() .merge(auth_routes) @@ -600,6 +619,7 @@ async fn main() { extensions, encryption_key: cfg.encryption_key.clone(), http_client: http_client.clone(), + proxy_http_client: proxy_http_client.clone(), cors_origin: cfg.cors_origin.clone(), invite_expiry_minutes: cfg.invite_expiry_minutes, anthropic_upstream_base: api::proxy::DEFAULT_ANTHROPIC_UPSTREAM_BASE.to_string(), diff --git a/crates/tracevault-server/tests/proxy_real_anthropic.rs b/crates/tracevault-server/tests/proxy_real_anthropic.rs index 6a0c45d0..2210873e 100644 --- a/crates/tracevault-server/tests/proxy_real_anthropic.rs +++ b/crates/tracevault-server/tests/proxy_real_anthropic.rs @@ -57,6 +57,10 @@ async fn build_real_state(pool: &sqlx::PgPool, upstream_key: &str) -> (AppState, extensions: tracevault_server::extensions::community_registry(), encryption_key: Some(encryption_key), http_client: reqwest::Client::new(), + proxy_http_client: reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap(), cors_origin: "*".to_string(), invite_expiry_minutes: 60, embedding_service: None, From 3449aa1cbf64b6fb5af632bef1d1e9d7381bceef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 17:05:01 +0200 Subject: [PATCH 17/18] chore: sync Cargo.lock with v0.16.0 base After rebasing onto main (v0.16.0), Cargo.lock fell behind the workspace's dependency graph. `cargo check` regenerated it; commit the regeneration so CI's `cargo check --locked` does not fail on a stale lockfile. --- Cargo.lock | 471 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 456 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65f94d67..a6fd04a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,12 +370,24 @@ dependencies = [ "syn", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -439,6 +451,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "built" version = "0.8.0" @@ -772,6 +793,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -843,8 +876,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -861,13 +904,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -924,6 +991,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -939,7 +1016,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -1023,6 +1100,26 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1057,6 +1154,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1189,6 +1307,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1403,6 +1531,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1509,6 +1638,17 @@ dependencies = [ "web-time", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.14" @@ -1521,7 +1661,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1539,6 +1679,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1742,6 +1888,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -1976,6 +2123,17 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -2062,6 +2220,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2547,6 +2714,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -2615,6 +2788,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2666,6 +2859,37 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac 0.12.1", + "http", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl" version = "0.10.80" @@ -2721,6 +2945,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ort" version = "2.0.0-rc.9" @@ -2745,6 +2978,30 @@ dependencies = [ "ureq", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -2935,6 +3192,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2954,6 +3217,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3164,7 +3436,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -3230,7 +3502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" dependencies = [ "either", - "itertools", + "itertools 0.14.0", "rayon", ] @@ -3273,6 +3545,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -3327,6 +3619,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3334,6 +3628,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3343,6 +3638,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", + "webpki-roots 1.0.7", ] [[package]] @@ -3388,6 +3684,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rgb" version = "0.8.53" @@ -3563,12 +3869,50 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3608,6 +3952,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3652,6 +4006,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3664,6 +4027,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3855,7 +4250,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.13.0", "log", "memchr", "once_cell", @@ -4199,6 +4594,37 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -4237,7 +4663,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "itertools", + "itertools 0.14.0", "log", "macro_rules_attribute", "monostate", @@ -4366,7 +4792,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap", + "indexmap 2.13.0", "pin-project-lite", "slab", "sync_wrapper", @@ -4472,8 +4898,22 @@ dependencies = [ name = "tracevault-enterprise" version = "0.1.0" dependencies = [ + "aes-gcm", "async-trait", + "base64 0.22.1", + "chrono", + "ed25519-dalek", + "glob-match", + "hex", + "openidconnect", + "rand 0.8.6", + "reqwest 0.13.2", + "serde", + "serde_json", + "sha2 0.11.0", "tracevault-core", + "tracing", + "uuid", ] [[package]] @@ -4783,6 +5223,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4969,7 +5410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -5008,7 +5449,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -5524,7 +5965,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn", "wasm-metadata", @@ -5555,7 +5996,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -5574,7 +6015,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", From 6757910b5ad304fd3b58bec35bf61ecdff05c6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kuras?= Date: Wed, 27 May 2026 17:16:40 +0200 Subject: [PATCH 18/18] fix(proxy): log path-traversal rejection as api_error, not authentication_error The log line said `error_type = "authentication_error"` while the response envelope used `ProxyErrorKind::ApiError`. Path traversal is not an authentication failure; align the log to match the envelope so error-type-based telemetry isn't split across two labels for the same failure. Addresses Aikido review comment on PR #208. --- crates/tracevault-server/src/api/proxy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracevault-server/src/api/proxy.rs b/crates/tracevault-server/src/api/proxy.rs index f380aff0..e3cb2d67 100644 --- a/crates/tracevault-server/src/api/proxy.rs +++ b/crates/tracevault-server/src/api/proxy.rs @@ -135,7 +135,7 @@ pub async fn anthropic_proxy( // regardless of how the base URL is configured later. if path.split(['/', '\\']).any(|seg| seg == "..") { tracing::warn!( - error_type = "authentication_error", + error_type = "api_error", reason = "path_traversal_segment", path = %path, "proxy rejected path containing '..'"