diff --git a/Cargo.lock b/Cargo.lock index 5f97a7c3..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", @@ -3341,8 +3636,9 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", + "webpki-roots 1.0.7", ] [[package]] @@ -3355,6 +3651,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -3376,15 +3673,27 @@ 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", ] +[[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" @@ -3560,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" @@ -3605,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" @@ -3649,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" @@ -3661,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" @@ -3852,7 +4250,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.13.0", "log", "memchr", "once_cell", @@ -4196,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" @@ -4234,7 +4663,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "itertools", + "itertools 0.14.0", "log", "macro_rules_attribute", "monostate", @@ -4363,7 +4792,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap", + "indexmap 2.13.0", "pin-project-lite", "slab", "sync_wrapper", @@ -4469,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]] @@ -4780,6 +5223,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4966,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", ] @@ -4984,6 +5428,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" @@ -4992,7 +5449,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -5508,7 +5965,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn", "wasm-metadata", @@ -5539,7 +5996,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -5558,7 +6015,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", 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); + } + } + }, } } 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 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/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/api/me.rs b/crates/tracevault-server/src/api/me.rs new file mode 100644 index 00000000..f488818b --- /dev/null +++ b/crates/tracevault-server/src/api/me.rs @@ -0,0 +1,118 @@ +//! 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(), + )); + } + // 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.). + // 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..f4efb2c8 100644 --- a/crates/tracevault-server/src/api/mod.rs +++ b/crates/tracevault-server/src/api/mod.rs @@ -12,9 +12,11 @@ pub mod dashboard; pub mod features; pub mod github; pub mod invites; +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..e3cb2d67 --- /dev/null +++ b/crates/tracevault-server/src/api/proxy.rs @@ -0,0 +1,567 @@ +//! 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, 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; + +/// 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`, +/// `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. +/// +/// 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, + OriginalUri(original_uri): OriginalUri, + method: Method, + headers: HeaderMap, + body: Bytes, +) -> 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 = "api_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, + }; + + 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, + _ => { + tracing::warn!( + error_type = "authentication_error", + reason = "missing_x_api_key", + path = %path, + "proxy auth failed" + ); + return Err(anthropic_error( + StatusCode::UNAUTHORIZED, + ProxyErrorKind::AuthenticationError, + "Missing x-api-key header", + )); + } + }; + + let token_hash = sha256_hex(tv_token); + let user_id = resolve_token(state, &token_hash).await?; + let upstream_key = load_anthropic_key(state, user_id).await?; + Ok((user_id, upstream_key)) +} + +/// 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}") + } else { + format!("{base}/{path}?{query}") + }; + + let mut upstream_req = state + .proxy_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); + + 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(); + 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`. +/// +/// `reqwest::HeaderMap` and `axum`/`http`'s `HeaderMap` share the same +/// underlying types from the `http` crate, so we can clone names and values +/// directly without round-tripping through `from_bytes` (which would +/// re-validate already-valid headers and silently drop them on the unlikely +/// failure path). +fn copy_response_headers(src: &reqwest::header::HeaderMap, dst: &mut HeaderMap) { + for (name, value) in src.iter() { + // Header names from `http::HeaderName` are already lowercase by + // construction, so a plain `starts_with` is sufficient and avoids + // an allocation per response header. + let name_str = name.as_str(); + let allow = FORWARDED_RESPONSE_HEADERS + .iter() + .any(|h| h.eq_ignore_ascii_case(name_str)) + || name_str.starts_with("anthropic-"); + if !allow { + continue; + } + dst.insert(name.clone(), value.clone()); + } +} + +#[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/lib.rs b/crates/tracevault-server/src/lib.rs index 7bc67b03..1b1d0bdc 100644 --- a/crates/tracevault-server/src/lib.rs +++ b/crates/tracevault-server/src/lib.rs @@ -42,9 +42,22 @@ 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: 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 ed4a4c15..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; @@ -218,6 +229,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 @@ -570,10 +587,30 @@ 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. + // + // 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) .merge(public_routes) .merge(authenticated_routes) + .merge(proxy_routes) .layer(TraceLayer::new_for_http()) .layer(cors) .with_state(AppState { @@ -582,8 +619,10 @@ 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(), embedding_service, }); 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/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..4a638b1e --- /dev/null +++ b/crates/tracevault-server/tests/proxy_integration.rs @@ -0,0 +1,753 @@ +//! 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}, + extract::DefaultBodyLimit, + 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(), + proxy_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), + ) + // 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) + .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::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() + .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" + ); + } +} + +// --- 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")] +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(), + proxy_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); +} 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..2210873e --- /dev/null +++ b/crates/tracevault-server/tests/proxy_real_anthropic.rs @@ -0,0 +1,175 @@ +//! 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 { + // 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) { + 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(), + 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, + // 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}" + ); +} 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()); +} 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} diff --git a/web/src/routes/me/proxy/+page.svelte b/web/src/routes/me/proxy/+page.svelte new file mode 100644 index 00000000..9b07c1e6 --- /dev/null +++ b/web/src/routes/me/proxy/+page.svelte @@ -0,0 +1,271 @@ + + + + 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} +