diff --git a/Cargo.lock b/Cargo.lock index cefa77c6..9cf4212f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -30,7 +30,7 @@ dependencies = [ "actix-service", "actix-utils", "base64 0.22.1", - "bitflags", + "bitflags 2.11.0", "brotli", "bytes", "bytestring", @@ -396,6 +396,12 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -511,7 +517,7 @@ dependencies = [ "comfy-table", "crossterm 0.28.1", "dialoguer", - "dirs", + "dirs 5.0.1", "dotenvy", "dunce", "flate2", @@ -524,6 +530,8 @@ dependencies = [ "oauth2", "open", "pathdiff", + "plotters", + "plotters-backend", "predicates", "ratatui", "regex", @@ -548,6 +556,18 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -656,6 +676,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -737,6 +763,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -753,6 +789,42 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation 0.9.4", + "core-graphics", + "foreign-types", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -833,7 +905,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "mio", "parking_lot", @@ -849,7 +921,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "document-features", "parking_lot", @@ -1014,7 +1086,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -1025,10 +1106,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.59.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1040,6 +1133,15 @@ dependencies = [ "syn", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1061,6 +1163,18 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1110,6 +1224,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1141,6 +1264,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + [[package]] name = "fnv" version = "1.0.7" @@ -1153,6 +1282,58 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "font-kit" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" +dependencies = [ + "bitflags 2.11.0", + "byteorder", + "core-foundation 0.9.4", + "core-graphics", + "core-text", + "dirs 6.0.0", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1162,6 +1343,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "futures" version = "0.3.32" @@ -1625,6 +1817,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "jpeg-decoder", + "num-traits", + "png", +] + [[package]] name = "impl-more" version = "0.1.9" @@ -1773,6 +1979,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.90" @@ -1789,6 +2001,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1801,13 +2019,23 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", ] @@ -2078,6 +2306,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + [[package]] name = "pbjson" version = "0.7.0" @@ -2149,6 +2396,62 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "font-kit", + "lazy_static", + "num-traits", + "pathfinder_geometry", + "plotters-backend", + "plotters-bitmap", + "plotters-svg", + "ttf-parser", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-bitmap" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405" +dependencies = [ + "image", + "plotters-backend", +] + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2415,7 +2718,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cassowary", "compact_str", "crossterm 0.28.1", @@ -2436,7 +2739,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -2450,6 +2753,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -2583,7 +2897,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2596,7 +2910,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -2662,6 +2976,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2707,8 +3030,8 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3319,7 +3642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", @@ -3407,6 +3730,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + [[package]] name = "typenum" version = "1.19.0" @@ -3539,6 +3868,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3672,7 +4011,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap 2.13.0", "semver", @@ -4037,6 +4376,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4095,7 +4443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap 2.13.0", "log", "serde", @@ -4131,6 +4479,17 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index a7da2549..15ca8027 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,8 @@ glob = "0.3" flate2 = "1.1.2" tempfile = "3" uuid = { version = "1.21.0", features = ["v4"] } +plotters = { version = "0.3.7", default-features = false, features = ["line_series", "svg_backend", "bitmap_backend", "bitmap_encoder", "ttf"] } +plotters-backend = "0.3.7" [profile.dist] inherits = "release" diff --git a/README.md b/README.md index cfa19e70..88de9252 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ Remove-Item -Recurse -Force (Join-Path $env:APPDATA "bt") -ErrorAction SilentlyC | ------------- | ------------------------------------------------------------------ | | `bt init` | Initialize `.bt/` config directory and link to a project | | `bt auth` | Authenticate with Braintrust | -| `bt switch` | Switch org and project context | +| `bt switch` | Switch instance, org, and project context | | `bt status` | Show current org and project context | | `bt datasets` | Manage datasets and dataset pipelines | | `bt eval` | Run eval files (Unix only) | @@ -312,53 +312,73 @@ Local version and pagination-key conversion helpers: ## `bt auth` -- Authenticate interactively (prompts for auth method, profile name defaults to org name): +- Authenticate interactively: - `bt auth login` - - First prompt chooses: `OAuth (browser)` (default) or `API key`. - - If your API key can access multiple orgs, `bt` uses a searchable picker (alphabetized) and lets you choose a specific org or no default org (cross-org mode). - - After login, `bt` updates the active profile/org context immediately. If `--project` is set, it also switches that project; otherwise it clears any stale default project for the new login. - - `bt` confirms the resolved API URL before saving. -- Login with OAuth (browser-based, stores refresh token in secure credential store): - - `bt auth login --oauth --profile work` - - You can pass `--no-browser` to print the URL without auto-opening. - - On remote/SSH hosts, paste the final callback URL from your local browser if localhost callback cannot be delivered. -- List profiles: - - `bt auth profiles` -- Log out (remove a saved profile): - - `bt auth logout` - - `bt auth logout --force` (skip confirmation) -- Show current auth source/profile: - - `bt auth status` -- Force-refresh OAuth access token for debugging: - - `bt auth refresh --profile work` + - First choose `OAuth (browser)` (default) or `API key`, then choose an organization and config scope. + - OAuth is stored once per Braintrust instance, identified by app URL, and can authenticate every organization available to that user in the instance. + - API-key logins remain organization-scoped; multiple keys for one organization remain distinct. + - Login writes `org`, `org_id`, `project`, `project_id`, `app_url`, and `api_url` to the selected config scope. A same-context login preserves the existing project when no project is requested. + - Use `--global` or `--local` to choose the config scope. Without either flag, an existing local config causes an interactive scope picker (default: local); non-interactive runs must pass a scope. `--local` never creates `.bt`. +- Login with OAuth: + - `bt auth login --oauth --org test-org` + - You can pass `--no-browser` to print the URL without opening it automatically. + - On remote/SSH hosts, paste the final callback URL if the localhost callback cannot be delivered. +- List saved auth logins: + - `bt auth logins` + - `bt auth logins --org test-org` dynamically lists only credentials that can use that organization. + - `bt auth logins --prefer-api-key` lists API-key logins only. +- Log out: + - `bt auth logout` — choose from all saved logins interactively + - `bt auth logout --app-url https://www.example.test --oauth` + - `bt auth logout --org test-org --api-key-hint sk-****abcde` + - `bt auth logout --force` — skip confirmation +- Force-refresh the OAuth login for the selected instance: + - `bt auth refresh --app-url https://www.example.test` Auth resolution order for commands is: -1. Explicit `--profile` -2. `--api-key` or `BRAINTRUST_API_KEY` (unless `--prefer-profile` is set) -3. `BRAINTRUST_PROFILE` -4. Org-based profile match (profile whose org matches `--org`/config org) -5. Single-profile auto-select (if only one profile exists) -6. Interactive profile picker (if multiple profiles exist and a TTY is available) +1. Explicit `--api-key sk-...` +2. `--prefer-api-key` / `BRAINTRUST_PREFER_API_KEY` (`BRAINTRUST_API_KEY`, then a matching stored API key, then matching OAuth) +3. OAuth for the selected Braintrust instance when it can access the selected organization +4. `BRAINTRUST_API_KEY` +5. A matching stored API key -On Linux, secure storage uses `secret-tool` (libsecret) with a running Secret Service daemon. On macOS, it uses the `security` keychain utility. If a secure store is unavailable, `bt` falls back to a plaintext secrets file with `0600` permissions. +OAuth credentials are matched by app URL. API-key credentials are matched by app URL, API URL, and organization. Explicit flags override environment variables, which override local config, global config, and finally the built-in Braintrust URLs. + +On Linux, secure storage uses `secret-tool` (libsecret) with a running Secret Service daemon. On macOS, it uses the `security` keychain utility. If secure storage is unavailable, `bt` falls back to a plaintext secrets file with `0600` permissions. + +## `bt init` + +`bt init` creates a project-local `.bt/config.json`. It walks upward to the first `.bt`, `.git`, home, or filesystem-root boundary. A git marker (directory or file) selects that repository root; reaching home/root or an existing `.bt` is an error. + +- `bt init --org test-org --project test-project` — initialize the containing repository +- `bt init --here` — create in the current directory without walking (including at home or `/`) +- `bt init --force` — overwrite an existing discovered `.bt/config.json`; it does not change discovery + +The saved context includes the Braintrust instance URLs, organization name and ID, and project name and ID. ## `bt switch` -Interactively switch org and project context: +`bt switch` changes context without selecting a credential. It chooses a Braintrust instance, discovers the organizations available through that instance's credentials, and then chooses a project. -- `bt switch` — interactive picker for org and project -- `bt switch myproject` — switch to a project by name -- `bt switch myorg/myproject` — switch to a specific org and project +- `bt switch` +- `bt switch test-project` +- `bt switch test-org/test-project` - `bt switch --global` — persist to global config (`~/.config/bt/config.json`) -- `bt switch --local` — persist to local config (`.bt/config.json`) +- `bt switch --local` — update an existing local config (`.bt/config.json`); it never creates one + +A sole instance, organization, or project is selected automatically. With an existing local config and no scope flag, interactive mode asks for global/local (default: local); non-interactive mode requires `--global` or `--local`. + +## Config context merging + +Global config is `~/.config/bt/config.json`; local config is the first discovered `.bt/config.json`. Both use the fields `org`, `org_id`, `project`, `project_id`, `app_url`, and `api_url`. Local values win. Organization IDs stay coupled to organization names, and organization/project context is inherited only within the same app URL. Legacy `profile` fields and obsolete empty cross-org contexts are ignored; unknown extra keys are preserved during updates. ## `bt status` Show current org and project context: -- `bt status` — display current org, project, and config source -- `bt status --verbose` — show detailed config resolution +- `bt status` — display current org, project, selected auth method, and config source +- `bt status --verbose` — show detailed config and auth resolution - `bt status -j` — JSON output ## `bt setup` and `bt docs` diff --git a/src/args.rs b/src/args.rs index c15c6478..cfe4e4ca 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,4 +1,3 @@ -use std::ffi::OsString; use std::path::{Path, PathBuf}; use clap::Args; @@ -11,7 +10,7 @@ pub enum ArgValueSource { EnvVariable, } -#[derive(Debug, Clone, Args)] +#[derive(Debug, Clone, Default, Args)] pub struct BaseArgs { /// Output as JSON #[arg(long, global = true)] @@ -39,17 +38,17 @@ pub struct BaseArgs { #[arg(long, env = "BRAINTRUST_NO_INPUT", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)] pub no_input: bool, - /// Use a saved login profile (or via BRAINTRUST_PROFILE) - #[arg(long, env = "BRAINTRUST_PROFILE", global = true)] - pub profile: Option, - - #[arg(skip = false)] - pub profile_explicit: bool, - /// Override active org (or via BRAINTRUST_ORG_NAME) - #[arg(short = 'o', long = "org", env = "BRAINTRUST_ORG_NAME", global = true)] + #[arg(short = 'o', long = "org", env = "BRAINTRUST_ORG_NAME", global = true, value_parser = parse_org_name)] pub org_name: Option, + #[arg(skip)] + pub org_name_source: Option, + + /// Stable org ID resolved from config or internal context selection. + #[arg(skip)] + pub org_id: Option, + /// Override active project #[arg( short = 'p', @@ -60,6 +59,9 @@ pub struct BaseArgs { )] pub project: Option, + #[arg(skip)] + pub project_source: Option, + /// Override stored API key (or via BRAINTRUST_API_KEY) #[arg(long, env = "BRAINTRUST_API_KEY", global = true, hide = true)] pub api_key: Option, @@ -67,9 +69,9 @@ pub struct BaseArgs { #[arg(skip)] pub api_key_source: Option, - /// Prefer profile credentials even if BRAINTRUST_API_KEY/--api-key is set. - #[arg(long, global = true)] - pub prefer_profile: bool, + /// Prefer API key credentials for the selected org when available. + #[arg(long = "prefer-api-key", env = "BRAINTRUST_PREFER_API_KEY", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)] + pub prefer_api_key: bool, /// Override API URL (or via BRAINTRUST_API_URL) #[arg( @@ -80,6 +82,9 @@ pub struct BaseArgs { )] pub api_url: Option, + #[arg(skip)] + pub api_url_source: Option, + /// Override app URL (or via BRAINTRUST_APP_URL) #[arg( long, @@ -89,6 +94,9 @@ pub struct BaseArgs { )] pub app_url: Option, + #[arg(skip)] + pub app_url_source: Option, + /// Path to a PEM-encoded CA bundle used for HTTPS requests. #[arg( long = "ca-cert", @@ -117,6 +125,23 @@ pub struct CLIArgs { pub base: BaseArgs, } +fn parse_org_name(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("organization cannot be empty".to_string()); + } + Ok(value.to_string()) +} + +pub(crate) fn custom_api_without_app_url(api_url: Option<&str>, app_url: Option<&str>) -> bool { + app_url.is_none_or(|url| url.trim().is_empty()) + && api_url.is_some_and(|url| { + !url.trim() + .trim_end_matches('/') + .eq_ignore_ascii_case(DEFAULT_API_URL.trim_end_matches('/')) + }) +} + impl BaseArgs { pub fn ca_cert(&self) -> Option<&Path> { self.ca_cert.as_deref() @@ -127,63 +152,28 @@ impl BaseArgs { } } -pub fn has_explicit_profile_arg(args: &[OsString]) -> bool { - let mut idx = 1usize; - while idx < args.len() { - let Some(arg) = args[idx].to_str() else { - idx += 1; - continue; - }; - - if arg == "--" { - break; - } - - if arg == "--profile" || arg.starts_with("--profile=") { - return true; - } - - idx += 1; - } - - false -} - #[cfg(test)] mod tests { - use super::has_explicit_profile_arg; - use std::ffi::OsString; - - #[test] - fn has_explicit_profile_arg_detects_split_flag() { - let args = vec![ - OsString::from("bt"), - OsString::from("status"), - OsString::from("--profile"), - OsString::from("work"), - ]; - assert!(has_explicit_profile_arg(&args)); - } + use super::{custom_api_without_app_url, parse_org_name, DEFAULT_API_URL}; #[test] - fn has_explicit_profile_arg_detects_equals_flag() { - let args = vec![ - OsString::from("bt"), - OsString::from("status"), - OsString::from("--profile=work"), - ]; - assert!(has_explicit_profile_arg(&args)); - } - - #[test] - fn has_explicit_profile_arg_ignores_passthrough_args() { - let args = vec![ - OsString::from("bt"), - OsString::from("eval"), - OsString::from("--"), - OsString::from("--profile"), - OsString::from("work"), - ]; - assert!(!has_explicit_profile_arg(&args)); + fn org_normalization() { + for (input, expected) in [ + ("cross-org", "cross-org"), + (" test-org ", "test-org"), + (" org_test_123 ", "org_test_123"), + ] { + assert_eq!(parse_org_name(input).unwrap(), expected); + } + assert!(parse_org_name(" ").is_err()); + assert!(custom_api_without_app_url( + Some("https://api.example.test"), + None + )); + assert!(!custom_api_without_app_url(Some(DEFAULT_API_URL), None)); + assert!(!custom_api_without_app_url( + Some("https://api.example.test"), + Some("https://app.example.test") + )); } } diff --git a/src/auth.rs b/src/auth.rs index 44db3d77..f422677b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::error::Error as StdError; use std::fs; -use std::io::{IsTerminal, Write}; +use std::io::Write; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::Command; @@ -25,6 +25,7 @@ use oauth2::{ }; use serde::{Deserialize, Serialize}; use serde_json::json; +use sha2::{Digest, Sha256}; use tokio::sync::oneshot; use crate::{ @@ -32,10 +33,12 @@ use crate::{ config, http::{build_http_client, build_http_client_from_builder, ApiClient}, projects::api, - switch, ui, + ui, + utils::shell_quote_arg, }; const KEYCHAIN_SERVICE: &str = "com.braintrust.bt.cli"; +const OAUTH_CLIENT_ID: &str = "bt_cli"; const OAUTH_SCOPE: &str = "mcp"; const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300); const OAUTH_REFRESH_SAFETY_WINDOW_SECONDS: u64 = 60; @@ -56,23 +59,23 @@ pub struct ResolvedAuth { pub api_url: Option, pub app_url: Option, pub org_name: Option, + pub org_id: Option, pub is_oauth: bool, + slot_key: Option, } #[derive(Debug, Clone)] pub struct ProfileInfo { - pub name: String, + pub auth_method: String, pub org_name: Option, pub user_name: Option, pub email: Option, pub api_key_hint: Option, } -#[derive(Debug, Clone)] -pub(crate) struct StoredProfileInfo { - pub name: String, - pub is_oauth: bool, - pub org_name: Option, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AvailableInstance { + pub app_url: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -84,9 +87,8 @@ pub struct AvailableOrg { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RecoverableAuthErrorKind { - OauthProfileSelection, - OauthClientId, OauthRefreshToken, + OauthOrgAccess, StoredCredential, } @@ -108,6 +110,14 @@ fn recoverable_auth_error(kind: RecoverableAuthErrorKind, message: String) -> an anyhow::Error::new(RecoverableAuthError { kind, message }) } +fn is_oauth_org_access_error(err: &anyhow::Error) -> bool { + err.chain().any(|source| { + source + .downcast_ref::() + .is_some_and(|err| err.kind == RecoverableAuthErrorKind::OauthOrgAccess) + }) +} + pub fn is_missing_credential_error(err: &anyhow::Error) -> bool { err.chain().any(|source| { source @@ -115,9 +125,7 @@ pub fn is_missing_credential_error(err: &anyhow::Error) -> bool { .is_some_and(|err| { matches!( err.kind, - RecoverableAuthErrorKind::OauthProfileSelection - | RecoverableAuthErrorKind::OauthClientId - | RecoverableAuthErrorKind::OauthRefreshToken + RecoverableAuthErrorKind::OauthRefreshToken | RecoverableAuthErrorKind::StoredCredential ) }) @@ -128,108 +136,17 @@ pub fn list_profiles() -> Result> { let store = load_auth_store()?; Ok(store .profiles - .iter() - .map(|(name, p)| ProfileInfo { - name: name.clone(), - org_name: p.org_name.clone(), - user_name: p.user_name.clone(), - email: p.email.clone(), - api_key_hint: p.api_key_hint.clone(), - }) + .values() + .map(profile_info_from_store_entry) .collect()) } -pub(crate) fn list_stored_profiles() -> Result> { +pub(crate) fn has_oauth_login_for_instance(base: &BaseArgs) -> Result { let store = load_auth_store()?; Ok(store .profiles - .iter() - .map(|(name, profile)| StoredProfileInfo { - name: name.clone(), - is_oauth: profile.auth_kind == AuthKind::Oauth, - org_name: profile.org_name.clone(), - }) - .collect()) -} - -pub fn resolve_org_to_profile(identifier: &str, profiles: &[ProfileInfo]) -> Result { - if profiles.is_empty() { - bail!("no auth profiles found. Run `bt auth login` to create one."); - } - - if let Some(p) = profiles.iter().find(|p| p.name == identifier) { - return Ok(p.name.clone()); - } - - let matches: Vec<&ProfileInfo> = profiles - .iter() - .filter(|p| p.org_name.as_deref() == Some(identifier)) - .collect(); - - match matches.len() { - 0 => { - let available: Vec = profiles - .iter() - .filter_map(|p| { - p.org_name - .as_ref() - .map(|org| format!(" {} (profile: {})", org, p.name)) - }) - .collect(); - bail!( - "no profile found for '{identifier}'.\nAvailable:\n{}", - available.join("\n") - ); - } - 1 => Ok(matches[0].name.clone()), - _ => { - if !ui::can_prompt() { - bail!( - "multiple profiles for org '{identifier}': {}. Use --profile to disambiguate.", - matches - .iter() - .map(|p| p.name.as_str()) - .collect::>() - .join(", ") - ); - } - let names: Vec<&str> = matches.iter().map(|p| p.name.as_str()).collect(); - let idx = crate::ui::fuzzy_select( - &format!("Multiple profiles for '{identifier}'. Select one"), - &names, - 0, - )?; - Ok(matches[idx].name.clone()) - } - } -} - -pub fn select_profile_interactive(current: Option<&str>) -> Result> { - let profiles = list_profiles()?; - if profiles.is_empty() { - bail!("no auth profiles found. Run `bt auth login` to create one."); - } - if profiles.len() == 1 { - return Ok(Some(profiles[0].name.clone())); - } - - let labels: Vec = profiles - .iter() - .map(|p| match &p.org_name { - Some(org) if org != &p.name => format!("{} (profile: {})", org, p.name), - _ => p.name.clone(), - }) - .collect(); - - let default = current - .and_then(|c| { - profiles - .iter() - .position(|p| p.name == c || p.org_name.as_deref() == Some(c)) - }) - .unwrap_or(0); - let idx = crate::ui::fuzzy_select("Select org", &labels, default)?; - Ok(Some(profiles[idx].name.clone())) + .values() + .any(|profile| profile.auth_kind == AuthKind::Oauth && profile_matches_urls(base, profile))) } pub async fn list_available_orgs(base: &BaseArgs) -> Result> { @@ -246,14 +163,19 @@ pub async fn list_available_orgs(base: &BaseArgs) -> Result> { .context("login state missing API key")?, }; - let mut orgs = fetch_login_orgs(&api_key, &app_url).await?; - orgs.sort_by(|a, b| { - a.name - .to_ascii_lowercase() - .cmp(&b.name.to_ascii_lowercase()) - .then_with(|| a.name.cmp(&b.name)) - }); + available_orgs(&api_key, &app_url).await +} + +pub(crate) async fn list_available_orgs_for_api_key( + api_key: &str, + app_url: &str, +) -> Result> { + available_orgs(api_key, app_url).await +} +async fn available_orgs(api_key: &str, app_url: &str) -> Result> { + let mut orgs = fetch_login_orgs(api_key, app_url).await?; + sort_login_orgs(&mut orgs); Ok(orgs .into_iter() .map(|org| AvailableOrg { @@ -264,29 +186,134 @@ pub async fn list_available_orgs(base: &BaseArgs) -> Result> { .collect()) } -pub(crate) async fn list_available_orgs_for_api_key( - api_key: &str, +pub(crate) fn available_instances(base: &BaseArgs) -> Result> { + let store = load_auth_store()?; + let constrain_app = matches!( + base.app_url_source, + Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable) + ); + let requested_app = constrain_app + .then_some(base.app_url.as_deref()) + .flatten() + .map(canonical_url); + let mut apps = store + .profiles + .values() + .map(profile_app_url) + .filter(|app| requested_app.is_none_or(|requested| canonical_url(app) == requested)) + .map(|app| canonical_url(app).to_string()) + .collect::>(); + + if base + .api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()) + { + let app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + if requested_app.is_none_or(|requested| canonical_url(app) == requested) { + apps.insert(canonical_url(app).to_string()); + } + } + + if apps.is_empty() && constrain_app { + bail!( + "no credentials found for app URL '{}'; run `bt auth login --app-url {}`", + base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + shell_quote_arg(base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL)) + ); + } + Ok(apps + .into_iter() + .map(|app_url| AvailableInstance { app_url }) + .collect()) +} + +pub(crate) async fn available_orgs_for_instance( + base: &BaseArgs, app_url: &str, ) -> Result> { - let mut orgs = fetch_login_orgs(api_key, app_url).await?; + let mut store = load_auth_store()?; + let explicit_api = matches!( + base.api_url_source, + Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable) + ) + .then(|| base.api_url.as_deref()) + .flatten(); + let mut orgs = BTreeMap::::new(); + let matching = store + .profiles + .iter() + .filter(|(_, profile)| canonical_url(profile_app_url(profile)) == canonical_url(app_url)) + .filter(|(_, profile)| { + profile.auth_kind == AuthKind::Oauth + || explicit_api + .is_none_or(|url| canonical_url(url) == canonical_url(profile_api_url(profile))) + }) + .map(|(slot, profile)| (slot.clone(), profile.clone())) + .collect::>(); + + for (slot, profile) in matching { + match profile.auth_kind { + AuthKind::Oauth => { + let mut oauth_base = base.clone(); + oauth_base.app_url = Some(app_url.to_string()); + if oauth_base.api_url_source.is_none() { + oauth_base.api_url = profile.api_url.clone(); + } + let token = load_oauth_access_token(&oauth_base, &mut store, &slot).await?; + for org in fetch_login_orgs(&token, app_url).await? { + orgs.insert( + org.id.clone(), + AvailableOrg { + id: org.id, + name: org.name, + api_url: org.api_url, + }, + ); + } + } + AuthKind::ApiKey => { + if let (Some(id), Some(name)) = (profile.org_id, profile.org_name) { + orgs.entry(id.clone()).or_insert(AvailableOrg { + id, + name, + api_url: profile.api_url, + }); + } + } + } + } + + if let Some(api_key) = base.api_key.as_deref().filter(|key| !key.trim().is_empty()) { + let base_app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + if canonical_url(base_app) == canonical_url(app_url) { + for org in fetch_login_orgs(api_key, app_url).await? { + orgs.insert( + org.id.clone(), + AvailableOrg { + id: org.id, + name: org.name, + api_url: org.api_url, + }, + ); + } + } + } + + let mut orgs = orgs.into_values().collect::>(); orgs.sort_by(|a, b| { a.name .to_ascii_lowercase() .cmp(&b.name.to_ascii_lowercase()) .then_with(|| a.name.cmp(&b.name)) }); - - Ok(orgs - .into_iter() - .map(|org| AvailableOrg { - id: org.id, - name: org.name, - api_url: org.api_url, - }) - .collect()) + if orgs.is_empty() { + bail!("no organizations are available for Braintrust instance '{app_url}'"); + } + Ok(orgs) } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] struct AuthStore { #[serde(default)] profiles: BTreeMap, @@ -298,7 +325,7 @@ struct SecretStore { secrets: BTreeMap, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] struct AuthProfile { #[serde(default)] auth_kind: AuthKind, @@ -307,9 +334,9 @@ struct AuthProfile { #[serde(default)] app_url: Option, #[serde(default)] - org_name: Option, + org_id: Option, #[serde(default)] - oauth_client_id: Option, + org_name: Option, #[serde(default)] oauth_access_expires_at: Option, #[serde(default)] @@ -317,10 +344,14 @@ struct AuthProfile { #[serde(default)] email: Option, #[serde(default)] + api_key_hash: Option, + #[serde(default)] api_key_hint: Option, + #[serde(default)] + legacy_secret_key: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] enum AuthKind { #[default] @@ -328,6 +359,13 @@ enum AuthKind { Oauth, } +fn auth_kind_label(kind: AuthKind) -> &'static str { + match kind { + AuthKind::ApiKey => "api_key", + AuthKind::Oauth => "oauth", + } +} + #[derive(Debug, Clone, Deserialize)] struct ApiKeyLoginResponse { org_info: Vec, @@ -375,10 +413,12 @@ struct OAuthErrorResponse { #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: - bt auth login - bt auth profiles - bt auth refresh - bt auth logout --profile work + bt auth login --global + bt auth login --oauth --org test-org --local + bt auth logins --org test-org --prefer-api-key + bt auth refresh --org test-org + bt auth logout + bt auth logout --org test-org --oauth ")] pub struct AuthArgs { #[command(subcommand)] @@ -389,20 +429,16 @@ pub struct AuthArgs { enum AuthCommand { /// Authenticate with Braintrust (OAuth or API key) Login(AuthLoginArgs), - /// Force-refresh OAuth access token for a profile + /// Force-refresh the OAuth access token for the selected instance Refresh, - /// List auth profiles and check connection status - Profiles(AuthProfilesArgs), - /// Log out by removing a saved profile + /// List saved auth logins and check connection status + Logins(AuthLoginsArgs), + /// Log out by removing a saved auth login Logout(AuthLogoutArgs), } #[derive(Debug, Clone, Args)] -struct AuthProfilesArgs { - /// Only show the profile with this name - #[arg(long, value_name = "NAME")] - profile: Option, -} +struct AuthLoginsArgs {} #[derive(Debug, Clone, Args)] struct AuthLoginArgs { @@ -410,20 +446,23 @@ struct AuthLoginArgs { #[arg(long)] oauth: bool, - /// OAuth client id (defaults to bt_cli_) - #[arg(long, value_name = "CLIENT_ID")] - client_id: Option, - /// Do not try to open a browser automatically #[arg(long)] no_browser: bool, + + #[command(flatten)] + scope: config::ScopeArgs, } #[derive(Debug, Clone, Args)] struct AuthLogoutArgs { - /// Profile name to log out of (interactive picker if omitted) - #[arg(long)] - profile: Option, + /// Only consider OAuth logins + #[arg(long, conflicts_with = "api_key_hint")] + oauth: bool, + + /// API key hint to log out of when multiple API keys exist for an org + #[arg(long = "api-key-hint", value_name = "HINT")] + api_key_hint: Option, /// Skip confirmation prompt #[arg(long, short = 'f')] @@ -437,10 +476,13 @@ struct PostLoginContextUpdate { pub async fn run(base: BaseArgs, args: AuthArgs) -> Result<()> { match args.command { - AuthCommand::Login(login_args) => run_login_set(&base, login_args).await, + AuthCommand::Login(login_args) => { + login_args.scope.preflight(ui::can_prompt())?; + run_login_set(&base, login_args).await + } AuthCommand::Refresh => run_login_refresh(&base).await, - AuthCommand::Profiles(profile_args) => run_profiles(&base, profile_args).await, - AuthCommand::Logout(logout_args) => run_login_logout(base, logout_args), + AuthCommand::Logins(logins_args) => run_logins(&base, logins_args).await, + AuthCommand::Logout(logout_args) => run_login_logout(base, logout_args).await, } } @@ -460,7 +502,6 @@ pub async fn login_read_only(base: &BaseArgs) -> Result { /// Build login context from stored auth without forcing a login validation request. /// Use for read-oriented flows where downstream API calls can surface auth errors. pub async fn fast_login(base: &BaseArgs) -> Result { - maybe_warn_api_key_override(base); let auth = resolve_auth(base).await?; let api_key = auth.api_key.clone().ok_or_else(|| { anyhow::anyhow!( @@ -480,7 +521,7 @@ pub async fn fast_login(base: &BaseArgs) -> Result { let login = LoginState::new(); login.set( api_key, - String::new(), + auth.org_id.clone().unwrap_or_default(), org_name, api_url.clone(), app_url.clone(), @@ -494,7 +535,6 @@ pub async fn fast_login(base: &BaseArgs) -> Result { } pub async fn login(base: &BaseArgs) -> Result { - maybe_warn_api_key_override(base); let auth = resolve_auth(base).await?; let api_key = auth.api_key.clone().ok_or_else(|| { anyhow::anyhow!( @@ -523,16 +563,22 @@ pub async fn login(base: &BaseArgs) -> Result { builder = builder.default_project(project); } let login = match builder.build().await { - Ok(client) => client.wait_for_login().await?, - Err(err) if auth.is_oauth => { - let org_name = auth - .org_name - .clone() - .ok_or_else(|| anyhow::anyhow!("oauth profile is missing org_name: {err}"))?; + Ok(client) => match client.wait_for_login().await { + Ok(login) => login, + Err(err) => { + let err: anyhow::Error = err.into(); + if !auth.is_oauth && is_unauthorized_auth_error(&err) { + return Err(err.context("API key is not valid")); + } + return Err(err); + } + }, + Err(_err) if auth.is_oauth => { + let org_name = auth.org_name.clone().unwrap_or_default(); let login = LoginState::new(); login.set( api_key.clone(), - String::new(), + auth.org_id.clone().unwrap_or_default(), org_name, auth.api_url .clone() @@ -543,9 +589,17 @@ pub async fn login(base: &BaseArgs) -> Result { ); login } - Err(err) => return Err(err.into()), + Err(err) => { + let err: anyhow::Error = err.into(); + if is_unauthorized_auth_error(&err) { + return Err(err.context("API key is not valid")); + } + return Err(err); + } }; + reconcile_resolved_auth_slot(&auth, &login)?; + let api_url = login .api_url() .or(auth.api_url.clone()) @@ -797,42 +851,43 @@ fn has_cached_project_id(base: &BaseArgs) -> bool { .is_some_and(|project_id| !project_id.trim().is_empty()) } -fn maybe_warn_api_key_override(base: &BaseArgs) { - if base.json || !std::io::stderr().is_terminal() { - return; - } - if resolve_api_key_override(base).is_none() { - return; - } - - let ignored_profile = base - .profile - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()); - - if let Some(profile_name) = ignored_profile { - eprintln!( - "Info: using --api-key/BRAINTRUST_API_KEY credentials; selected profile '{profile_name}' is ignored for this command. Use --prefer-profile or unset BRAINTRUST_API_KEY to use a profile with OAuth login.", - ); - } +fn is_unauthorized_auth_error(err: &anyhow::Error) -> bool { + err.chain().any(|source| { + if let Some(http_error) = source.downcast_ref::() { + return matches!(http_error.status.as_u16(), 401 | 403); + } + if let Some(sdk_error) = source.downcast_ref::() { + return matches!( + sdk_error, + braintrust_sdk_rust::BraintrustError::Api { + status: 401 | 403, + .. + } + ); + } + false + }) } -fn has_explicit_profile_selection(base: &BaseArgs) -> bool { - base.profile_explicit - && base - .profile - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) +fn resolve_cli_api_key_override(base: &BaseArgs) -> Option { + if matches!( + base.api_key_source, + Some(crate::args::ArgValueSource::EnvVariable) + ) { + return None; + } + let value = base.api_key.as_deref()?.trim(); + if value.is_empty() { + return None; + } + Some(value.to_string()) } -fn resolve_api_key_override(base: &BaseArgs) -> Option { - if (base.prefer_profile || has_explicit_profile_selection(base)) - && !matches!( - base.api_key_source, - Some(crate::args::ArgValueSource::CommandLine) - ) - { +fn resolve_env_api_key(base: &BaseArgs) -> Option { + if !matches!( + base.api_key_source, + Some(crate::args::ArgValueSource::EnvVariable) + ) { return None; } let value = base.api_key.as_deref()?.trim(); @@ -842,123 +897,532 @@ fn resolve_api_key_override(base: &BaseArgs) -> Option { Some(value.to_string()) } -fn config_auth_context(base: &BaseArgs) -> (Option, Option) { +fn config_auth_context(base: &BaseArgs) -> Option { let cfg = crate::config::load().unwrap_or_default(); config_auth_context_from_config(base, &cfg) } -fn config_auth_context_from_config( - base: &BaseArgs, - cfg: &crate::config::Config, -) -> (Option, Option) { - let profile = if crate::config::trimmed_option(base.profile.as_deref()).is_none() { - crate::config::trimmed_option(cfg.profile.as_deref()).map(str::to_string) - } else { - None - }; +fn configured_org_for_app_url(app_url: &str) -> Option { + let cfg = crate::config::load().ok()?; + let config_app = cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + crate::config::urls_equal(app_url, config_app) + .then_some(cfg.org) + .flatten() +} - let org = if crate::config::trimmed_option(base.org_name.as_deref()).is_none() { - crate::config::trimmed_option(cfg.org.as_deref()).map(str::to_string) +fn config_auth_context_from_config(base: &BaseArgs, cfg: &crate::config::Config) -> Option { + let base_app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let config_app = cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + if crate::config::org_option(base.org_name.as_deref()).is_none() + && crate::config::urls_equal(base_app, config_app) + { + crate::config::org_option(cfg.org.as_deref()).map(str::to_string) } else { None - }; + } +} - (profile, org) +fn effective_org_name<'a>(base: &'a BaseArgs, cfg_org: &'a Option) -> Option<&'a str> { + crate::config::org_option(base.org_name.as_deref()) + .or_else(|| crate::config::org_option(cfg_org.as_deref())) } -pub async fn resolve_auth(base: &BaseArgs) -> Result { - let mut store = load_auth_store()?; - let mut auth_base = base.clone(); - let (cfg_profile, cfg_org) = config_auth_context(base); - if let Some(profile) = cfg_profile { - auth_base.profile = Some(profile); +/// The auth source selected by the precedence ladder, before any live +/// credential is fetched. `resolve_auth` turns this into a `ResolvedAuth` +/// (fetching/refreshing tokens); `active_auth_info` turns it into a +/// `ProfileInfo` for display. Both share [`resolve_auth_source`] so the +/// precedence order documented in the README lives in exactly one place. +#[derive(Debug, Clone, PartialEq, Eq)] +enum AuthSource { + CliApiKey(String), + EnvApiKey(String), + Oauth(String), + ApiKey(String), + None, +} + +/// Pure auth-source precedence ladder (README "Auth resolution order"): +/// 1. explicit `--api-key` +/// 2. `--prefer-api-key`: `BRAINTRUST_API_KEY` → stored API key → OAuth fallback +/// 3. stored OAuth login for the selected org +/// 4. `BRAINTRUST_API_KEY` +/// 5. stored API key login for the selected org +/// +/// The slot selectors return `Ok(None)` when no candidate matches (the ladder +/// continues) and may return `Err` for an ambiguous selection that neither +/// caller can resolve without prompting (the ladder stops). +fn resolve_auth_source( + prefer_api_key: bool, + cli_api_key: Option, + env_api_key: impl Fn() -> Option, + select_oauth: impl Fn() -> Result>, + select_api_key: impl Fn() -> Result>, +) -> Result { + if let Some(api_key) = cli_api_key { + return Ok(AuthSource::CliApiKey(api_key)); + } + + if prefer_api_key { + if let Some(api_key) = env_api_key() { + return Ok(AuthSource::EnvApiKey(api_key)); + } + if let Some(slot) = select_api_key()? { + return Ok(AuthSource::ApiKey(slot)); + } + if let Some(slot) = select_oauth()? { + return Ok(AuthSource::Oauth(slot)); + } + return Ok(AuthSource::None); } - if let Some(profile_name) = - maybe_select_profile_for_auth(&auth_base, &store, &cfg_org, ui::can_prompt())? - { - auth_base.profile = Some(profile_name); + if let Some(slot) = select_oauth()? { + return Ok(AuthSource::Oauth(slot)); + } + if let Some(api_key) = env_api_key() { + return Ok(AuthSource::EnvApiKey(api_key)); } + if let Some(slot) = select_api_key()? { + return Ok(AuthSource::ApiKey(slot)); + } + Ok(AuthSource::None) +} - let mut auth = resolve_auth_from_store_with_secret_lookup( - &auth_base, - &store, - load_profile_secret, - &cfg_org, +pub async fn resolve_auth(base: &BaseArgs) -> Result { + let mut store = load_auth_store()?; + let cfg_org = config_auth_context(base); + let can_prompt = ui::can_prompt(); + + let effective_org = effective_org_name(base, &cfg_org); + + let source = resolve_auth_source( + base.prefer_api_key, + resolve_cli_api_key_override(base), + || resolve_env_api_key(base), + || select_profile_for_auth(base, &store, &cfg_org, AuthKind::Oauth, can_prompt), + || select_profile_for_auth(base, &store, &cfg_org, AuthKind::ApiKey, can_prompt), )?; - if !auth.is_oauth { - return Ok(auth); + + match source { + AuthSource::CliApiKey(api_key) | AuthSource::EnvApiKey(api_key) => { + resolve_ad_hoc_api_key_auth(base, &cfg_org, api_key).await + } + AuthSource::Oauth(slot) => { + match resolve_saved_auth_slot(base, &mut store, &cfg_org, &slot).await { + Ok(auth) => Ok(auth), + Err(err) if is_oauth_org_access_error(&err) && !base.prefer_api_key => { + if let Some(api_key) = resolve_env_api_key(base) { + return resolve_ad_hoc_api_key_auth(base, &cfg_org, api_key).await; + } + if let Some(api_key_slot) = select_profile_for_auth( + base, + &store, + &cfg_org, + AuthKind::ApiKey, + can_prompt, + )? { + return resolve_saved_auth_slot(base, &mut store, &cfg_org, &api_key_slot) + .await; + } + Err(err) + } + Err(err) => Err(err), + } + } + AuthSource::ApiKey(slot) => { + resolve_saved_auth_slot(base, &mut store, &cfg_org, &slot).await + } + AuthSource::None => { + if base.prefer_api_key { + bail!("--prefer-api-key requires an API key or OAuth login for the selected org"); + } + if !store.profiles.is_empty() + && !store + .profiles + .values() + .any(|profile| profile_matches_urls(base, profile)) + { + let app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let api = base.api_url.as_deref().unwrap_or(DEFAULT_API_URL); + bail!( + "no credentials match app URL '{}' and API URL '{}'; run `bt auth login` with these URLs", + app, + api + ); + } + if effective_org.is_none() { + if let Some(err) = missing_org_for_stored_logins_error(&store) { + return Err(err); + } + } + Ok(ResolvedAuth { + api_key: None, + api_url: base.api_url.clone(), + app_url: base.app_url.clone(), + org_name: effective_org.map(str::to_string), + org_id: base.org_id.clone(), + is_oauth: false, + slot_key: None, + }) + } } +} - let effective_org = auth_base.org_name.as_deref().or(cfg_org.as_deref()); - let profile_name = auth_base - .profile - .as_deref() - .filter(|value| !value.trim().is_empty()) - .or_else(|| effective_org.and_then(|org| resolve_profile_for_org(org, &store))) - .or_else(|| { - (store.profiles.len() == 1).then(|| store.profiles.keys().next().unwrap().as_str()) - }) +async fn resolve_ad_hoc_api_key_auth( + base: &BaseArgs, + cfg_org: &Option, + api_key: String, +) -> Result { + let requested_org = effective_org_name(base, cfg_org); + if requested_org == Some("") { + bail!("API keys require a concrete org; rerun with --org "); + } + + let mut resolved_org = requested_org.map(str::to_string); + let mut resolved_org_id = base.org_id.clone(); + let mut resolved_api_url = base.api_url.clone(); + if let Some(requested_org) = requested_org { + if crate::args::custom_api_without_app_url(base.api_url.as_deref(), base.app_url.as_deref()) + { + bail!("API key organization validation with a custom API URL requires --app-url or BRAINTRUST_APP_URL"); + } + let app_url = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let orgs = fetch_login_orgs(&api_key, app_url).await.map_err(|err| { + if is_unauthorized_auth_error(&err) { + anyhow::anyhow!("API key is not valid") + } else { + err.context("failed to validate API key organization membership") + } + })?; + let selected_org = find_login_org(&orgs, requested_org).ok_or_else(|| { + let available = login_org_names(&orgs); + anyhow::anyhow!( + "API key does not belong to requested org '{requested_org}'. Available orgs for this key: {available}" + ) + })?; + resolved_org = Some(selected_org.name.clone()); + resolved_org_id = Some(selected_org.id.clone()); + resolved_api_url = resolved_api_url.or_else(|| selected_org.api_url.clone()); + } + + Ok(ResolvedAuth { + api_key: Some(api_key), + api_url: resolved_api_url, + app_url: base.app_url.clone(), + org_name: resolved_org, + org_id: resolved_org_id, + is_oauth: false, + slot_key: None, + }) +} + +async fn resolve_saved_auth_slot( + base: &BaseArgs, + store: &mut AuthStore, + cfg_org: &Option, + slot: &str, +) -> Result { + let kind = store + .profiles + .get(slot) + .map(|profile| profile.auth_kind) .ok_or_else(|| { - recoverable_auth_error( - RecoverableAuthErrorKind::OauthProfileSelection, - "oauth profile requested but none selected".to_string(), + anyhow::anyhow!( + "saved auth login not found; run `bt auth logins` to see available logins" ) - })? - .to_string(); + })?; + match kind { + AuthKind::ApiKey => resolve_api_key_profile_auth(base, store, cfg_org, slot), + AuthKind::Oauth => resolve_oauth_profile_auth(base, store, cfg_org, slot).await, + } +} + +fn resolve_api_key_profile_auth( + base: &BaseArgs, + store: &mut AuthStore, + cfg_org: &Option, + profile_name: &str, +) -> Result { let profile = store .profiles - .get(profile_name.as_str()) - .ok_or_else(|| anyhow::anyhow!("profile '{profile_name}' not found"))?; - let client_id = profile.oauth_client_id.as_deref().ok_or_else(|| { + .get(profile_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("saved auth login not found; run `bt auth logins`"))?; + if let Some(requested_org) = effective_org_name(base, cfg_org) { + if !profile_matches_org_identifier(&profile, requested_org) { + bail!( + "stored API key for '{}' does not belong to requested org '{requested_org}'", + profile_org_label(&profile) + ); + } + } + + let api_key = load_profile_secret_with_legacy( + profile_name, + profile.legacy_secret_key.as_deref(), + )? + .ok_or_else(|| { recoverable_auth_error( - RecoverableAuthErrorKind::OauthClientId, + RecoverableAuthErrorKind::StoredCredential, format!( - "oauth profile '{profile_name}' is missing client_id; re-run `bt auth login --oauth --profile {profile_name}`" + "no keychain credential found for auth login '{}'; re-run `bt auth login --org --api-key `", + auth_slot_label(&profile) ), ) })?; - let cached_expires_at = profile.oauth_access_expires_at; - let api_url = auth - .api_url - .clone() - .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - if let Some(cached_access_token) = - load_valid_cached_oauth_access_token(&profile_name, cached_expires_at)? - { - auth.api_key = Some(cached_access_token); - return Ok(auth); + let resolved = ResolvedAuth { + api_key: Some(api_key.clone()), + api_url: base.api_url.clone().or_else(|| profile.api_url.clone()), + app_url: base.app_url.clone().or_else(|| profile.app_url.clone()), + org_name: effective_org_name(base, cfg_org) + .map(str::to_string) + .or_else(|| profile.org_name.clone()), + org_id: profile.org_id.clone().or_else(|| base.org_id.clone()), + is_oauth: false, + slot_key: Some(profile_name.to_string()), + }; + + maybe_rekey_api_key_profile_after_secret_load(store, profile_name, &api_key)?; + Ok(resolved) +} + +fn replace_with_canonical_auth_profile( + store: &mut AuthStore, + current_key: &str, + mut profile: AuthProfile, +) -> bool { + let canonical_key = canonical_profile_key(current_key, &profile); + if canonical_key != current_key && profile.legacy_secret_key.is_none() { + // Keep the old key as a lazy keychain fallback. Secrets are relocated + // only when they are next saved, avoiding platform-specific migration + // work while auth.json is being upgraded. + profile.legacy_secret_key = Some(current_key.to_string()); } - let refresh_token = load_profile_oauth_refresh_token(&profile_name)?.ok_or_else(|| { - recoverable_auth_error( - RecoverableAuthErrorKind::OauthRefreshToken, - format!( - "oauth refresh token missing for profile '{profile_name}'; re-run `bt auth login --oauth --profile {profile_name}`" - ), - ) - })?; - let refreshed = - refresh_oauth_access_token(&api_url, &refresh_token, client_id, &profile_name).await?; - save_profile_oauth_access_token(&profile_name, &refreshed.access_token)?; + let unchanged = canonical_key == current_key + && store + .profiles + .get(current_key) + .is_some_and(|existing| existing == &profile); + if unchanged { + return false; + } + + if canonical_key != current_key { + // Two entries collapse onto the same slot (same OAuth org+email, or the + // same API key+org). Keep the usable one and delete the loser's secrets + // so we never orphan a credential in the keychain, and never drop the + // entry that still holds a working refresh token. + if let Some(existing) = store.profiles.get(&canonical_key).cloned() { + if should_replace_canonical_profile(&canonical_key, &existing, &profile) { + delete_all_profile_secrets(&canonical_key, &existing); + } else { + delete_all_profile_secrets(current_key, &profile); + store.profiles.remove(current_key); + return true; + } + } + store.profiles.remove(current_key); + } + store.profiles.insert(canonical_key, profile); + true +} + +fn maybe_rekey_api_key_profile_after_secret_load( + store: &mut AuthStore, + profile_name: &str, + api_key: &str, +) -> Result<()> { + let Some(mut profile) = store.profiles.get(profile_name).cloned() else { + return Ok(()); + }; + if profile.auth_kind != AuthKind::ApiKey + || profile + .org_id + .as_deref() + .map(str::trim) + .is_none_or(str::is_empty) + { + return Ok(()); + } + + profile.api_key_hash = Some(api_key_hash(api_key)); + + // If the secret still lives only in the lazy legacy keychain slot, relocate + // it to the canonical slot now (mirroring the OAuth refresh path) so future + // resolves stop paying a permanent miss+fallback and deleting the old-named + // keychain item can't orphan the login. + if profile.legacy_secret_key.is_some() { + let canonical_key = canonical_profile_key(profile_name, &profile); + save_profile_secret(&canonical_key, api_key)?; + delete_legacy_profile_secrets(&profile); + profile.legacy_secret_key = None; + } + + if replace_with_canonical_auth_profile(store, profile_name, profile) { + save_auth_store(store)?; + } + Ok(()) +} + +fn reconcile_resolved_auth_slot(auth: &ResolvedAuth, login: &LoginState) -> Result<()> { + let Some(slot_key) = auth.slot_key.as_deref() else { + return Ok(()); + }; + let mut store = load_auth_store()?; + let Some(mut profile) = store.profiles.get(slot_key).cloned() else { + return Ok(()); + }; + + if profile.auth_kind == AuthKind::Oauth { + return Ok(()); + } + let login_org_id = login.org_id().unwrap_or_default(); + if login_org_id.trim().is_empty() { + return Ok(()); + } + + profile.org_id = Some(login_org_id); + profile.org_name = login + .org_name() + .filter(|org| !org.trim().is_empty()) + .or_else(|| auth.org_name.clone()); + let Some(api_key) = auth.api_key.as_deref() else { + return Ok(()); + }; + profile.api_key_hash = Some(api_key_hash(api_key)); + if profile.api_key_hint.is_none() { + profile.api_key_hint = Some(obscure_api_key(api_key)); + } + + if replace_with_canonical_auth_profile(&mut store, slot_key, profile) { + save_auth_store(&store)?; + } + Ok(()) +} + +async fn load_oauth_access_token( + base: &BaseArgs, + store: &mut AuthStore, + profile_name: &str, +) -> Result { + let profile = store + .profiles + .get(profile_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("saved OAuth login not found; run `bt auth logins`"))?; + if let Some(cached) = load_valid_cached_oauth_access_token( + profile_name, + &profile, + profile.oauth_access_expires_at, + )? { + return Ok(cached); + } + + let refresh_token = load_profile_oauth_refresh_token_for_profile(profile_name, &profile)? + .ok_or_else(|| { + recoverable_auth_error( + RecoverableAuthErrorKind::OauthRefreshToken, + format!( + "oauth refresh token missing for '{}'; re-run `{}`", + auth_slot_label(&profile), + oauth_reauth_command(&profile) + ), + ) + })?; + let api_url = base + .api_url + .as_deref() + .unwrap_or_else(|| profile_api_url(&profile)); + let refreshed = refresh_oauth_access_token(api_url, &refresh_token, &profile).await?; + save_profile_oauth_access_token(profile_name, &refreshed.access_token)?; + let mut refresh_rotated = false; if let Some(next_refresh_token) = refreshed.refresh_token.as_ref() { if next_refresh_token != &refresh_token { - save_profile_oauth_refresh_token(&profile_name, next_refresh_token)?; + save_profile_oauth_refresh_token(profile_name, next_refresh_token)?; + refresh_rotated = true; } } - if let Some(profile) = store.profiles.get_mut(&profile_name) { + if !refresh_rotated && profile.legacy_secret_key.is_some() { + save_profile_oauth_refresh_token(profile_name, &refresh_token)?; + } + if let Some(profile) = store.profiles.get_mut(profile_name) { profile.oauth_access_expires_at = determine_oauth_access_expiry_epoch(&refreshed); + if refresh_rotated || profile.legacy_secret_key.is_some() { + delete_legacy_profile_secrets(profile); + profile.legacy_secret_key = None; + } + } + save_auth_store(store)?; + Ok(refreshed.access_token) +} + +async fn resolve_oauth_profile_auth( + base: &BaseArgs, + store: &mut AuthStore, + cfg_org: &Option, + profile_name: &str, +) -> Result { + let profile = store + .profiles + .get(profile_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("saved OAuth login not found; run `bt auth logins`"))?; + let access_token = load_oauth_access_token(base, store, profile_name).await?; + let auth = ResolvedAuth { + api_key: Some(access_token), + api_url: Some( + base.api_url + .clone() + .or_else(|| profile.api_url.clone()) + .unwrap_or_else(|| DEFAULT_API_URL.to_string()), + ), + app_url: Some( + base.app_url + .clone() + .or_else(|| profile.app_url.clone()) + .unwrap_or_else(|| DEFAULT_APP_URL.to_string()), + ), + org_name: effective_org_name(base, cfg_org).map(str::to_string), + org_id: base.org_id.clone(), + is_oauth: true, + slot_key: Some(profile_name.to_string()), + }; + resolve_oauth_org_context(auth).await +} + +async fn resolve_oauth_org_context(mut auth: ResolvedAuth) -> Result { + let requested_org = auth.org_name.as_deref().ok_or_else(|| { + recoverable_auth_error( + RecoverableAuthErrorKind::OauthOrgAccess, + "an active organization is required; run `bt switch` or pass --org ".to_string(), + ) + })?; + let credential = auth + .api_key + .as_deref() + .context("OAuth access token is missing")?; + let app_url = auth.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let orgs = fetch_login_orgs(credential, app_url).await?; + let selected = find_login_org(&orgs, requested_org).ok_or_else(|| { + recoverable_auth_error( + RecoverableAuthErrorKind::OauthOrgAccess, + format!( + "OAuth login for '{}' cannot access organization '{requested_org}'", + canonical_url(app_url) + ), + ) + })?; + auth.org_name = Some(selected.name.clone()); + auth.org_id = Some(selected.id.clone()); + if auth.api_url.is_none() { + auth.api_url = selected.api_url.clone(); } - save_auth_store(&store)?; - auth.api_key = Some(refreshed.access_token); Ok(auth) } -pub async fn resolved_auth_env(base: &BaseArgs) -> Result> { - let auth = resolve_auth(base).await?; +fn auth_env(auth: ResolvedAuth) -> Vec<(String, String)> { let mut envs = Vec::new(); - if let Some(api_key) = auth.api_key { envs.push(("BRAINTRUST_API_KEY".to_string(), api_key)); } @@ -971,65 +1435,233 @@ pub async fn resolved_auth_env(base: &BaseArgs) -> Result> if let Some(org_name) = auth.org_name { envs.push(("BRAINTRUST_ORG_NAME".to_string(), org_name)); } - Ok(envs) + envs } pub async fn resolved_runner_env(base: &BaseArgs) -> Result> { - let mut envs = resolved_auth_env(base).await?; + let auth = resolve_auth(base).await?; + let resolved_org = auth.org_name.clone(); + let mut envs = auth_env(auth); let project = base .project .clone() - .or_else(|| crate::config::load().ok().and_then(|c| c.project)); + .or_else(|| crate::config::configured_project_for_context(base, resolved_org.as_deref())); if let Some(project) = project { envs.push(("BRAINTRUST_DEFAULT_PROJECT".to_string(), project)); } Ok(envs) } -fn resolve_profile_for_org<'a>(org: &str, store: &'a AuthStore) -> Option<&'a str> { - if store.profiles.contains_key(org) { - return Some( - store - .profiles - .keys() - .find(|k| k.as_str() == org) - .map(|k| k.as_str()) - .unwrap(), - ); +fn canonical_url(url: &str) -> &str { + url.trim().trim_end_matches('/') +} + +fn profile_app_url(profile: &AuthProfile) -> &str { + profile.app_url.as_deref().unwrap_or(DEFAULT_APP_URL) +} + +fn profile_api_url(profile: &AuthProfile) -> &str { + profile.api_url.as_deref().unwrap_or(DEFAULT_API_URL) +} + +fn profile_matches_urls(base: &BaseArgs, profile: &AuthProfile) -> bool { + let app_url = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + if canonical_url(app_url) != canonical_url(profile_app_url(profile)) { + return false; } + profile.auth_kind == AuthKind::Oauth + || canonical_url(base.api_url.as_deref().unwrap_or(DEFAULT_API_URL)) + == canonical_url(profile_api_url(profile)) +} - let matches: Vec<&str> = store - .profiles - .iter() - .filter(|(_, p)| p.org_name.as_deref() == Some(org)) - .map(|(name, _)| name.as_str()) - .collect(); +/// Match only URL filters the caller actually supplied. Listing and logout use +/// this variant so an absent filter means "all instances", while command auth +/// uses [`profile_matches_urls`] and therefore honors the built-in URL defaults. +fn profile_matches_url_filters(base: &BaseArgs, profile: &AuthProfile) -> bool { + let app_matches = base + .app_url + .as_deref() + .is_none_or(|url| canonical_url(url) == canonical_url(profile_app_url(profile))); + app_matches + && (profile.auth_kind == AuthKind::Oauth + || base + .api_url + .as_deref() + .is_none_or(|url| canonical_url(url) == canonical_url(profile_api_url(profile)))) +} + +fn profile_matches_org_identifier(profile: &AuthProfile, org: &str) -> bool { + profile.org_id.as_deref() == Some(org) || profile.org_name.as_deref() == Some(org) +} + +fn profile_org(profile: &AuthProfile) -> &str { + profile + .org_name + .as_deref() + .filter(|org| !org.trim().is_empty()) + .or(profile + .org_id + .as_deref() + .filter(|org| !org.trim().is_empty())) + .unwrap_or("") +} + +fn profile_org_label(profile: &AuthProfile) -> String { + profile_org(profile).to_string() +} + +fn oauth_reauth_command(profile: &AuthProfile) -> String { + format!( + "bt auth login --oauth --app-url {}", + shell_quote_arg(profile_app_url(profile)) + ) +} + +pub(crate) fn identity_label( + name: Option<&str>, + email: Option<&str>, + fallback: Option<&str>, +) -> Option { + match (name, email) { + (Some(name), Some(email)) => Some(format!("{name} ({email})")), + (Some(name), None) => Some(name.to_string()), + (None, Some(email)) => Some(email.to_string()), + (None, None) => fallback.map(str::to_string), + } +} + +fn profile_identity_label(profile: &AuthProfile) -> Option { + let fallback = (profile.auth_kind == AuthKind::ApiKey) + .then_some(profile.api_key_hint.as_deref()) + .flatten(); + identity_label( + profile.user_name.as_deref(), + profile.email.as_deref(), + fallback, + ) +} - match matches.len() { - 0 => None, - 1 => Some(matches[0]), - _ => None, +fn auth_slot_label(profile: &AuthProfile) -> String { + let mut parts = match profile.auth_kind { + AuthKind::Oauth => vec![profile_app_url(profile).to_string(), "oauth".to_string()], + AuthKind::ApiKey => vec![profile_org_label(profile), "api_key".to_string()], + }; + if let Some(identity) = profile_identity_label(profile) { + parts.push(identity); } + parts.join(" — ") } -fn profile_names_for_org<'a>(org: &str, store: &'a AuthStore) -> Vec<&'a str> { +fn auth_profile_names_by_kind<'a>( + base: &BaseArgs, + store: &'a AuthStore, + org: Option<&str>, + kind: AuthKind, +) -> Vec<&'a str> { store .profiles .iter() - .filter(|(_, profile)| profile.org_name.as_deref() == Some(org)) + .filter(|(_, profile)| profile.auth_kind == kind) + .filter(|(_, profile)| profile_matches_urls(base, profile)) + .filter(|(_, profile)| { + kind == AuthKind::Oauth + || org.is_some_and(|org| profile_matches_org_identifier(profile, org)) + }) .map(|(name, _)| name.as_str()) .collect() } +fn profile_info_from_store_entry(profile: &AuthProfile) -> ProfileInfo { + ProfileInfo { + auth_method: auth_kind_label(profile.auth_kind).to_string(), + org_name: profile.org_name.clone(), + user_name: profile.user_name.clone(), + email: profile.email.clone(), + api_key_hint: profile.api_key_hint.clone(), + } +} + +fn profile_info_for_candidate(store: &AuthStore, name: &str) -> Option { + store.profiles.get(name).map(profile_info_from_store_entry) +} + +fn ad_hoc_api_key_profile(org: Option<&str>, api_key: &str) -> ProfileInfo { + ProfileInfo { + auth_method: auth_kind_label(AuthKind::ApiKey).to_string(), + org_name: org.map(str::to_string), + user_name: None, + email: None, + api_key_hint: Some(obscure_api_key(api_key)), + } +} + +pub(crate) fn active_auth_info(base: &BaseArgs, org: Option<&str>) -> Result> { + let store = load_auth_store().unwrap_or_default(); + + let select = |kind| match auth_profile_names_by_kind(base, &store, org, kind).as_slice() { + [] => Ok(None), + [name] => Ok(Some((*name).to_string())), + _ => bail!("multiple {kind:?} logins"), + }; + + let source = match resolve_auth_source( + base.prefer_api_key, + resolve_cli_api_key_override(base), + || resolve_env_api_key(base), + || select(AuthKind::Oauth), + || select(AuthKind::ApiKey), + ) { + Ok(source) => source, + Err(_) => return Ok(None), + }; + + Ok(match source { + AuthSource::CliApiKey(api_key) | AuthSource::EnvApiKey(api_key) => { + Some(ad_hoc_api_key_profile(org, &api_key)) + } + AuthSource::Oauth(slot) | AuthSource::ApiKey(slot) => { + profile_info_for_candidate(&store, &slot) + } + AuthSource::None => None, + }) +} + +fn missing_org_for_stored_logins_error(store: &AuthStore) -> Option { + let candidates = store.profiles.iter().collect::>(); + if candidates.is_empty() { + return None; + } + + let labels = candidates + .iter() + .map(|(_, profile)| auth_slot_label(profile)) + .collect::>() + .join(", "); + let all_api_key = candidates + .iter() + .all(|(_, profile)| profile.auth_kind == AuthKind::ApiKey); + + Some(if candidates.len() == 1 { + anyhow::anyhow!( + "auth org selection required; pass --org to use saved auth login: {labels}" + ) + } else if all_api_key { + anyhow::anyhow!( + "multiple API key logins available: {labels}. Pass --org to disambiguate." + ) + } else { + anyhow::anyhow!( + "multiple auth logins available: {labels}. Pass --org to disambiguate." + ) + }) +} + fn profile_label_from_store(name: &str, store: &AuthStore) -> String { - match store + store .profiles .get(name) - .and_then(|profile| profile.org_name.as_deref()) - { - Some(org) if org != name => format!("{} (profile: {})", org, name), - _ => name.to_string(), - } + .map(auth_slot_label) + .unwrap_or_else(|| "saved auth login".to_string()) } fn select_profile_from_store( @@ -1038,6 +1670,7 @@ fn select_profile_from_store( current: Option<&str>, store: &AuthStore, ) -> Result { + let names: Vec<&str> = names.to_vec(); let labels: Vec = names .iter() .map(|name| profile_label_from_store(name, store)) @@ -1049,159 +1682,98 @@ fn select_profile_from_store( || store .profiles .get(*name) - .and_then(|profile| profile.org_name.as_deref()) - == Some(current) + .is_some_and(|profile| profile_matches_org_identifier(profile, current)) }) }) .unwrap_or(0); - let idx = ui::fuzzy_select(prompt, &labels, default)?; + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let idx = ui::fuzzy_select(prompt, &label_refs, default)?; Ok(names[idx].to_string()) } -fn maybe_select_profile_for_auth( +fn candidate_identities<'a>(names: &[&'a str], store: &'a AuthStore) -> Vec { + names + .iter() + .map(|name| { + store + .profiles + .get(*name) + .map(|profile| { + profile_identity_label(profile).unwrap_or_else(|| auth_slot_label(profile)) + }) + .unwrap_or_else(|| "saved auth login".to_string()) + }) + .collect() +} + +fn select_profile_for_auth( base: &BaseArgs, store: &AuthStore, cfg_org: &Option, + kind: AuthKind, can_prompt: bool, ) -> Result> { - if resolve_api_key_override(base).is_some() { - return Ok(None); - } - - let requested_profile = base - .profile - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - if requested_profile.is_some() { - return Ok(None); - } - - let effective_org = base.org_name.as_deref().or(cfg_org.as_deref()); - if let Some(org) = effective_org { - if resolve_profile_for_org(org, store).is_some() { - return Ok(None); - } + let org = effective_org_name(base, cfg_org); + let candidates = auth_profile_names_by_kind(base, store, org, kind); + let label = match kind { + AuthKind::Oauth => "OAuth login", + AuthKind::ApiKey => "API key", + }; + select_auth_profile_candidate( + label, + org, + &candidates, + store, + can_prompt && kind == AuthKind::ApiKey, + ) +} - let matching_profiles = profile_names_for_org(org, store); - if matching_profiles.is_empty() { - return Ok(None); +fn select_auth_profile_candidate( + kind_label: &str, + org: Option<&str>, + candidates: &[&str], + store: &AuthStore, + can_prompt: bool, +) -> Result> { + match candidates.len() { + 0 => Ok(None), + 1 => Ok(Some(candidates[0].to_string())), + _ if can_prompt => { + let prompt = org + .map(|org| format!("Multiple {kind_label} logins for '{org}'. Select one")) + .unwrap_or_else(|| format!("Select {kind_label} login")); + select_profile_from_store(&prompt, candidates, org, store).map(Some) } - - if !can_prompt { + _ => { + let identities = candidate_identities(candidates, store).join(", "); + if kind_label == "OAuth login" { + bail!( + "multiple Braintrust OAuth instances are available: {identities}. Run `bt switch` or pass --app-url ." + ); + } + if let Some(org) = org { + bail!( + "multiple {kind_label} logins for org '{org}': {identities}. Rerun interactively or remove one with `bt auth logout`." + ); + } bail!( - "multiple profiles for org '{org}': {}. Use --profile to disambiguate.", - matching_profiles.join(", ") + "multiple {kind_label} logins available: {identities}. Pass --app-url , rerun interactively, or remove one with `bt auth logout`." ); } - - return select_profile_from_store( - &format!("Multiple profiles for '{org}'. Select one"), - &matching_profiles, - Some(org), - store, - ) - .map(Some); - } - - if store.profiles.len() <= 1 { - return Ok(None); - } - - let names: Vec<&str> = store.profiles.keys().map(|name| name.as_str()).collect(); - if !can_prompt { - bail!( - "multiple auth profiles available: {}. Pass --profile , set BRAINTRUST_PROFILE, or configure an org.", - names.join(", ") - ); } - - select_profile_from_store("Select org", &names, None, store).map(Some) -} - -fn resolve_auth_from_store_with_secret_lookup( - base: &BaseArgs, - store: &AuthStore, - load_secret: F, - cfg_org: &Option, -) -> Result -where - F: Fn(&str) -> Result>, -{ - if let Some(api_key) = resolve_api_key_override(base) { - return Ok(ResolvedAuth { - api_key: Some(api_key), - api_url: base.api_url.clone(), - app_url: base.app_url.clone(), - org_name: base.org_name.clone().or_else(|| cfg_org.clone()), - is_oauth: false, - }); - } - - let requested_profile = base - .profile - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()); - - let effective_org = base.org_name.as_deref().or(cfg_org.as_deref()); - - let selected_profile_name = if let Some(profile) = requested_profile { - Some(profile) - } else if let Some(org) = effective_org { - resolve_profile_for_org(org, store) - } else if store.profiles.len() == 1 { - store.profiles.keys().next().map(|k| k.as_str()) - } else { - None - }; - - if let Some(profile_name) = selected_profile_name { - let profile = store.profiles.get(profile_name).ok_or_else(|| { - anyhow::anyhow!( - "profile '{profile_name}' not found; run `bt auth profiles` or `bt auth login --profile {profile_name}`" - ) - })?; - let is_oauth = profile.auth_kind == AuthKind::Oauth; - let api_key = if is_oauth { - None - } else { - Some(load_secret(profile_name)?.ok_or_else(|| { - recoverable_auth_error( - RecoverableAuthErrorKind::StoredCredential, - format!( - "no keychain credential found for profile '{profile_name}'; re-run `bt auth login --profile {profile_name}`" - ), - ) - })?) - }; - - return Ok(ResolvedAuth { - api_key, - api_url: base.api_url.clone().or_else(|| profile.api_url.clone()), - app_url: base.app_url.clone().or_else(|| profile.app_url.clone()), - org_name: base - .org_name - .clone() - .or_else(|| cfg_org.clone()) - .or_else(|| profile.org_name.clone()), - is_oauth, - }); - } - - Ok(ResolvedAuth { - api_key: None, - api_url: base.api_url.clone(), - app_url: base.app_url.clone(), - org_name: base.org_name.clone().or_else(|| cfg_org.clone()), - is_oauth: false, - }) } async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { if args.oauth { return run_login_oauth(base, args).await; } + if base + .org_name + .as_deref() + .is_some_and(|org| org.trim().is_empty()) + { + bail!("API-key login requires a non-empty organization"); + } let has_explicit_api_key = base.api_key.as_ref().is_some_and(|k| !k.trim().is_empty()); if !has_explicit_api_key && ui::can_prompt() { @@ -1225,7 +1797,6 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { .clone() .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); let login_orgs = fetch_login_orgs(&api_key, &login_app_url).await?; - let store = load_auth_store()?; let requested_org_resolution = resolve_requested_org_for_api_key_login( &login_orgs, base.org_name.as_deref(), @@ -1235,8 +1806,7 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { if requested_org_resolution == RequestedOrgResolution::SwitchToOauth { return run_login_oauth(base, args).await; } - let default_org_name = - default_login_org_name(&store, base.profile.as_deref(), base.org_name.as_deref()); + let configured_org = configured_org_for_app_url(&login_app_url); let selected_org = select_login_org( login_orgs.clone(), match requested_org_resolution { @@ -1246,51 +1816,49 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { } RequestedOrgResolution::SwitchToOauth => unreachable!("handled above"), }, - default_org_name.as_deref(), + configured_org.as_deref(), interactive, base.verbose, - true, explicitly_quiet(base), )?; - let selected_api_url = - resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let (profile_name, should_confirm_overwrite) = resolve_api_key_login_profile_name( - base.profile.as_deref(), - selected_org.as_ref().map(|org| org.name.as_str()), - &selected_api_url, - &store, + let selected_org = selected_org.ok_or_else(|| { + anyhow::anyhow!("API-key login requires an org; pass --org or rerun interactively") + })?; + let selected_api_url = resolve_profile_api_url( + base.api_url.clone(), + Some(&selected_org), + &login_orgs, + ui::can_prompt(), )?; - if should_confirm_overwrite { - confirm_profile_overwrite(&profile_name)?; - } commit_api_key_profile( - &profile_name, &api_key, selected_api_url.clone(), - base.app_url.clone(), - selected_org.as_ref().map(|org| org.name.clone()), + Some(login_app_url.clone()), + selected_org.id.clone(), + selected_org.name.clone(), )?; let context_update = persist_post_login_context( base, - &profile_name, &api_key, &selected_api_url, &login_app_url, - selected_org.as_ref(), + Some(&selected_org), + &args.scope, ) .await .context("login succeeded, but failed to update active context")?; - let human = format_login_success(&selected_org, &profile_name, &selected_api_url); + let human = format_login_success(Some(&selected_org), &selected_api_url); emit_result( base.json, serde_json::json!({ - "name": profile_name, "auth": "api_key", - "org": selected_org.as_ref().map(|org| org.name.clone()), + "org": selected_org.name, + "org_id": selected_org.id, "api_url": selected_api_url, - "app_url": base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + "app_url": login_app_url, + "api_key_hint": obscure_api_key(&api_key), "status": "ok", }), || { @@ -1315,23 +1883,12 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { .app_url .clone() .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); - let provisional_profile = base - .profile - .as_deref() - .map(str::trim) - .filter(|name| !name.is_empty()) - .unwrap_or("default"); - let client_id = args - .client_id - .clone() - .unwrap_or_else(|| default_oauth_client_id(provisional_profile)); - let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); let state = generate_random_token(32)?; let callback_server = bind_oauth_callback_server()?; let redirect_uri = callback_server.redirect_uri(); - let oauth_client = build_oauth_client(&api_url, &client_id, Some(&redirect_uri))?; + let oauth_client = build_oauth_client(&api_url, Some(&redirect_uri))?; let (authorize_url, _) = oauth_client .authorize_url(|| CsrfToken::new(state.clone())) .add_scope(Scope::new(OAUTH_SCOPE.to_string())) @@ -1366,68 +1923,50 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { bail!("oauth state mismatch; please try again"); } - let oauth_tokens = exchange_oauth_authorization_code( - &api_url, - &client_id, - &redirect_uri, - &auth_code, - pkce_verifier, - ) - .await?; + let oauth_tokens = + exchange_oauth_authorization_code(&api_url, &redirect_uri, &auth_code, pkce_verifier) + .await?; let login_orgs = fetch_login_orgs(&oauth_tokens.access_token, &app_url).await?; - let store = load_auth_store()?; - let default_org_name = - default_login_org_name(&store, base.profile.as_deref(), base.org_name.as_deref()); + let configured_org = configured_org_for_app_url(&app_url); let selected_org = select_login_org( login_orgs.clone(), base.org_name.as_deref(), - default_org_name.as_deref(), + configured_org.as_deref(), ui::can_prompt(), base.verbose, - true, explicitly_quiet(base), )?; - let selected_api_url = - resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let jwt_id = decode_jwt_identity(&oauth_tokens.access_token); - let (profile_name, should_confirm_overwrite) = resolve_oauth_login_profile_name( - base.profile.as_deref(), - selected_org.as_ref().map(|org| org.name.as_str()), - &selected_api_url, - &app_url, - &jwt_id, - &store, + let selected_org = selected_org.ok_or_else(|| { + anyhow::anyhow!( + "OAuth login requires an organization; pass --org or rerun interactively" + ) + })?; + let selected_api_url = resolve_profile_api_url( + base.api_url.clone(), + Some(&selected_org), + &login_orgs, + ui::can_prompt(), )?; - if should_confirm_overwrite { - confirm_profile_overwrite(&profile_name)?; - } - commit_oauth_profile( - &profile_name, - &oauth_tokens, - selected_api_url.clone(), - app_url.clone(), - client_id.clone(), - selected_org.as_ref().map(|org| org.name.clone()), - )?; + commit_oauth_profile(&oauth_tokens, api_url.clone(), app_url.clone())?; let context_update = persist_post_login_context( base, - &profile_name, &oauth_tokens.access_token, &selected_api_url, &app_url, - selected_org.as_ref(), + Some(&selected_org), + &args.scope, ) .await .context("login succeeded, but failed to update active context")?; - let human = format_login_success(&selected_org, &profile_name, &selected_api_url); + let human = format_login_success(Some(&selected_org), &selected_api_url); emit_result( base.json, serde_json::json!({ - "name": profile_name, "auth": "oauth", - "org": selected_org.as_ref().map(|org| org.name.clone()), + "org": selected_org.name, + "org_id": selected_org.id, "api_url": selected_api_url, "app_url": app_url, "status": "ok", @@ -1446,67 +1985,86 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { } pub(crate) fn commit_api_key_profile( - profile_name: &str, api_key: &str, api_url: String, app_url: Option, - org_name: Option, + org_id: String, + org_name: String, ) -> Result<()> { - save_profile_secret(profile_name, api_key)?; - let _ = delete_profile_oauth_refresh_token(profile_name); - let _ = delete_profile_oauth_access_token(profile_name); + let hash = api_key_hash(api_key); + let slot_key = api_key_slot_key(&hash, &org_id); + save_profile_secret(&slot_key, api_key)?; let mut store = load_auth_store()?; + if let Some(old_profile) = store.profiles.get(&slot_key) { + delete_legacy_profile_secrets(old_profile); + } store.profiles.insert( - profile_name.to_string(), + slot_key, AuthProfile { auth_kind: AuthKind::ApiKey, api_url: Some(api_url), app_url, - org_name, - oauth_client_id: None, + org_id: Some(org_id), + org_name: Some(org_name), oauth_access_expires_at: None, user_name: None, email: None, + api_key_hash: Some(hash), api_key_hint: Some(obscure_api_key(api_key)), + legacy_secret_key: None, }, ); save_auth_store(&store) } fn commit_oauth_profile( - profile_name: &str, tokens: &OAuthTokenResponse, api_url: String, app_url: String, - client_id: String, - org_name: Option, ) -> Result<()> { let refresh_token = tokens.refresh_token.as_ref().ok_or_else(|| { anyhow::anyhow!( - "oauth token response did not include a refresh_token; cannot create persistent oauth profile" + "oauth token response did not include a refresh_token; cannot create persistent oauth login" ) })?; - save_profile_oauth_refresh_token(profile_name, refresh_token)?; - save_profile_oauth_access_token(profile_name, &tokens.access_token)?; - let _ = delete_profile_secret(profile_name); let oauth_access_expires_at = determine_oauth_access_expiry_epoch(tokens); let jwt_id = decode_jwt_identity(&tokens.access_token); + let _email = jwt_id + .email + .clone() + .filter(|email| !email.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "oauth token did not include an email; cannot create persistent oauth login" + ) + })?; + let app_url = canonical_url(&app_url).to_string(); + let slot_key = oauth_slot_key(&app_url); let mut store = load_auth_store()?; + if let Some(old_profile) = store.profiles.get(&slot_key) { + delete_all_profile_secrets(&slot_key, old_profile); + } + save_profile_oauth_refresh_token(&slot_key, refresh_token)?; + save_profile_oauth_access_token(&slot_key, &tokens.access_token)?; + let _ = delete_profile_secret(&slot_key); + store.profiles.insert( - profile_name.to_string(), + slot_key, AuthProfile { auth_kind: AuthKind::Oauth, api_url: Some(api_url), app_url: Some(app_url), - org_name, - oauth_client_id: Some(client_id), + org_id: None, + org_name: None, oauth_access_expires_at, user_name: jwt_id.name, email: jwt_id.email, + api_key_hash: None, api_key_hint: None, + legacy_secret_key: None, }, ); save_auth_store(&store) @@ -1514,35 +2072,47 @@ fn commit_oauth_profile( async fn run_login_refresh(base: &BaseArgs) -> Result<()> { let mut store = load_auth_store()?; - let (profile_name, source) = resolve_selected_profile_name_for_debug(base, &store)?; + let cfg_org = config_auth_context(base); + let profile_name = select_profile_for_auth( + base, + &store, + &cfg_org, + AuthKind::Oauth, + ui::can_prompt(), + )? + .ok_or_else(|| { + anyhow::anyhow!( + "no OAuth login selected; pass --app-url or run `bt auth logins` to see available logins" + ) + })?; let profile = store .profiles .get(profile_name.as_str()) - .ok_or_else(|| profile_not_found_err(&profile_name, &store))?; - if profile.auth_kind != AuthKind::Oauth { - bail!( - "profile '{profile_name}' uses api key auth; `bt auth refresh` only applies to oauth profiles" - ); - } + .cloned() + .ok_or_else(|| { + anyhow::anyhow!("OAuth login not found; run `bt auth logins` to see available logins") + })?; - let api_url = profile + let api_url = base .api_url .clone() + .or_else(|| profile.api_url.clone()) .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - let client_id = profile.oauth_client_id.clone().ok_or_else(|| { - anyhow::anyhow!( - "oauth profile '{profile_name}' is missing client_id; re-run `bt auth login --oauth --profile {profile_name}`" - ) - })?; let previous_expires_at = profile.oauth_access_expires_at; - let refresh_token = load_profile_oauth_refresh_token(profile_name.as_str())?.ok_or_else(|| { - anyhow::anyhow!( - "oauth refresh token missing for profile '{profile_name}'; re-run `bt auth login --oauth --profile {profile_name}`" - ) - })?; + let refresh_token = + load_profile_oauth_refresh_token_for_profile(profile_name.as_str(), &profile)?.ok_or_else( + || { + anyhow::anyhow!( + "OAuth refresh token missing for '{}'; re-run `{}`", + auth_slot_label(&profile), + oauth_reauth_command(&profile) + ) + }, + )?; eprintln!( - "Refreshing OAuth token for profile '{profile_name}' (source: {source}, api_url: {api_url})" + "Refreshing OAuth token for {} (api_url: {api_url})", + auth_slot_label(&profile) ); if let Some(expires_at) = previous_expires_at { let now = current_unix_timestamp(); @@ -1554,9 +2124,7 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { eprintln!("Cached access token expiry before refresh: unknown"); } - let refreshed = - refresh_oauth_access_token(&api_url, &refresh_token, &client_id, profile_name.as_str()) - .await?; + let refreshed = refresh_oauth_access_token(&api_url, &refresh_token, &profile).await?; save_profile_oauth_access_token(profile_name.as_str(), &refreshed.access_token)?; let mut refresh_rotated = false; if let Some(next_refresh_token) = refreshed.refresh_token.as_ref() { @@ -1565,13 +2133,19 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { refresh_rotated = true; } } + if !refresh_rotated && profile.legacy_secret_key.is_some() { + save_profile_oauth_refresh_token(profile_name.as_str(), &refresh_token)?; + } let new_expires_at = determine_oauth_access_expiry_epoch(&refreshed); if let Some(profile) = store.profiles.get_mut(profile_name.as_str()) { profile.oauth_access_expires_at = new_expires_at; + if refresh_rotated || profile.legacy_secret_key.is_some() { + delete_legacy_profile_secrets(profile); + profile.legacy_secret_key = None; + } } save_auth_store(&store)?; - if let Some(expires_at) = new_expires_at { let now = current_unix_timestamp(); let remaining = expires_at.saturating_sub(now); @@ -1588,8 +2162,9 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { emit_result( base.json, serde_json::json!({ - "name": profile_name, "auth": "oauth", + "app_url": profile.app_url, + "user_email": profile.email, "access_expires_at": new_expires_at, "refresh_token_rotated": refresh_rotated, "status": "ok", @@ -1598,241 +2173,10 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { ) } -fn resolve_selected_profile_name_for_debug( - base: &BaseArgs, - store: &AuthStore, -) -> Result<(String, &'static str)> { - if let Some(profile_name) = base.profile.as_deref() { - let profile_name = profile_name.trim(); - if !profile_name.is_empty() { - return Ok((profile_name.to_string(), "--profile/BRAINTRUST_PROFILE")); - } - } - - if let Some(org) = base.org_name.as_deref() { - if let Some(profile_name) = resolve_profile_for_org(org, store) { - return Ok((profile_name.to_string(), "org-based resolution")); - } - } - - if store.profiles.len() == 1 { - let name = store.profiles.keys().next().unwrap().clone(); - return Ok((name, "only profile")); - } - - if store.profiles.len() > 1 && ui::can_prompt() { - if let Some(name) = select_profile_interactive(None)? { - return Ok((name, "interactive selection")); - } - } - - bail!("no profile selected; pass --profile , set BRAINTRUST_PROFILE, or configure an org") -} - -fn resolve_profile_name( - explicit_profile: Option<&str>, - suggested_org_name: Option<&str>, -) -> Result { - if let Some(profile) = explicit_profile { - let profile = profile.trim(); - if profile.is_empty() { - bail!("profile name cannot be empty"); - } - return Ok(profile.to_string()); - } - - Ok(suggested_org_name - .map(str::trim) - .filter(|name| !name.is_empty()) - .unwrap_or("profile") - .to_string()) -} - -fn default_login_org_name( - store: &AuthStore, - profile_name: Option<&str>, - requested_org_name: Option<&str>, -) -> Option { - if requested_org_name - .map(str::trim) - .is_some_and(|name| !name.is_empty()) - { - return None; - } - - let profile_name = profile_name - .map(str::trim) - .filter(|name| !name.is_empty())?; - let stored_org_name = store - .profiles - .get(profile_name) - .and_then(|profile| profile.org_name.as_deref()) - .map(str::trim) - .filter(|org_name| !org_name.is_empty()); - - Some(stored_org_name.unwrap_or(profile_name).to_string()) -} - -fn default_profile_name(suggested_org_name: Option<&str>) -> String { - suggested_org_name - .map(str::trim) - .filter(|name| !name.is_empty()) - .unwrap_or("profile") - .to_string() -} - -fn next_available_profile_name(base_name: &str, store: &AuthStore) -> String { - if !store.profiles.contains_key(base_name) { - return base_name.to_string(); - } - - (2u32..) - .map(|idx| format!("{base_name}-{idx}")) - .find(|candidate| !store.profiles.contains_key(candidate)) - .expect("profile name sequence is infinite") -} - -fn resolve_api_key_login_profile_name( - explicit_profile: Option<&str>, - suggested_org_name: Option<&str>, - selected_api_url: &str, - store: &AuthStore, -) -> Result<(String, bool)> { - if let Some(profile_name) = explicit_profile { - let profile_name = resolve_profile_name(Some(profile_name), suggested_org_name)?; - let should_confirm_overwrite = store.profiles.get(&profile_name).is_some_and(|profile| { - !profile_matches_api_key_login_target(profile, selected_api_url, suggested_org_name) - }); - return Ok((profile_name.clone(), should_confirm_overwrite)); - } - - let default_name = default_profile_name(suggested_org_name); - let has_matching_api_key_profile = store.profiles.values().any(|profile| { - profile.auth_kind == AuthKind::ApiKey - && profile.api_url.as_deref() == Some(selected_api_url) - && profile.org_name.as_deref() == suggested_org_name - }); - - if has_matching_api_key_profile { - return Ok((next_available_profile_name(&default_name, store), false)); - } - - Ok(( - default_name.clone(), - store.profiles.contains_key(&default_name), - )) -} - -fn resolve_oauth_login_profile_name( - explicit_profile: Option<&str>, - suggested_org_name: Option<&str>, - selected_api_url: &str, - app_url: &str, - jwt_id: &JwtIdentity, - store: &AuthStore, -) -> Result<(String, bool)> { - if let Some(profile_name) = explicit_profile { - let profile_name = resolve_profile_name(Some(profile_name), suggested_org_name)?; - let should_confirm_overwrite = store.profiles.get(&profile_name).is_some_and(|profile| { - !profile_matches_oauth_login_target( - profile, - selected_api_url, - app_url, - suggested_org_name, - jwt_id, - ) - }); - return Ok((profile_name.clone(), should_confirm_overwrite)); - } - - let matched_profile = store - .profiles - .iter() - .filter(|(_, profile)| { - profile_matches_oauth_login_target( - profile, - selected_api_url, - app_url, - suggested_org_name, - jwt_id, - ) - }) - .max_by(|(left_name, left), (right_name, right)| { - left.oauth_access_expires_at - .unwrap_or_default() - .cmp(&right.oauth_access_expires_at.unwrap_or_default()) - .then_with(|| left_name.cmp(right_name)) - }) - .map(|(name, _)| name.clone()); - - if let Some(profile_name) = matched_profile { - return Ok((profile_name, false)); - } - - let default_name = default_profile_name(suggested_org_name); - Ok(( - default_name.clone(), - store.profiles.contains_key(&default_name), - )) -} - -fn profile_matches_api_key_login_target( - profile: &AuthProfile, - selected_api_url: &str, - suggested_org_name: Option<&str>, -) -> bool { - profile.auth_kind == AuthKind::ApiKey - && profile.api_url.as_deref() == Some(selected_api_url) - && profile.org_name.as_deref() == suggested_org_name -} - -fn profile_matches_oauth_login_target( - profile: &AuthProfile, - selected_api_url: &str, - app_url: &str, - suggested_org_name: Option<&str>, - jwt_id: &JwtIdentity, -) -> bool { - profile.auth_kind == AuthKind::Oauth - && profile.api_url.as_deref() == Some(selected_api_url) - && profile.app_url.as_deref() == Some(app_url) - && profile.org_name.as_deref() == suggested_org_name - && profile.user_name == jwt_id.name - && profile.email == jwt_id.email -} - -fn confirm_profile_overwrite(profile_name: &str) -> Result<()> { - let store = load_auth_store()?; - if !store.profiles.contains_key(profile_name) { - return Ok(()); - } - let Some(term) = ui::prompt_term() else { - return Ok(()); - }; - let confirmed = Confirm::new() - .with_prompt(format!( - "Profile '{profile_name}' already exists. Overwrite?" - )) - .default(false) - .interact_on(&term)?; - if !confirmed { - bail!("login cancelled"); - } - Ok(()) -} - -fn format_login_success( - selected_org: &Option, - profile_name: &str, - api_url: &str, -) -> String { - match selected_org.as_ref() { - Some(org) => format!( - "Logged in as {} (profile: {profile_name}, api: {api_url})", - org.name - ), - None => format!("Logged in (cross-org, profile: {profile_name}, api: {api_url})"), - } +fn format_login_success(selected_org: Option<&LoginOrgInfo>, api_url: &str) -> String { + selected_org + .map(|org| format!("Logged in as {} (api: {api_url})", org.name)) + .unwrap_or_else(|| format!("Logged in (api: {api_url})")) } fn build_login_context_for_selected_org( @@ -1863,7 +2207,7 @@ fn format_post_login_context( match (selected_org, project) { (Some(org), Some(project)) => format!("{}/{}", org.name, project.name), (Some(org), None) => org.name.clone(), - (None, _) => "cross-org mode".to_string(), + (None, _) => "Braintrust".to_string(), } } @@ -1878,65 +2222,62 @@ async fn resolve_post_login_project( return Ok(None); }; - let selected_org = selected_org.ok_or_else(|| { - anyhow::anyhow!( - "cannot set a default project in cross-org mode; rerun `bt auth login --org --project `" - ) - })?; + let selected_org = selected_org + .ok_or_else(|| anyhow::anyhow!("an organization is required to select a project"))?; let ctx = build_login_context_for_selected_org(credential, api_url, app_url, Some(selected_org)); let client = ApiClient::new(&ctx)?; - switch::validate_or_create_project(&client, project_name) + ui::select_or_create_project(&client, Some(project_name), None, None) .await .map(Some) } async fn persist_post_login_context( base: &BaseArgs, - profile_name: &str, credential: &str, api_url: &str, app_url: &str, selected_org: Option<&LoginOrgInfo>, + scope: &config::ScopeArgs, ) -> Result { + // Scope is prompted last, after org (during login) and project. let project = resolve_post_login_project(base, credential, api_url, app_url, selected_org).await?; - let path = if ui::can_prompt() && config::local_path().is_some() { - switch::select_scope()?.0 + let (path, _) = scope.resolve(ui::can_prompt(), "Where to use this login")?; + let mut cfg = config::load_file(&path); + let selected_org = selected_org + .ok_or_else(|| anyhow::anyhow!("an organization is required to update config"))?; + let preserve_project = project.is_none() + && config::org_option(cfg.org.as_deref()) == Some(selected_org.name.as_str()) + && cfg.org_id.as_deref() == Some(selected_org.id.as_str()) + && cfg + .app_url + .as_deref() + .is_some_and(|url| config::urls_equal(url, app_url)); + let selected_project = if preserve_project { + cfg.project.clone().zip(cfg.project_id.clone()) } else { - config::global_path()? + project + .as_ref() + .map(|project| (project.name.clone(), project.id.clone())) }; - - let mut cfg = config::load_file(&path); - switch::apply_switch_config( - &mut cfg, - Some(profile_name), - selected_org.map(|org| org.name.as_str()), - project.as_ref(), + cfg.set_context( + (selected_org.name.as_str(), selected_org.id.as_str()), + selected_project + .as_ref() + .map(|(name, id)| (name.as_str(), id.as_str())), + app_url, + api_url, ); config::save_file(&path, &cfg) - .context(format!("Could not save config to {}", path.display()))?; + .with_context(|| format!("Could not save config to {}", path.display()))?; Ok(PostLoginContextUpdate { - display: format_post_login_context(selected_org, project.as_ref()), + display: format_post_login_context(Some(selected_org), project.as_ref()), path, }) } -/// Build an actionable "profile not found" error that lists the available -/// profiles, so non-interactive callers can see what they can pick from. -fn profile_not_found_err(name: &str, store: &AuthStore) -> anyhow::Error { - let available: Vec = store.profiles.keys().cloned().collect(); - let suffix = if available.is_empty() { - String::new() - } else { - format!(": {}", available.join(", ")) - }; - anyhow::anyhow!( - "profile '{name}' not found; run `bt auth profiles` to see available profiles{suffix}" - ) -} - /// Emit a machine-readable JSON payload on stdout when `--json` is set, /// otherwise run the human-readable printer. Keeps stdout pure JSON. fn emit_result(json: bool, payload: serde_json::Value, human: impl FnOnce()) -> Result<()> { @@ -1948,36 +2289,102 @@ fn emit_result(json: bool, payload: serde_json::Value, human: impl FnOnce()) -> Ok(()) } -async fn run_profiles(base: &BaseArgs, args: AuthProfilesArgs) -> Result<()> { - let store = load_auth_store()?; +fn filter_auth_store( + base: &BaseArgs, + store: &AuthStore, + kind: Option, + api_key_hint: Option<&str>, +) -> AuthStore { + let mut filtered = store.clone(); + filtered.profiles.retain(|_, profile| { + profile_matches_url_filters(base, profile) + && kind.is_none_or(|kind| profile.auth_kind == kind) + && api_key_hint.is_none_or(|hint| { + profile.auth_kind == AuthKind::ApiKey + && profile.api_key_hint.as_deref() == Some(hint.trim()) + }) + }); + filtered +} - // Filter to a single profile when --profile is given; error out if it doesn't match. - let filtered_store = match &args.profile { - Some(name) => { - let profile = store - .profiles - .get(name) - .ok_or_else(|| profile_not_found_err(name, &store))?; - let mut s = AuthStore::default(); - s.profiles.insert(name.clone(), profile.clone()); - s - } - None => store, +async fn filter_auth_store_for_org( + base: &BaseArgs, + store: &mut AuthStore, + candidates: AuthStore, + org: Option<&str>, +) -> Result { + let Some(org) = org else { + return Ok(candidates); }; + let mut filtered = AuthStore::default(); + for (slot, profile) in candidates.profiles { + let matches = match profile.auth_kind { + AuthKind::ApiKey => profile_matches_org_identifier(&profile, org), + AuthKind::Oauth => { + let mut oauth_base = base.clone(); + oauth_base.app_url = Some(profile_app_url(&profile).to_string()); + if oauth_base.api_url_source.is_none() { + oauth_base.api_url = profile.api_url.clone(); + } + let token = load_oauth_access_token(&oauth_base, store, &slot).await?; + let orgs = fetch_login_orgs(&token, profile_app_url(&profile)).await?; + find_login_org(&orgs, org).is_some() + } + }; + if matches { + filtered.profiles.insert(slot, profile); + } + } + Ok(filtered) +} - if filtered_store.profiles.is_empty() { +async fn run_logins(base: &BaseArgs, _args: AuthLoginsArgs) -> Result<()> { + let mut store = load_auth_store()?; + let requested_org = matches!( + base.org_name_source, + Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable) + ) + .then(|| base.org_name.as_deref()) + .flatten(); + let has_url_filter = matches!( + base.app_url_source, + Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable) + ) || matches!( + base.api_url_source, + Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable) + ); + let has_filter = requested_org.is_some() || base.prefer_api_key || has_url_filter; + let mut filter_base = base.clone(); + if filter_base.app_url_source.is_none() { + filter_base.app_url = None; + } + if filter_base.api_url_source.is_none() { + filter_base.api_url = None; + } + let candidates = filter_auth_store( + &filter_base, + &store, + base.prefer_api_key.then_some(AuthKind::ApiKey), + None, + ); + let filtered = + filter_auth_store_for_org(&filter_base, &mut store, candidates, requested_org).await?; + if filtered.profiles.is_empty() { return emit_result(base.json, serde_json::json!([]), || { - println!("No saved profiles. Run `bt auth login` to create one.") + if store.profiles.is_empty() && !has_filter { + println!("No saved auth logins. Run `bt auth login` to create one."); + } }); } - let verifications = verify_all_profiles_from_store(&filtered_store).await; + let verifications = verify_all_profiles_from_store(&filtered).await; + reconcile_verified_auth_slots(&mut store, &verifications)?; let all_network_errors = verifications .iter() .all(|v| v.status == "error" && !v.error.as_deref().unwrap_or("").contains("invalid")); if all_network_errors { - eprintln!("Could not reach Braintrust API. Showing saved profiles:"); - print_saved_profiles(&filtered_store, base.json)?; + eprintln!("Could not reach Braintrust API. Showing saved auth logins:"); + print_saved_profiles(&filtered, base.json)?; return Ok(()); } @@ -2004,79 +2411,123 @@ async fn run_profiles(base: &BaseArgs, args: AuthProfilesArgs) -> Result<()> { Ok(()) } +fn auth_profile_json(profile: &AuthProfile, status: &str) -> serde_json::Value { + serde_json::json!({ + "auth": auth_kind_label(profile.auth_kind), + "org": profile.org_name, + "org_id": profile.org_id.as_deref().filter(|org_id| !org_id.trim().is_empty()), + "user_name": profile.user_name, + "user_email": profile.email, + "api_key_hint": profile.api_key_hint, + "app_url": profile.app_url, + "api_url": profile.api_url, + "status": status, + }) +} + fn run_login_delete(profile_name: &str, force: bool, base_json: bool) -> Result<()> { let profile_name = profile_name.trim(); if profile_name.is_empty() { - bail!("profile name cannot be empty"); + bail!("auth login key cannot be empty"); } let mut store = load_auth_store()?; - if !store.profiles.contains_key(profile_name) { - return Err(profile_not_found_err(profile_name, &store)); - } + let profile = store.profiles.get(profile_name).cloned().ok_or_else(|| { + anyhow::anyhow!("auth login not found; run `bt auth logins` to see available logins") + })?; + let label = auth_slot_label(&profile); if !force { - if let Some(term) = ui::prompt_term() { - let confirmed = Confirm::new() - .with_prompt(format!("Delete profile '{profile_name}'?")) - .default(false) - .interact_on(&term)?; - if !confirmed { - return emit_result( - base_json, - serde_json::json!({ "name": profile_name, "status": "cancelled" }), - || eprintln!("Cancelled"), - ); - } + let term = ui::prompt_term().ok_or_else(|| { + anyhow::anyhow!( + "logout confirmation requires an interactive terminal; rerun with --force" + ) + })?; + let confirmed = Confirm::new() + .with_prompt(format!("Delete {label}?")) + .default(false) + .interact_on(&term)?; + if !confirmed { + return emit_result(base_json, auth_profile_json(&profile, "cancelled"), || { + eprintln!("Cancelled") + }); } } store.profiles.remove(profile_name); save_auth_store(&store)?; if let Err(err) = delete_profile_secret(profile_name) { - eprintln!("warning: failed to delete keychain credential for '{profile_name}': {err}"); + eprintln!("warning: failed to delete keychain credential for '{label}': {err}"); } if let Err(err) = delete_profile_oauth_refresh_token(profile_name) { - eprintln!("warning: failed to delete oauth refresh token for '{profile_name}': {err}"); + eprintln!("warning: failed to delete oauth refresh token for '{label}': {err}"); } if let Err(err) = delete_profile_oauth_access_token(profile_name) { - eprintln!("warning: failed to delete oauth access token for '{profile_name}': {err}"); + eprintln!("warning: failed to delete oauth access token for '{label}': {err}"); } + delete_legacy_profile_secrets(&profile); - emit_result( - base_json, - serde_json::json!({ "name": profile_name, "status": "deleted" }), - || { - ui::print_command_status( - ui::CommandStatus::Success, - &format!("Deleted profile '{profile_name}'"), - ) - }, - ) + emit_result(base_json, auth_profile_json(&profile, "deleted"), || { + ui::print_command_status(ui::CommandStatus::Success, &format!("Deleted {label}")); + }) } -fn run_login_logout(base: BaseArgs, args: AuthLogoutArgs) -> Result<()> { +async fn run_login_logout(base: BaseArgs, args: AuthLogoutArgs) -> Result<()> { let store = load_auth_store()?; if store.profiles.is_empty() { return emit_result(base.json, serde_json::json!({ "status": "empty" }), || { - println!("No saved profiles.") + println!("No saved auth logins.") }); } - let profile_name = if let Some(p) = args.profile.or(base.profile) { - let p = p.trim().to_string(); - if !store.profiles.contains_key(&p) { - return Err(profile_not_found_err(&p, &store)); - } - p - } else if store.profiles.len() == 1 { - store.profiles.keys().next().unwrap().clone() - } else if ui::can_prompt() { - let names: Vec<&str> = store.profiles.keys().map(|k| k.as_str()).collect(); - let idx = crate::ui::fuzzy_select("Select profile to log out", &names, 0)?; - names[idx].to_string() + let requested_org = if matches!( + base.org_name_source, + Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable) + ) { + config::org_option(base.org_name.as_deref()) } else { - bail!("multiple profiles exist. Use --profile to specify which one."); + None + }; + let mut filter_base = base.clone(); + if filter_base.app_url_source.is_none() { + filter_base.app_url = None; + } + if filter_base.api_url_source.is_none() { + filter_base.api_url = None; + } + let candidates = filter_auth_store( + &filter_base, + &store, + args.oauth.then_some(AuthKind::Oauth), + args.api_key_hint.as_deref(), + ); + let mut mutable_store = store.clone(); + let filtered = + filter_auth_store_for_org(&filter_base, &mut mutable_store, candidates, requested_org) + .await?; + let candidates = filtered + .profiles + .keys() + .map(String::as_str) + .collect::>(); + + let cfg_org = config_auth_context(&base); + let current_org = effective_org_name(&base, &cfg_org); + let profile_name = match candidates.len() { + 0 => bail!("no matching auth login found; run `bt auth logins` to see available logins"), + 1 => candidates[0].to_string(), + _ if ui::can_prompt() => select_profile_from_store( + "Select auth login to log out", + &candidates, + current_org, + &filtered, + )?, + _ => { + let labels = candidate_identities(&candidates, &filtered).join(", "); + bail!( + "multiple auth logins match: {labels}. Rerun interactively, use --app-url with --oauth, or use --org with --api-key-hint ." + ); + } }; run_login_delete(&profile_name, args.force, base.json) @@ -2098,18 +2549,20 @@ enum CredentialLoad { fn load_credential_for_profile(name: &str, profile: &AuthProfile) -> CredentialLoad { match profile.auth_kind { - AuthKind::ApiKey => match load_profile_secret(name) { - Ok(Some(k)) => CredentialLoad::Found(k), - Ok(None) => CredentialLoad::Missing, - Err(e) => CredentialLoad::Error(e.to_string()), - }, + AuthKind::ApiKey => { + match load_profile_secret_with_legacy(name, profile.legacy_secret_key.as_deref()) { + Ok(Some(k)) => CredentialLoad::Found(k), + Ok(None) => CredentialLoad::Missing, + Err(e) => CredentialLoad::Error(e.to_string()), + } + } AuthKind::Oauth => { if let Some(ts) = profile.oauth_access_expires_at { if !oauth_access_token_is_fresh(ts) { return CredentialLoad::Expired; } } - match load_profile_oauth_access_token(name) { + match load_profile_oauth_access_token_for_profile(name, profile) { Ok(Some(k)) => CredentialLoad::Found(k), Ok(None) => CredentialLoad::Missing, Err(e) => CredentialLoad::Error(e.to_string()), @@ -2120,16 +2573,25 @@ fn load_credential_for_profile(name: &str, profile: &AuthProfile) -> CredentialL #[derive(Debug, Clone, Serialize)] pub struct ProfileVerification { + #[serde(skip_serializing)] pub name: String, + #[serde(skip_serializing)] + slot_hash: Option, pub auth: String, #[serde(skip_serializing_if = "Option::is_none")] pub org: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub org_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub user_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub user_email: Option, #[serde(skip_serializing_if = "Option::is_none")] pub api_key_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_url: Option, pub status: String, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, @@ -2137,8 +2599,7 @@ pub struct ProfileVerification { fn build_verification( name: &str, - auth_kind: &str, - org: Option, + profile: &AuthProfile, jwt_id: Option, api_key_hint: Option, status: ProfileStatus, @@ -2151,11 +2612,18 @@ fn build_verification( }; ProfileVerification { name: name.to_string(), - auth: auth_kind.to_string(), - org, + slot_hash: None, + auth: auth_kind_label(profile.auth_kind).to_string(), + org: profile.org_name.clone(), + org_id: profile + .org_id + .clone() + .filter(|org_id| !org_id.trim().is_empty()), user_name: jwt_id.as_ref().and_then(|j| j.name.clone()), user_email: jwt_id.as_ref().and_then(|j| j.email.clone()), api_key_hint, + app_url: profile.app_url.clone(), + api_url: profile.api_url.clone(), status: status_str.to_string(), error, } @@ -2163,26 +2631,21 @@ fn build_verification( async fn verify_profile_full(name: &str, profile: &AuthProfile) -> ProfileVerification { let app_url = profile.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); - let auth_kind = match profile.auth_kind { - AuthKind::ApiKey => "api_key", - AuthKind::Oauth => "oauth", - }; let mk = |status, jwt_id: Option, hint: Option| { - build_verification( - name, - auth_kind, - profile.org_name.clone(), - jwt_id, - hint, - status, - ) + build_verification(name, profile, jwt_id, hint, status) }; let credential = match load_credential_for_profile(name, profile) { CredentialLoad::Found(k) => k, - CredentialLoad::Missing => return mk(ProfileStatus::Missing, None, None), - CredentialLoad::Expired => return mk(ProfileStatus::Expired, None, None), - CredentialLoad::Error(e) => return mk(ProfileStatus::Error(e), None, None), + CredentialLoad::Missing => { + return mk(ProfileStatus::Missing, None, profile.api_key_hint.clone()) + } + CredentialLoad::Expired => { + return mk(ProfileStatus::Expired, None, profile.api_key_hint.clone()) + } + CredentialLoad::Error(e) => { + return mk(ProfileStatus::Error(e), None, profile.api_key_hint.clone()) + } }; let (jwt_id, hint) = match profile.auth_kind { @@ -2191,19 +2654,38 @@ async fn verify_profile_full(name: &str, profile: &AuthProfile) -> ProfileVerifi }; match fetch_login_orgs(&credential, app_url).await { - Ok(_) => mk(ProfileStatus::Ok, jwt_id, hint), + Ok(orgs) => { + let mut verification = mk(ProfileStatus::Ok, jwt_id, hint); + if profile.auth_kind == AuthKind::ApiKey { + if let Some(org) = profile + .org_id + .as_deref() + .and_then(|id| find_login_org(&orgs, id)) + .or_else(|| { + profile + .org_name + .as_deref() + .and_then(|name| find_login_org(&orgs, name)) + }) + { + verification.org = Some(org.name.clone()); + verification.org_id = Some(org.id.clone()); + } + verification.slot_hash = Some(api_key_hash(&credential)); + } + verification + } Err(e) => { - let msg = e.to_string(); - let status = if msg.contains("401") || msg.contains("Unauthorized") { + let status = if is_unauthorized_auth_error(&e) { if profile.auth_kind == AuthKind::Oauth { ProfileStatus::Expired } else { ProfileStatus::Error("invalid API key".to_string()) } } else { - ProfileStatus::Error(msg) + ProfileStatus::Error(e.to_string()) }; - mk(status, None, None) + mk(status, None, hint) } } } @@ -2222,28 +2704,99 @@ async fn verify_all_profiles_from_store(store: &AuthStore) -> Vec String { - let mut parts = vec![v.name.clone(), v.auth.clone()]; - if let Some(ref org) = v.org { - parts.push(format!("org: {org}")); +fn sort_profile_verifications(verifications: &mut [ProfileVerification]) { + verifications.sort_by(|a, b| { + a.org + .as_deref() + .unwrap_or("") + .cmp(b.org.as_deref().unwrap_or("")) + .then_with(|| a.name.cmp(&b.name)) + }); +} + +fn reconcile_verified_auth_slots( + store: &mut AuthStore, + verifications: &[ProfileVerification], +) -> Result<()> { + let mut changed = false; + for verification in verifications + .iter() + .filter(|verification| verification.status == "ok") + { + let Some(mut profile) = store.profiles.get(&verification.name).cloned() else { + continue; + }; + + if profile.auth_kind == AuthKind::ApiKey { + if let Some(org_id) = verification.org_id.as_deref() { + profile.org_id = Some(org_id.to_string()); + profile.org_name = verification.org.clone(); + } + } else { + profile.org_id = None; + profile.org_name = None; + } + + match profile.auth_kind { + AuthKind::ApiKey => { + let Some(hash) = verification.slot_hash.as_deref() else { + continue; + }; + if profile.org_id.as_deref().is_none_or(str::is_empty) { + continue; + } + profile.api_key_hash = Some(hash.to_string()); + } + AuthKind::Oauth => { + if let Some(email) = verification.user_email.as_deref() { + profile.email = Some(email.to_string()); + } + if let Some(user_name) = verification.user_name.as_deref() { + profile.user_name = Some(user_name.to_string()); + } + if profile.email.as_deref().is_none_or(str::is_empty) { + continue; + } + } + } + + changed |= replace_with_canonical_auth_profile(store, verification.name.as_str(), profile); } + + if changed { + save_auth_store(store)?; + } + Ok(()) +} + +fn format_verification_line(v: &ProfileVerification) -> String { + let subject = if v.auth == "oauth" { + v.app_url + .clone() + .unwrap_or_else(|| DEFAULT_APP_URL.to_string()) + } else { + v.org.clone().unwrap_or_else(|| "(unknown org)".to_string()) + }; + let mut parts = vec![subject, v.auth.clone()]; match v.status.as_str() { "ok" => { - let id = match (&v.user_name, &v.user_email) { - (Some(name), Some(email)) => Some(format!("{name} ({email})")), - (None, Some(email)) => Some(email.clone()), - _ => v.api_key_hint.clone(), - }; - if let Some(id) = id { + if let Some(id) = identity_label( + v.user_name.as_deref(), + v.user_email.as_deref(), + v.api_key_hint.as_deref(), + ) { parts.push(id); } } "expired" => parts.push("token expired".into()), - "missing" => parts.push("credential missing".into()), + "missing" => match v.api_key_hint.as_deref() { + Some(hint) => parts.push(format!("{hint} credential missing")), + None => parts.push("credential missing".into()), + }, _ => { if let Some(ref e) = v.error { parts.push(e.clone()); @@ -2253,45 +2806,43 @@ fn format_verification_line(v: &ProfileVerification) -> String { parts.join(" — ") } +fn profiles_grouped_by_org(store: &AuthStore) -> Vec<(&str, &AuthProfile)> { + let mut profiles = store + .profiles + .iter() + .map(|(name, profile)| (name.as_str(), profile)) + .collect::>(); + profiles.sort_by(|(a_name, a), (b_name, b)| { + profile_org(a) + .cmp(profile_org(b)) + .then_with(|| a_name.cmp(b_name)) + }); + profiles +} + fn print_saved_profiles(store: &AuthStore, json: bool) -> Result<()> { + let profiles = profiles_grouped_by_org(store); if json { - let output: Vec = store - .profiles - .iter() - .map(|(name, p)| { + let output: Vec = profiles + .into_iter() + .map(|(_, p)| { serde_json::json!({ - "name": name, - "auth": match p.auth_kind { AuthKind::ApiKey => "api_key", AuthKind::Oauth => "oauth" }, + "auth": auth_kind_label(p.auth_kind), "org": p.org_name, + "org_id": p.org_id, "user_name": p.user_name, "user_email": p.email, "api_key_hint": p.api_key_hint, + "app_url": p.app_url, + "api_url": p.api_url, "status": "unchecked" }) }) .collect(); println!("{}", serde_json::to_string(&output)?); } else { - for (name, profile) in &store.profiles { - let kind = match profile.auth_kind { - AuthKind::ApiKey => "api_key", - AuthKind::Oauth => "oauth", - }; - let org = profile - .org_name - .as_deref() - .map(|o| format!(" org={o}")) - .unwrap_or_default(); - let id = match (profile.user_name.as_deref(), profile.email.as_deref()) { - (Some(n), Some(e)) => format!(" {n} ({e})"), - (None, Some(e)) => format!(" {e}"), - _ => profile - .api_key_hint - .as_deref() - .map(|h| format!(" {h}")) - .unwrap_or_default(), - }; - println!(" {name} {kind}{org}{id}"); + for (_, profile) in profiles { + println!(" {}", auth_slot_label(profile)); } } Ok(()) @@ -2332,18 +2883,12 @@ fn select_login_org( default_org_name: Option<&str>, interactive: bool, verbose: bool, - allow_cross_org: bool, quiet_requested: bool, ) -> Result> { if orgs.is_empty() { bail!("no organizations found for this credential"); } - orgs.sort_by(|a, b| { - a.name - .to_ascii_lowercase() - .cmp(&b.name.to_ascii_lowercase()) - .then_with(|| a.name.cmp(&b.name)) - }); + sort_login_orgs(&mut orgs); if let Some(name) = requested_org_name { return find_login_org(&orgs, name) @@ -2360,40 +2905,40 @@ fn select_login_org( return Ok(None); } - let default_org_matched = move_default_login_org_first(&mut orgs, default_org_name); - let offset = if allow_cross_org { 1 } else { 0 }; - let mut labels: Vec = Vec::new(); - if allow_cross_org { - labels.push( - "No default org (cross-org mode; pass --org or BRAINTRUST_ORG_NAME when needed)" - .to_string(), - ); - } - labels.extend(orgs.iter().map(|org| { - if verbose { - let api_url = org.api_url.as_deref().unwrap_or(DEFAULT_API_URL); - format!("{} [{}] ({})", org.name, org.id, api_url) - } else { - org.name.clone() - } - })); + move_default_login_org_first(&mut orgs, default_org_name); + let labels: Vec = orgs + .iter() + .map(|org| { + if verbose { + let api_url = org.api_url.as_deref().unwrap_or(DEFAULT_API_URL); + format!("{} [{}] ({})", org.name, org.id, api_url) + } else { + org.name.clone() + } + }) + .collect(); let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); if !quiet_requested { eprintln!("\n\nA Braintrust organization is usually a team or a company."); } - let default = if default_org_matched { offset } else { 0 }; - let selection = ui::fuzzy_select("Select organization", &label_refs, default)?; - if allow_cross_org && selection == 0 { - return Ok(None); - } + let selection = ui::fuzzy_select("Select organization", &label_refs, 0)?; Ok(Some( orgs.into_iter() - .nth(selection - offset) + .nth(selection) .expect("selected index should be in range"), )) } +fn sort_login_orgs(orgs: &mut [LoginOrgInfo]) { + orgs.sort_by(|a, b| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + .then_with(|| a.name.cmp(&b.name)) + }); +} + fn move_default_login_org_first( orgs: &mut Vec, default_org_name: Option<&str>, @@ -2417,7 +2962,7 @@ fn find_login_org<'a>( fn find_login_org_index(orgs: &[LoginOrgInfo], requested_org_name: &str) -> Option { orgs.iter() - .position(|org| org.name == requested_org_name) + .position(|org| org.id == requested_org_name || org.name == requested_org_name) .or_else(|| { let lowered = requested_org_name.to_ascii_lowercase(); orgs.iter() @@ -2425,13 +2970,18 @@ fn find_login_org_index(orgs: &[LoginOrgInfo], requested_org_name: &str) -> Opti }) } -fn missing_requested_org_error(orgs: &[LoginOrgInfo], requested_org_name: &str) -> anyhow::Error { - let available = orgs - .iter() +fn login_org_names(orgs: &[LoginOrgInfo]) -> String { + orgs.iter() .map(|org| org.name.as_str()) .collect::>() - .join(", "); - anyhow::anyhow!("org '{requested_org_name}' not found. Available: {available}") + .join(", ") +} + +fn missing_requested_org_error(orgs: &[LoginOrgInfo], requested_org_name: &str) -> anyhow::Error { + anyhow::anyhow!( + "org '{requested_org_name}' not found. Available: {}", + login_org_names(orgs) + ) } fn resolve_requested_org_for_api_key_login( @@ -2488,12 +3038,16 @@ fn resolve_profile_api_url( explicit_api_url: Option, selected_org: Option<&LoginOrgInfo>, orgs: &[LoginOrgInfo], + can_prompt: bool, ) -> Result { if let Some(api_url) = explicit_api_url { return Ok(api_url); } - if let Some(api_url) = selected_org.and_then(|org| org.api_url.clone()) { - return Ok(api_url); + if let Some(selected_org) = selected_org { + return Ok(selected_org + .api_url + .clone() + .unwrap_or_else(|| DEFAULT_API_URL.to_string())); } let mut api_urls = orgs @@ -2510,30 +3064,19 @@ fn resolve_profile_api_url( .unwrap_or_else(|| DEFAULT_API_URL.to_string())); } + if can_prompt { + let idx = ui::fuzzy_select("Select API URL", &api_urls, 0)?; + return Ok(api_urls + .into_iter() + .nth(idx) + .expect("selected API URL should be in range")); + } + bail!( - "multiple organizations expose different API URLs; choose an organization or pass --api-url explicitly" + "multiple organizations expose different API URLs; pass --org to pick one, or --api-url explicitly" ) } -fn default_oauth_client_id(profile_name: &str) -> String { - let sanitized = profile_name - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch - } else { - '-' - } - }) - .collect::(); - let trimmed = sanitized.trim_matches('-'); - if trimmed.is_empty() { - "bt_cli_default".to_string() - } else { - format!("bt_cli_{trimmed}") - } -} - fn generate_random_token(num_bytes: usize) -> Result { let mut bytes = vec![0u8; num_bytes]; getrandom::fill(&mut bytes) @@ -2890,7 +3433,6 @@ fn is_ssh_session() -> bool { async fn exchange_oauth_authorization_code( api_url: &str, - client_id: &str, redirect_uri: &str, code: &str, code_verifier: PkceCodeVerifier, @@ -2906,7 +3448,7 @@ async fn exchange_oauth_authorization_code( api_url, &[ ("grant_type", "authorization_code"), - ("client_id", client_id), + ("client_id", OAUTH_CLIENT_ID), ("code", code), ("redirect_uri", redirect_uri), ("code_verifier", code_verifier.secret()), @@ -2917,20 +3459,20 @@ async fn exchange_oauth_authorization_code( fn map_refresh_oauth_error( api_url: &str, - profile_name: &str, + profile: &AuthProfile, status: reqwest::StatusCode, body: &str, ) -> anyhow::Error { if let Ok(server_err) = serde_json::from_str::(body) { if matches!(server_err.error.as_deref(), Some("invalid_grant")) { - let mut message = - format!("oauth refresh token expired or was rejected for profile '{profile_name}'"); + let mut message = format!( + "oauth refresh token expired or was rejected for auth login '{}'", + auth_slot_label(profile) + ); if let Some(description) = server_err.error_description.as_deref() { message.push_str(&format!(" ({description})")); } - message.push_str(&format!( - "; re-run `bt auth login --oauth --profile {profile_name}`" - )); + message.push_str(&format!("; re-run `{}`", oauth_reauth_command(profile))); return recoverable_auth_error(RecoverableAuthErrorKind::OauthRefreshToken, message); } } @@ -2944,8 +3486,7 @@ fn map_refresh_oauth_error( async fn refresh_oauth_access_token( api_url: &str, refresh_token: &str, - client_id: &str, - profile_name: &str, + profile: &AuthProfile, ) -> Result { let http_client = build_http_client_from_builder( reqwest::Client::builder() @@ -2958,7 +3499,7 @@ async fn refresh_oauth_access_token( .post(&token_url) .form(&[ ("grant_type", "refresh_token"), - ("client_id", client_id), + ("client_id", OAUTH_CLIENT_ID), ("refresh_token", refresh_token), ]) .send() @@ -2967,12 +3508,7 @@ async fn refresh_oauth_access_token( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(map_refresh_oauth_error( - api_url, - profile_name, - status, - &body, - )); + return Err(map_refresh_oauth_error(api_url, profile, status, &body)); } response @@ -3005,18 +3541,14 @@ async fn request_oauth_token( .context("failed to parse oauth token response") } -fn build_oauth_client( - api_url: &str, - client_id: &str, - redirect_uri: Option<&str>, -) -> Result { +fn build_oauth_client(api_url: &str, redirect_uri: Option<&str>) -> Result { let api_url = api_url.trim_end_matches('/'); let auth_url = AuthUrl::new(format!("{api_url}/oauth/authorize")) .context("failed to construct oauth authorize URL")?; let token_url = TokenUrl::new(format!("{api_url}/oauth/token")) .context("failed to construct oauth token URL")?; let client = BasicClient::new( - ClientId::new(client_id.to_string()), + ClientId::new(OAUTH_CLIENT_ID.to_string()), None, auth_url, Some(token_url), @@ -3093,6 +3625,47 @@ fn load_profile_secret(profile_name: &str) -> Result> { } } +fn load_profile_secret_with_legacy( + primary_key: &str, + legacy_key: Option<&str>, +) -> Result> { + if let Some(secret) = load_profile_secret(primary_key)? { + return Ok(Some(secret)); + } + + let Some(legacy_key) = legacy_key + .map(str::trim) + .filter(|key| !key.is_empty() && *key != primary_key) + else { + return Ok(None); + }; + + let Some(secret) = load_profile_secret(legacy_key)? else { + return Ok(None); + }; + let _ = relocate_plaintext_secret_if_present(primary_key, legacy_key, &secret); + Ok(Some(secret)) +} + +fn relocate_plaintext_secret_if_present( + primary_key: &str, + legacy_key: &str, + secret: &str, +) -> Result<()> { + let path = secret_store_path()?; + if !path.exists() { + return Ok(()); + } + let mut store = load_secret_store()?; + if store.secrets.remove(legacy_key).is_some() { + store + .secrets + .insert(primary_key.to_string(), secret.to_string()); + save_secret_store(&store)?; + } + Ok(()) +} + fn delete_profile_secret(profile_name: &str) -> Result<()> { let keychain_err = delete_profile_secret_keychain(profile_name).err(); let plaintext_err = delete_profile_secret_plaintext(profile_name).err(); @@ -3156,40 +3729,26 @@ fn load_secret_store() -> Result { fn save_secret_store(store: &SecretStore) -> Result<()> { let path = secret_store_path()?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; - } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; let data = serde_json::to_string_pretty(store).context("failed to serialize secret store")?; - let temp_path = path.with_extension("tmp"); - let mut file = fs::File::create(&temp_path) - .with_context(|| format!("failed to write temp secret store {}", temp_path.display()))?; + // A uniquely-named temp file (created `0600` by `tempfile`) prevents two + // concurrent `bt` writers from sharing one `.tmp` inode and renaming + // interleaved bytes over the store, and closes the umask window that a + // truncate-then-chmod on a fixed name would leave open. + let mut file = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("failed to create temp secret store in {}", parent.display()))?; file.write_all(data.as_bytes()) - .with_context(|| format!("failed to write temp secret store {}", temp_path.display()))?; + .context("failed to write temp secret store")?; file.write_all(b"\n") - .with_context(|| format!("failed to write temp secret store {}", temp_path.display()))?; - file.sync_all() - .with_context(|| format!("failed to flush temp secret store {}", temp_path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)).with_context(|| { - format!( - "failed to set permissions on temp secret store {}", - temp_path.display() - ) - })?; - } - - fs::rename(&temp_path, &path).with_context(|| { - format!( - "failed to move temp secret store {} to {}", - temp_path.display(), - path.display() - ) - })?; + .context("failed to write temp secret store")?; + file.as_file() + .sync_all() + .context("failed to flush temp secret store")?; + file.persist(&path) + .with_context(|| format!("failed to move temp secret store to {}", path.display()))?; #[cfg(unix)] { @@ -3445,9 +4004,16 @@ fn save_profile_oauth_refresh_token(profile_name: &str, refresh_token: &str) -> save_profile_secret(&key, refresh_token) } -fn load_profile_oauth_refresh_token(profile_name: &str) -> Result> { - let key = oauth_refresh_secret_key(profile_name); - load_profile_secret(&key) +fn load_profile_oauth_refresh_token_for_profile( + profile_name: &str, + profile: &AuthProfile, +) -> Result> { + let primary = oauth_refresh_secret_key(profile_name); + let legacy = profile + .legacy_secret_key + .as_deref() + .map(oauth_refresh_secret_key); + load_profile_secret_with_legacy(&primary, legacy.as_deref()) } fn delete_profile_oauth_refresh_token(profile_name: &str) -> Result<()> { @@ -3460,9 +4026,16 @@ fn save_profile_oauth_access_token(profile_name: &str, access_token: &str) -> Re save_profile_secret(&key, access_token) } -fn load_profile_oauth_access_token(profile_name: &str) -> Result> { - let key = oauth_access_secret_key(profile_name); - load_profile_secret(&key) +fn load_profile_oauth_access_token_for_profile( + profile_name: &str, + profile: &AuthProfile, +) -> Result> { + let primary = oauth_access_secret_key(profile_name); + let legacy = profile + .legacy_secret_key + .as_deref() + .map(oauth_access_secret_key); + load_profile_secret_with_legacy(&primary, legacy.as_deref()) } fn delete_profile_oauth_access_token(profile_name: &str) -> Result<()> { @@ -3470,8 +4043,40 @@ fn delete_profile_oauth_access_token(profile_name: &str) -> Result<()> { delete_profile_secret(&key) } +fn delete_legacy_profile_secrets(profile: &AuthProfile) { + let Some(legacy_key) = profile.legacy_secret_key.as_deref() else { + return; + }; + match profile.auth_kind { + AuthKind::ApiKey => { + let _ = delete_profile_secret(legacy_key); + } + AuthKind::Oauth => { + let _ = delete_profile_oauth_refresh_token(legacy_key); + let _ = delete_profile_oauth_access_token(legacy_key); + } + } +} + +/// Delete every secret a profile could reference: those stored under its own +/// slot key and those under its lazy legacy fallback key. Used when a duplicate +/// login is discarded during canonicalization so nothing is orphaned. +fn delete_all_profile_secrets(slot_key: &str, profile: &AuthProfile) { + match profile.auth_kind { + AuthKind::ApiKey => { + let _ = delete_profile_secret(slot_key); + } + AuthKind::Oauth => { + let _ = delete_profile_oauth_refresh_token(slot_key); + let _ = delete_profile_oauth_access_token(slot_key); + } + } + delete_legacy_profile_secrets(profile); +} + fn load_valid_cached_oauth_access_token( profile_name: &str, + profile: &AuthProfile, expires_at: Option, ) -> Result> { let Some(expires_at) = expires_at else { @@ -3480,7 +4085,7 @@ fn load_valid_cached_oauth_access_token( if !oauth_access_token_is_fresh(expires_at) { return Ok(None); } - load_profile_oauth_access_token(profile_name) + load_profile_oauth_access_token_for_profile(profile_name, profile) } fn oauth_access_token_is_fresh(expires_at: u64) -> bool { @@ -3527,43 +4132,250 @@ fn decode_jwt_identity(token: &str) -> JwtIdentity { .and_then(|v| v.as_str()) .map(String::from), }) - }; - extract().unwrap_or(JwtIdentity { - name: None, - email: None, - }) + }; + extract().unwrap_or(JwtIdentity { + name: None, + email: None, + }) +} + +pub fn obscure_api_key(key: &str) -> String { + if !key.is_ascii() || key.len() <= 8 { + return "****".to_string(); + } + let suffix_start = key.len().saturating_sub(5); + let prefix_end = key.find('-').map(|i| i + 1).unwrap_or(0); + // A late first dash can push the prefix up to (or past) the suffix, leaving + // no masked middle and revealing the whole key. Fully mask instead. + if prefix_end >= suffix_start { + return "****".to_string(); + } + format!("{}****{}", &key[..prefix_end], &key[suffix_start..]) +} + +fn sha256_hex(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + let digest = hasher.finalize(); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + +fn api_key_hash(api_key: &str) -> String { + sha256_hex(api_key) +} + +fn oauth_slot_key(app_url: &str) -> String { + format!("oauth::{}", sha256_hex(canonical_url(app_url))) +} + +fn api_key_slot_key(api_key_hash: &str, org_id: &str) -> String { + format!("{api_key_hash}::{org_id}") +} + +fn current_unix_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn load_auth_store() -> Result { + let path = auth_store_path()?; + load_auth_store_from_path(&path) +} + +fn load_auth_store_from_path(path: &Path) -> Result { + if !path.exists() { + return Ok(AuthStore::default()); + } + + let data = fs::read_to_string(path) + .with_context(|| format!("failed to read auth config {}", path.display()))?; + let store: AuthStore = serde_json::from_str(&data) + .with_context(|| format!("failed to parse auth config {}", path.display()))?; + let migrated = migrate_auth_store(store.clone()); + if migrated != store { + // The migrated store is already usable in memory, so a failed write-back + // must not break read-only commands (`bt status`, `bt auth logins`). Warn + // and proceed; the next writable run retries the migration. + match save_auth_store_to_path(path, &migrated) { + // Only prune once the collapsed store is durably on disk; otherwise + // the on-disk file still references the dropped duplicate and the + // next load must be able to retry the migration. + Ok(()) => prune_orphaned_migration_secrets(&store, &migrated), + Err(err) => eprintln!( + "warning: Migrating {} to use the new format failed. Please delete this file and login again. ({err})", + path.display() + ), + } + } + Ok(migrated) +} + +fn migrate_auth_store(store: AuthStore) -> AuthStore { + let mut migrated = AuthStore::default(); + let mut oauth_refresh_usable = BTreeMap::::new(); + for (old_key, mut profile) in store.profiles { + normalize_profile_cached_fields_from_key(&old_key, &mut profile); + let refresh_usable = profile.auth_kind == AuthKind::Oauth + && matches!( + load_profile_oauth_refresh_token_for_profile(&old_key, &profile), + Ok(Some(_)) + ); + if profile.auth_kind == AuthKind::Oauth { + profile.org_id = None; + profile.org_name = None; + profile.app_url = Some(canonical_url(profile_app_url(&profile)).to_string()); + } + let new_key = canonical_profile_key(&old_key, &profile); + if new_key != old_key && profile.legacy_secret_key.is_none() { + profile.legacy_secret_key = Some(old_key.clone()); + } + if let Some(existing) = migrated.profiles.get(&new_key) { + let existing_usable = oauth_refresh_usable.get(&new_key).copied().unwrap_or(false); + let replace = match (existing.auth_kind, profile.auth_kind) { + (AuthKind::Oauth, AuthKind::Oauth) => { + (refresh_usable && !existing_usable) + || (refresh_usable == existing_usable + && should_replace_migrated_profile(existing, &profile)) + } + _ => false, + }; + if !replace { + continue; + } + } + oauth_refresh_usable.insert(new_key.clone(), refresh_usable); + migrated.profiles.insert(new_key, profile); + } + migrated +} + +/// Secret slots left dangling after migration collapsed duplicate logins onto a +/// shared canonical key. A surviving login keeps its secret under its +/// `legacy_secret_key` (until it is lazily relocated) or, absent one, under its +/// own slot key; any pre-migration key outside that referenced set belonged to a +/// dropped duplicate and can be deleted. Pure so it stays unit-testable; the +/// caller performs the keychain I/O. +fn orphaned_migration_secret_keys<'a>( + before: &'a AuthStore, + after: &AuthStore, +) -> Vec<(&'a str, AuthKind)> { + let referenced: BTreeSet<&str> = after + .profiles + .iter() + .map(|(slot, profile)| { + profile + .legacy_secret_key + .as_deref() + .unwrap_or(slot.as_str()) + }) + .collect(); + before + .profiles + .iter() + .filter(|(key, _)| !referenced.contains(key.as_str())) + .map(|(key, profile)| (key.as_str(), profile.auth_kind)) + .collect() } -pub fn obscure_api_key(key: &str) -> String { - if !key.is_ascii() || key.len() <= 8 { - return "****".to_string(); +fn prune_orphaned_migration_secrets(before: &AuthStore, after: &AuthStore) { + for (key, auth_kind) in orphaned_migration_secret_keys(before, after) { + match auth_kind { + AuthKind::ApiKey => { + let _ = delete_profile_secret(key); + } + AuthKind::Oauth => { + let _ = delete_profile_oauth_refresh_token(key); + let _ = delete_profile_oauth_access_token(key); + } + } } - let prefix_end = key.find('-').map(|i| i + 1).unwrap_or(0); - let suffix_start = key.len().saturating_sub(5); - format!("{}****{}", &key[..prefix_end], &key[suffix_start..]) } -fn current_unix_timestamp() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) +fn should_replace_migrated_profile(existing: &AuthProfile, candidate: &AuthProfile) -> bool { + match (existing.auth_kind, candidate.auth_kind) { + (AuthKind::Oauth, AuthKind::Oauth) => { + candidate.oauth_access_expires_at.unwrap_or_default() + > existing.oauth_access_expires_at.unwrap_or_default() + } + _ => false, + } } -fn load_auth_store() -> Result { - let path = auth_store_path()?; - load_auth_store_from_path(&path) +/// Runtime variant of [`should_replace_migrated_profile`] that can read the +/// keychain: when two OAuth logins collapse onto the same slot, keep whichever +/// still has a loadable refresh token (cached access-token expiry is unrelated +/// to which refresh token is live). Falls back to the pure expiry heuristic +/// when both or neither can refresh. +fn should_replace_canonical_profile( + slot_key: &str, + existing: &AuthProfile, + candidate: &AuthProfile, +) -> bool { + if let (AuthKind::Oauth, AuthKind::Oauth) = (existing.auth_kind, candidate.auth_kind) { + let has_refresh = |profile: &AuthProfile| { + matches!( + load_profile_oauth_refresh_token_for_profile(slot_key, profile), + Ok(Some(_)) + ) + }; + match (has_refresh(existing), has_refresh(candidate)) { + (false, true) => return true, + (true, false) => return false, + _ => {} + } + } + should_replace_migrated_profile(existing, candidate) } -fn load_auth_store_from_path(path: &Path) -> Result { - if !path.exists() { - return Ok(AuthStore::default()); +fn looks_like_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn normalize_profile_cached_fields_from_key(current_key: &str, profile: &mut AuthProfile) { + let Some((left, right)) = current_key.split_once("::") else { + return; + }; + + match profile.auth_kind { + AuthKind::Oauth => { + let key_matches_email = profile.email.as_deref() == Some(right); + if key_matches_email || (profile.email.is_none() && right.contains('@')) { + profile.org_id = Some(left.to_string()); + if profile.email.is_none() { + profile.email = Some(right.to_string()); + } + } + } + AuthKind::ApiKey => { + if looks_like_sha256_hex(left) && !right.trim().is_empty() { + profile.api_key_hash = Some(left.to_string()); + profile.org_id = Some(right.to_string()); + } + } } +} - let data = fs::read_to_string(path) - .with_context(|| format!("failed to read auth config {}", path.display()))?; - serde_json::from_str(&data) - .with_context(|| format!("failed to parse auth config {}", path.display())) +fn canonical_profile_key(current_key: &str, profile: &AuthProfile) -> String { + match profile.auth_kind { + AuthKind::Oauth => oauth_slot_key(profile_app_url(profile)), + AuthKind::ApiKey => match ( + profile + .api_key_hash + .as_deref() + .filter(|value| !value.is_empty()), + profile.org_id.as_deref().filter(|value| !value.is_empty()), + ) { + (Some(hash), Some(org_id)) => api_key_slot_key(hash, org_id), + _ => current_key.to_string(), + }, + } } fn save_auth_store(store: &AuthStore) -> Result<()> { @@ -3572,40 +4384,24 @@ fn save_auth_store(store: &AuthStore) -> Result<()> { } fn save_auth_store_to_path(path: &Path, store: &AuthStore) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; - } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; let data = serde_json::to_string_pretty(store).context("failed to serialize auth config")?; - let temp_path = path.with_extension("tmp"); - let mut file = fs::File::create(&temp_path) - .with_context(|| format!("failed to write temp auth config {}", temp_path.display()))?; + // Unique temp name (see `save_secret_store`): keeps concurrent writers from + // colliding on a shared `.tmp` inode and publishing a corrupt store. + let mut file = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("failed to create temp auth config in {}", parent.display()))?; file.write_all(data.as_bytes()) - .with_context(|| format!("failed to write temp auth config {}", temp_path.display()))?; + .context("failed to write temp auth config")?; file.write_all(b"\n") - .with_context(|| format!("failed to write temp auth config {}", temp_path.display()))?; - file.sync_all() - .with_context(|| format!("failed to flush temp auth config {}", temp_path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)).with_context(|| { - format!( - "failed to set permissions on temp auth config {}", - temp_path.display() - ) - })?; - } - - fs::rename(&temp_path, path).with_context(|| { - format!( - "failed to move temp auth config {} to {}", - temp_path.display(), - path.display() - ) - })?; + .context("failed to write temp auth config")?; + file.as_file() + .sync_all() + .context("failed to flush temp auth config")?; + file.persist(path) + .with_context(|| format!("failed to move temp auth config to {}", path.display()))?; #[cfg(unix)] { @@ -3660,31 +4456,11 @@ mod tests { }; fn make_base() -> BaseArgs { - BaseArgs { - json: false, - verbose: false, - verbose_source: None, - quiet: false, - quiet_source: None, - no_color: false, - no_input: false, - profile: None, - profile_explicit: false, - project: None, - org_name: None, - api_key: None, - api_key_source: None, - prefer_profile: false, - api_url: None, - app_url: None, - ca_cert: None, - env_file: None, - } + BaseArgs::default() } - fn auth_config(profile: Option<&str>, org: Option<&str>) -> crate::config::Config { + fn auth_config(org: Option<&str>) -> crate::config::Config { crate::config::Config { - profile: profile.map(str::to_string), org: org.map(str::to_string), ..Default::default() } @@ -3941,6 +4717,7 @@ mod tests { fn setup_global_config(project_id: Option<&str>, org: Option<&str>) { let cfg = crate::config::Config { org: org.map(str::to_string), + project: project_id.map(|_| "test-project".to_string()), project_id: project_id.map(str::to_string), ..crate::config::Config::default() }; @@ -3948,21 +4725,32 @@ mod tests { crate::config::save_global(&cfg).expect("save global config"); } + fn set_global_config_urls(app_url: &str, api_url: Option<&str>) { + let mut cfg = crate::config::load_global().expect("load global config"); + cfg.app_url = Some(app_url.to_string()); + cfg.api_url = api_url.map(str::to_string); + crate::config::save_global(&cfg).expect("save global config URLs"); + } + + fn org_profile(kind: AuthKind, org_id: &str, org_name: &str) -> AuthProfile { + AuthProfile { + auth_kind: kind, + org_id: Some(org_id.into()), + org_name: Some(org_name.into()), + ..Default::default() + } + } + fn setup_auth_store_profiles(profiles: &[(&str, &str, &str, &str)]) { let mut store = AuthStore::default(); for (profile_name, org_name, api_url, app_url) in profiles { store.profiles.insert( (*profile_name).to_string(), AuthProfile { - auth_kind: AuthKind::ApiKey, api_url: Some((*api_url).to_string()), app_url: Some((*app_url).to_string()), org_name: Some((*org_name).to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, - user_name: None, - email: None, - api_key_hint: None, + ..Default::default() }, ); } @@ -4017,22 +4805,29 @@ mod tests { #[test] fn invalid_grant_refresh_error_is_treated_as_recoverable() { + let profile = org_profile(AuthKind::Oauth, "org_test", "BT Staging"); + let command = oauth_reauth_command(&profile); + assert_eq!( + command, + "bt auth login --oauth --app-url https://www.braintrust.dev" + ); let err = map_refresh_oauth_error( "https://api.example.com", - "work", + &profile, reqwest::StatusCode::BAD_REQUEST, r#"{"error":"invalid_grant","error_description":"refresh token expired"}"#, ); assert!(is_missing_credential_error(&err)); assert!(err.to_string().contains("refresh token expired")); + assert!(err.to_string().contains(&format!("re-run `{command}`"))); } #[test] fn nonrecoverable_refresh_errors_remain_nonrecoverable() { let err = map_refresh_oauth_error( "https://api.example.com", - "work", + &org_profile(AuthKind::Oauth, "org_test", "test-org"), reqwest::StatusCode::BAD_REQUEST, "unexpected response", ); @@ -4098,8 +4893,11 @@ mod tests { } async fn login_read_only_probe(&self, org_name: Option<&str>) -> Result { - self.login_read_only_with_base(base_args_for_path_probe(org_name)) - .await + let mut base = base_args_for_path_probe(org_name); + if let Some(org) = org_name.filter(|org| !org.trim().is_empty()) { + base.app_url = Some(spawn_api_key_login_server(org)); + } + self.login_read_only_with_base(base).await } } @@ -4111,860 +4909,902 @@ mod tests { } } + fn spawn_login_response_server(status: &str, body: String) -> String { + use std::io::{Read as _, Write as _}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind login server"); + let address = listener.local_addr().expect("login server address"); + let status = status.to_string(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept login request"); + let mut request = [0u8; 4096]; + let _ = stream.read(&mut request); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write login response"); + }); + format!("http://{address}") + } + + fn spawn_api_key_login_server(org_name: &str) -> String { + spawn_login_response_server( + "200 OK", + serde_json::json!({ + "org_info": [{ + "id": "org_test", + "name": org_name, + "api_url": "https://api.example.test" + }] + }) + .to_string(), + ) + } + #[test] fn default_app_url_is_www() { assert_eq!(DEFAULT_APP_URL, "https://www.braintrust.dev"); } - #[test] - fn save_and_load_auth_store_round_trip() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let dir = std::env::temp_dir().join(format!("bt-auth-store-test-{unique}")); - fs::create_dir_all(&dir).expect("create dir"); - let path = dir.join("auth.json"); - - let mut store = AuthStore::default(); + fn save_cached_oauth_login(store: &mut AuthStore, app_url: &str) -> String { + let slot_key = oauth_slot_key(app_url); store.profiles.insert( - "work".to_string(), + slot_key.clone(), AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: Some("https://www.example.com".to_string()), - org_name: Some("Example Org".to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, + api_url: Some("https://api.example.test".to_string()), + app_url: Some(app_url.to_string()), + oauth_access_expires_at: Some(current_unix_timestamp() + 3600), + user_name: Some("Test User".to_string()), + email: Some("user@example.test".to_string()), + auth_kind: AuthKind::Oauth, + org_id: None, + org_name: None, ..Default::default() }, ); + save_profile_secret_plaintext( + &oauth_access_secret_key(&slot_key), + "cached-oauth-access-token", + ) + .expect("save cached OAuth token"); + slot_key + } - save_auth_store_to_path(&path, &store).expect("save"); - let loaded = load_auth_store_from_path(&path).expect("load"); + #[tokio::test] + async fn auth_precedence_keeps_env_api_key_below_oauth() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + let app_url = spawn_api_key_login_server("test-org"); + save_cached_oauth_login(&mut store, &app_url); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.org_name = Some("test-org".to_string()); + base.app_url = Some(app_url); + base.api_key = Some("environment-api-key".to_string()); + base.api_key_source = Some(crate::args::ArgValueSource::EnvVariable); - assert!(loaded.profiles.contains_key("work")); + let resolved = resolve_auth(&base).await.expect("resolve auth"); - let _ = fs::remove_dir_all(&dir); + assert!(resolved.is_oauth); + assert_eq!( + resolved.api_key.as_deref(), + Some("cached-oauth-access-token") + ); } - #[test] - fn resolve_auth_uses_profile_when_no_api_key_override() { + #[tokio::test] + async fn auth_precedence_cli_api_key_overrides_oauth() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + let app_url = spawn_api_key_login_server("test-org"); + save_cached_oauth_login(&mut store, &app_url); + save_auth_store(&store).expect("save auth store"); let mut base = make_base(); - base.profile = Some("work".to_string()); + base.org_name = Some("test-org".to_string()); + base.api_key = Some("command-line-api-key".to_string()); + base.api_key_source = Some(crate::args::ArgValueSource::CommandLine); + base.app_url = Some(app_url); - let mut store = AuthStore::default(); - store.profiles.insert( - "work".to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: Some("https://www.example.com".to_string()), - org_name: Some("Example Org".to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, - ..Default::default() - }, - ); + let resolved = resolve_auth(&base).await.expect("resolve auth"); - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.api_url.as_deref(), Some("https://api.example.com")); - assert_eq!(resolved.org_name.as_deref(), Some("Example Org")); assert!(!resolved.is_oauth); + assert_eq!(resolved.api_key.as_deref(), Some("command-line-api-key")); } - #[test] - fn config_auth_context_returns_profile_and_org_independently() { - let base = make_base(); - let cfg = auth_config(Some("default-profile"), Some("local-org")); + #[tokio::test] + async fn ad_hoc_api_key_validation_errors() { + for (app_url, expected) in [ + ( + spawn_api_key_login_server("different-org"), + "does not belong", + ), + ( + spawn_login_response_server("401 Unauthorized", "{}".into()), + "not valid", + ), + ] { + let mut base = make_base(); + base.org_name = Some("requested-org".into()); + base.app_url = Some(app_url); + assert!( + resolve_ad_hoc_api_key_auth(&base, &None, "selected-key".into()) + .await + .unwrap_err() + .to_string() + .contains(expected) + ); + } + } - let (profile, org) = config_auth_context_from_config(&base, &cfg); + #[test] + fn selected_stored_api_key_wrong_org_fails_before_secret_lookup() { + let mut store = AuthStore::default(); + store.profiles.insert( + "stored-slot".into(), + org_profile(AuthKind::ApiKey, "org_actual", "actual-org"), + ); + let mut base = make_base(); + base.org_name = Some("requested-org".into()); - assert_eq!(profile.as_deref(), Some("default-profile")); - assert_eq!(org.as_deref(), Some("local-org")); + let err = resolve_api_key_profile_auth(&base, &mut store, &None, "stored-slot") + .expect_err("wrong-org stored key must fail locally"); + assert!(err.to_string().contains("does not belong")); } - #[test] - fn config_auth_context_preserves_explicit_profile_and_config_org() { + #[tokio::test] + async fn auth_precedence_prefer_api_key_promotes_env_api_key() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + let app_url = spawn_api_key_login_server("test-org"); + save_cached_oauth_login(&mut store, &app_url); + save_auth_store(&store).expect("save auth store"); let mut base = make_base(); - base.profile = Some("explicit-profile".to_string()); - let cfg = auth_config(Some("config-profile"), Some("local-org")); + base.org_name = Some("test-org".to_string()); + base.api_key = Some("environment-api-key".to_string()); + base.api_key_source = Some(crate::args::ArgValueSource::EnvVariable); + base.prefer_api_key = true; + base.app_url = Some(app_url); - let (profile, org) = config_auth_context_from_config(&base, &cfg); + let resolved = resolve_auth(&base).await.expect("resolve auth"); - assert_eq!(profile, None); - assert_eq!(org.as_deref(), Some("local-org")); + assert!(!resolved.is_oauth); + assert_eq!(resolved.api_key.as_deref(), Some("environment-api-key")); } - #[test] - fn resolve_auth_prefers_explicit_api_key() { + #[tokio::test] + async fn auth_precedence_prefer_api_key_falls_back_to_oauth_without_key() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + let app_url = spawn_api_key_login_server("test-org"); + save_cached_oauth_login(&mut store, &app_url); + save_auth_store(&store).expect("save auth store"); let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.api_url = Some("https://override.example.com".to_string()); + base.org_name = Some("test-org".to_string()); + base.app_url = Some(app_url); + base.prefer_api_key = true; - let mut store = AuthStore::default(); - store.profiles.insert( - "work".to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: None, - org_name: None, - oauth_client_id: None, - oauth_access_expires_at: None, - ..Default::default() - }, - ); + let resolved = resolve_auth(&base).await.expect("resolve auth"); - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); + assert!(resolved.is_oauth); assert_eq!( - resolved.api_url.as_deref(), - Some("https://override.example.com") + resolved.api_key.as_deref(), + Some("cached-oauth-access-token") ); - assert!(!resolved.is_oauth); } - #[test] - fn resolve_auth_prefer_profile_ignores_api_key_override() { + #[tokio::test] + async fn active_auth_info_hides_ambiguous_api_keys_instead_of_failing_status() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + for (slot, hint) in [("key-a", "sk-****aaaaa"), ("key-b", "sk-****bbbbb")] { + store.profiles.insert( + slot.into(), + AuthProfile { + api_key_hint: Some(hint.into()), + ..org_profile(AuthKind::ApiKey, "org_test", "test-org") + }, + ); + } + save_auth_store(&store).expect("save auth store"); let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.prefer_profile = true; - base.profile = Some("work".to_string()); + base.org_name = Some("test-org".into()); + + assert!(active_auth_info(&base, Some("test-org")) + .expect("status auth lookup") + .is_none()); + } + #[tokio::test] + async fn active_auth_info_prefer_api_key_selects_stored_key_for_org() { + let _env = TestEnv::new(None, None).await; let mut store = AuthStore::default(); store.profiles.insert( - "work".to_string(), + oauth_slot_key(DEFAULT_APP_URL), AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: None, - org_name: Some("Example Org".to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, + auth_kind: AuthKind::Oauth, + app_url: Some(DEFAULT_APP_URL.to_string()), + user_name: Some("Test User".to_string()), + email: Some("user@example.test".to_string()), ..Default::default() }, ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("Example Org")); - } - - #[test] - fn resolve_auth_prefers_cli_api_key_even_with_prefer_profile() { - let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.api_key_source = Some(crate::args::ArgValueSource::CommandLine); - base.prefer_profile = true; - base.profile = Some("work".to_string()); - - let mut store = AuthStore::default(); store.profiles.insert( - "work".to_string(), + api_key_slot_key(&api_key_hash("test-api-key"), "org_fake"), AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: None, - org_name: Some("Example Org".to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, - ..Default::default() + api_key_hint: Some("sk-****abcde".to_string()), + ..org_profile(AuthKind::ApiKey, "org_fake", "test-org") }, ); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.prefer_api_key = true; - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); - assert_eq!(resolved.org_name, None); - } + let info = active_auth_info(&base, Some("test-org")) + .expect("resolve active auth") + .expect("active auth info"); - #[test] - fn resolve_auth_explicit_profile_ignores_env_api_key_override() { - let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.profile = Some("work".to_string()); - base.profile_explicit = true; + assert_eq!(info.auth_method, "api_key"); + assert_eq!(info.api_key_hint.as_deref(), Some("sk-****abcde")); + } + #[tokio::test] + async fn api_key_profile_rekeys_after_legacy_secret_load() { + let _env = TestEnv::new(None, None).await; let mut store = AuthStore::default(); store.profiles.insert( "work".to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: None, - org_name: Some("Example Org".to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, - ..Default::default() - }, + org_profile(AuthKind::ApiKey, "org_fake", "test-org"), ); - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("Example Org")); + maybe_rekey_api_key_profile_after_secret_load(&mut store, "work", "test-api-key") + .expect("rekey api key profile"); + + let key = api_key_slot_key(&api_key_hash("test-api-key"), "org_fake"); + let profile = store.profiles.get(&key).expect("rekeyed profile"); + assert!(!store.profiles.contains_key("work")); + assert_eq!( + profile.api_key_hash.as_deref(), + Some(api_key_hash("test-api-key").as_str()) + ); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); } #[test] - fn resolve_auth_marks_oauth_profiles() { - let mut base = make_base(); - base.profile = Some("work".to_string()); + fn save_and_load_auth_store_round_trip() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("bt-auth-store-test-{unique}")); + fs::create_dir_all(&dir).expect("create dir"); + let path = dir.join("auth.json"); let mut store = AuthStore::default(); store.profiles.insert( "work".to_string(), AuthProfile { - auth_kind: AuthKind::Oauth, + auth_kind: AuthKind::ApiKey, api_url: Some("https://api.example.com".to_string()), app_url: Some("https://www.example.com".to_string()), org_name: Some("Example Org".to_string()), - oauth_client_id: Some("bt_cli_work".to_string()), oauth_access_expires_at: None, ..Default::default() }, ); - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("should-not-be-used".to_string())), - &None, - ) - .expect("resolve"); - - assert!(resolved.is_oauth); - assert_eq!(resolved.api_key, None); - assert_eq!(resolved.org_name.as_deref(), Some("Example Org")); - } - - #[test] - fn refresh_profile_selector_prefers_explicit_profile() { - let mut base = make_base(); - base.profile = Some(" work ".to_string()); - let store = AuthStore::default(); - let (profile_name, source) = - resolve_selected_profile_name_for_debug(&base, &store).expect("resolve"); - assert_eq!(profile_name, "work"); - assert_eq!(source, "--profile/BRAINTRUST_PROFILE"); - } - - #[test] - fn parse_oauth_callback_input_accepts_json_payload() { - let parsed = - parse_oauth_callback_input(r#"{"code":"abc123","state":"state123","error":null}"#) - .expect("parse"); - assert_eq!(parsed.code.as_deref(), Some("abc123")); - assert_eq!(parsed.state.as_deref(), Some("state123")); - assert_eq!(parsed.error, None); - } + save_auth_store_to_path(&path, &store).expect("save"); + let loaded = load_auth_store_from_path(&path).expect("load"); - #[test] - fn parse_oauth_callback_input_accepts_fragment_payload() { - let parsed = parse_oauth_callback_input("#code=abc123&state=state123").expect("parse"); - assert_eq!(parsed.code.as_deref(), Some("abc123")); - assert_eq!(parsed.state.as_deref(), Some("state123")); - assert_eq!(parsed.error, None); - } + assert!(loaded.profiles.contains_key("work")); - #[test] - fn parse_oauth_callback_input_requires_code_or_error() { - let err = parse_oauth_callback_input("https://localhost/callback?state=only-state") - .expect_err("should fail"); - assert!( - err.to_string().contains("did not include code or error"), - "unexpected error: {err}" - ); + let _ = fs::remove_dir_all(&dir); } #[test] - fn resolve_profile_for_org_exact_profile_name() { + fn migrate_auth_store_rekeys_oauth_slots_and_preserves_legacy_secret_key() { let mut store = AuthStore::default(); store.profiles.insert( - "acme".into(), + "work".to_string(), AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() + email: Some("user@example.test".to_string()), + ..org_profile(AuthKind::Oauth, "org_fake", "test-org") }, ); - assert_eq!(resolve_profile_for_org("acme", &store), Some("acme")); - } - #[test] - fn resolve_profile_for_org_by_org_name() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() - }, - ); - assert_eq!(resolve_profile_for_org("acme-corp", &store), Some("work")); + let migrated = migrate_auth_store(store); + let key = oauth_slot_key(DEFAULT_APP_URL); + let profile = migrated.profiles.get(&key).expect("migrated profile"); + + assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); + assert_eq!(profile.org_id, None); + assert_eq!(profile.org_name, None); } #[test] - fn resolve_profile_for_org_no_match() { + fn migrate_auth_store_purges_old_oauth_org_scope() { let mut store = AuthStore::default(); store.profiles.insert( - "work".into(), + "legacy-login".to_string(), AuthProfile { - org_name: Some("acme-corp".into()), + auth_kind: AuthKind::Oauth, + org_id: Some("org_old".to_string()), + org_name: Some("old-org".to_string()), + email: Some("user@example.test".to_string()), ..Default::default() }, ); - assert_eq!(resolve_profile_for_org("unknown", &store), None); + + let migrated = migrate_auth_store(store); + let profile = migrated + .profiles + .get(&oauth_slot_key(DEFAULT_APP_URL)) + .expect("instance OAuth slot"); + + assert_eq!(profile.org_id, None); + assert_eq!(profile.org_name, None); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("legacy-login")); } #[test] - fn resolve_profile_for_org_multiple_returns_none() { + fn migrate_auth_store_rekeys_api_key_slots_by_hash_and_org() { + let hash = api_key_hash("test-api-key"); let mut store = AuthStore::default(); store.profiles.insert( - "work-1".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "work-2".into(), + "work".to_string(), AuthProfile { - org_name: Some("acme".into()), + auth_kind: AuthKind::ApiKey, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + api_key_hash: Some(hash.clone()), + api_key_hint: Some("test-****i-key".to_string()), ..Default::default() }, ); - assert_eq!(resolve_profile_for_org("acme", &store), None); + + let migrated = migrate_auth_store(store); + let key = api_key_slot_key(&hash, "org_fake"); + let profile = migrated.profiles.get(&key).expect("migrated profile"); + + assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); + assert_eq!(profile.api_key_hint.as_deref(), Some("test-****i-key")); } #[test] - fn profile_selection_requires_choice_when_multiple_profiles_without_prompt() { - let base = make_base(); + fn load_auth_store_persists_canonical_slot_migration() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("bt-auth-migration-test-{unique}")); + fs::create_dir_all(&dir).expect("create dir"); + let path = dir.join("auth.json"); + let mut store = AuthStore::default(); store.profiles.insert( - "alpha".into(), - AuthProfile { - org_name: Some("alpha-org".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "beta".into(), + "legacy-login".to_string(), AuthProfile { - org_name: Some("beta-org".into()), + auth_kind: AuthKind::Oauth, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + email: Some("user@example.test".to_string()), ..Default::default() }, ); + save_auth_store_to_path(&path, &store).expect("save legacy store"); - let err = maybe_select_profile_for_auth(&base, &store, &None, false) - .expect_err("selection should be required"); + let loaded = load_auth_store_from_path(&path).expect("load and migrate"); + let persisted: AuthStore = + serde_json::from_str(&fs::read_to_string(&path).expect("read migrated store")) + .expect("parse migrated store"); + let slot_key = oauth_slot_key(DEFAULT_APP_URL); - assert!(err.to_string().contains("multiple auth profiles available")); - assert!(err.to_string().contains("alpha")); - assert!(err.to_string().contains("beta")); - assert!(err.to_string().contains("--profile ")); - } + for migrated in [&loaded, &persisted] { + let profile = migrated + .profiles + .get(&slot_key) + .expect("canonical OAuth slot"); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("legacy-login")); + } - #[test] - fn profile_selection_requires_choice_for_ambiguous_org_without_prompt() { - let mut base = make_base(); - base.org_name = Some("acme".into()); + let _ = fs::remove_dir_all(&dir); + } + #[tokio::test] + async fn verified_legacy_slots_gain_stable_keys() { + let _env = TestEnv::new(None, None).await; let mut store = AuthStore::default(); store.profiles.insert( - "work-1".into(), + "legacy-oauth".to_string(), AuthProfile { - org_name: Some("acme".into()), + auth_kind: AuthKind::Oauth, + org_name: Some("test-org".to_string()), ..Default::default() }, ); store.profiles.insert( - "work-2".into(), + "legacy-api-key".to_string(), AuthProfile { - org_name: Some("acme".into()), + auth_kind: AuthKind::ApiKey, + org_name: Some("test-org".to_string()), + api_key_hint: Some("sk-****abcde".to_string()), ..Default::default() }, ); + let hash = api_key_hash("test-api-key"); + let verifications = vec![ + ProfileVerification { + name: "legacy-oauth".to_string(), + slot_hash: None, + auth: "oauth".to_string(), + org: Some("test-org".to_string()), + org_id: Some("org_fake".to_string()), + user_name: Some("Test User".to_string()), + user_email: Some("user@example.test".to_string()), + api_key_hint: None, + app_url: Some(DEFAULT_APP_URL.to_string()), + api_url: None, + status: "ok".to_string(), + error: None, + }, + ProfileVerification { + name: "legacy-api-key".to_string(), + slot_hash: Some(hash.clone()), + auth: "api_key".to_string(), + org: Some("test-org".to_string()), + org_id: Some("org_fake".to_string()), + user_name: None, + user_email: None, + api_key_hint: Some("sk-****abcde".to_string()), + app_url: None, + api_url: None, + status: "ok".to_string(), + error: None, + }, + ]; - let err = maybe_select_profile_for_auth(&base, &store, &None, false) - .expect_err("org selection should be required"); + reconcile_verified_auth_slots(&mut store, &verifications) + .expect("reconcile verified slots"); - assert!(err.to_string().contains("multiple profiles for org 'acme'")); - assert!(err.to_string().contains("work-1")); - assert!(err.to_string().contains("work-2")); + let oauth = store + .profiles + .get(&oauth_slot_key(DEFAULT_APP_URL)) + .expect("canonical OAuth slot"); + assert_eq!(oauth.legacy_secret_key.as_deref(), Some("legacy-oauth")); + let api_key = store + .profiles + .get(&api_key_slot_key(&hash, "org_fake")) + .expect("canonical API-key slot"); + assert_eq!(api_key.legacy_secret_key.as_deref(), Some("legacy-api-key")); } #[test] - fn profile_selection_skips_when_api_key_override_is_active() { - let mut base = make_base(); - base.api_key = Some("explicit-key".into()); - + fn migrate_auth_store_dedupes_oauth_slots_by_latest_expiry() { let mut store = AuthStore::default(); - store - .profiles - .insert("alpha".into(), AuthProfile::default()); - store.profiles.insert("beta".into(), AuthProfile::default()); - - let selection = maybe_select_profile_for_auth(&base, &store, &None, false) - .expect("api key override should skip profile selection"); + for (name, expires_at) in [("old", 10), ("new", 20)] { + store.profiles.insert( + name.to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + email: Some("user@example.test".to_string()), + oauth_access_expires_at: Some(expires_at), + ..Default::default() + }, + ); + } - assert_eq!(selection, None); + let migrated = migrate_auth_store(store); + let key = oauth_slot_key(DEFAULT_APP_URL); + assert_eq!(migrated.profiles.len(), 1); + let profile = migrated.profiles.get(&key).expect("migrated profile"); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("new")); } - #[test] - fn resolve_auth_uses_org_to_find_profile() { - let mut base = make_base(); - base.org_name = Some("acme-corp".into()); - + #[tokio::test] + async fn migrate_auth_store_prefers_loadable_refresh_token_before_expiry() { + let _env = TestEnv::new(None, None).await; let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - api_url: Some("https://api.acme.com".into()), - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".into())), - &None, + for (name, expires_at) in [("usable-old", 10), ("missing-new", 20)] { + store.profiles.insert( + name.to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + oauth_access_expires_at: Some(expires_at), + ..Default::default() + }, + ); + } + save_profile_secret_plaintext( + &oauth_refresh_secret_key("usable-old"), + "test-refresh-token", ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("acme-corp")); + .expect("save refresh token"); + + let migrated = migrate_auth_store(store); + let profile = migrated + .profiles + .get(&oauth_slot_key(DEFAULT_APP_URL)) + .expect("migrated OAuth profile"); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("usable-old")); } #[test] - fn resolve_auth_uses_config_org_to_find_profile() { - let base = make_base(); - + fn migration_reports_dropped_duplicate_secret_as_orphan() { + // Two legacy OAuth entries for the same org+email collapse onto one + // canonical slot. The survivor's secret stays reachable (via its + // legacy_secret_key), while the dropped duplicate's key must be reported + // as an orphan so its keychain secret can be deleted. let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - api_url: Some("https://api.acme.com".into()), - ..Default::default() - }, - ); - let cfg_org = Some("acme-corp".to_string()); + for (name, expires_at) in [("old", 10), ("new", 20)] { + store.profiles.insert( + name.to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + email: Some("user@example.test".to_string()), + oauth_access_expires_at: Some(expires_at), + ..Default::default() + }, + ); + } - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".into())), - &cfg_org, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("acme-corp")); + let migrated = migrate_auth_store(store.clone()); + let orphans = orphaned_migration_secret_keys(&store, &migrated); + + assert_eq!(orphans, vec![("old", AuthKind::Oauth)]); + // The survivor "new" is referenced through the canonical slot's + // legacy_secret_key and must never be pruned. + assert!(!orphans.iter().any(|(key, _)| *key == "new")); } #[test] - fn resolve_auth_config_org_overrides_profile_org() { - let mut base = make_base(); - base.profile = Some("default-profile".to_string()); - + fn migration_without_collapse_reports_no_orphans() { + // A single entry that merely gets rekeyed keeps its secret under the + // legacy key, so nothing is orphaned. let mut store = AuthStore::default(); store.profiles.insert( - "default-profile".into(), + "test-org".to_string(), AuthProfile { - org_name: Some("profile-org".into()), + auth_kind: AuthKind::Oauth, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + email: Some("user@example.test".to_string()), ..Default::default() }, ); - let cfg_org = Some("local-org".to_string()); - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".into())), - &cfg_org, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("local-org")); + let migrated = migrate_auth_store(store.clone()); + assert!(orphaned_migration_secret_keys(&store, &migrated).is_empty()); } #[test] - fn resolve_auth_api_key_override_keeps_config_org() { - let mut base = make_base(); - base.api_key = Some("explicit-key".into()); + fn config_auth_context_returns_config_org() { + let base = make_base(); + let cfg = auth_config(Some("local-org")); - let store = AuthStore::default(); - let cfg_org = Some("local-org".to_string()); + let org = config_auth_context_from_config(&base, &cfg); - let resolved = - resolve_auth_from_store_with_secret_lookup(&base, &store, |_| Ok(None), &cfg_org) - .expect("resolve"); + assert_eq!(org.as_deref(), Some("local-org")); - assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); - assert_eq!(resolved.org_name.as_deref(), Some("local-org")); + let other_instance = BaseArgs { + app_url: Some("https://other.example.test".into()), + ..base + }; + assert_eq!(config_auth_context_from_config(&other_instance, &cfg), None); } #[test] - fn resolve_auth_explicit_profile_overrides_org_resolution() { - let mut base = make_base(); - base.profile = Some("other".into()); - base.org_name = Some("acme-corp".into()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "other".into(), - AuthProfile { - org_name: Some("other-org".into()), - api_url: Some("https://api.other.com".into()), - ..Default::default() - }, - ); + fn parse_oauth_callback_input_accepts_json_payload() { + let parsed = + parse_oauth_callback_input(r#"{"code":"abc123","state":"state123","error":null}"#) + .expect("parse"); + assert_eq!(parsed.code.as_deref(), Some("abc123")); + assert_eq!(parsed.state.as_deref(), Some("state123")); + assert_eq!(parsed.error, None); + } - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("other-key".into())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("other-key")); - assert_eq!(resolved.org_name.as_deref(), Some("acme-corp")); + #[test] + fn parse_oauth_callback_input_accepts_fragment_payload() { + let parsed = parse_oauth_callback_input("#code=abc123&state=state123").expect("parse"); + assert_eq!(parsed.code.as_deref(), Some("abc123")); + assert_eq!(parsed.state.as_deref(), Some("state123")); + assert_eq!(parsed.error, None); } #[test] - fn resolve_api_key_login_profile_name_creates_new_profile_for_matching_org() { - let mut store = AuthStore::default(); - store.profiles.insert( - "acme".into(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.acme.example".into()), - org_name: Some("acme".into()), - ..Default::default() - }, + fn parse_oauth_callback_input_requires_code_or_error() { + let err = parse_oauth_callback_input("https://localhost/callback?state=only-state") + .expect_err("should fail"); + assert!( + err.to_string().contains("did not include code or error"), + "unexpected error: {err}" ); + } - let (profile_name, should_confirm) = resolve_api_key_login_profile_name( - None, - Some("acme"), - "https://api.acme.example", - &store, - ) - .expect("resolve"); + #[test] + fn move_default_login_org_first_moves_matching_org() { + let mut orgs = vec![ + login_org("org_1", "acme"), + login_org("org_2", "beta"), + login_org("org_3", "gamma"), + ]; - assert_eq!(profile_name, "acme-2"); - assert!(!should_confirm); + assert!(move_default_login_org_first(&mut orgs, Some("beta"))); + assert_eq!(orgs[0].name, "beta"); + assert_eq!(orgs[1].name, "acme"); } #[test] - fn resolve_api_key_login_profile_name_updates_explicit_matching_profile_without_confirm() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.test.example".into()), - org_name: Some("test-org".into()), - ..Default::default() - }, - ); - - let (profile_name, should_confirm) = resolve_api_key_login_profile_name( - Some("work"), - Some("test-org"), - "https://api.test.example", - &store, - ) - .expect("resolve"); + fn move_default_login_org_first_keeps_order_without_match() { + let mut orgs = vec![login_org("org_1", "acme"), login_org("org_2", "beta")]; - assert_eq!(profile_name, "work"); - assert!(!should_confirm); + assert!(!move_default_login_org_first(&mut orgs, Some("missing"))); + assert_eq!(orgs[0].name, "acme"); + assert_eq!(orgs[1].name, "beta"); } - #[test] - fn resolve_api_key_login_profile_name_confirms_explicit_different_target() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.test.example".into()), - org_name: Some("test-org".into()), - ..Default::default() - }, - ); + fn login_org(id: &str, name: &str) -> LoginOrgInfo { + LoginOrgInfo { + id: id.to_string(), + name: name.to_string(), + api_url: None, + } + } - let (profile_name, should_confirm) = resolve_api_key_login_profile_name( - Some("work"), - Some("other-org"), - "https://api.test.example", - &store, + fn auth_source( + prefer_api_key: bool, + cli: Option<&str>, + env: Option<&str>, + oauth: Option<&str>, + api_key: Option<&str>, + ) -> AuthSource { + resolve_auth_source( + prefer_api_key, + cli.map(str::to_string), + || env.map(str::to_string), + || Ok(oauth.map(str::to_string)), + || Ok(api_key.map(str::to_string)), ) - .expect("resolve"); - - assert_eq!(profile_name, "work"); - assert!(should_confirm); + .expect("resolve auth source") } #[test] - fn default_login_org_name_uses_profile_org_when_org_not_requested() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, + fn auth_source_cli_api_key_wins_over_everything() { + assert_eq!( + auth_source(false, Some("cli"), Some("env"), Some("oauth"), Some("ak")), + AuthSource::CliApiKey("cli".into()) ); - assert_eq!( - default_login_org_name(&store, Some(" work "), None).as_deref(), - Some("acme") + auth_source(true, Some("cli"), Some("env"), Some("oauth"), Some("ak")), + AuthSource::CliApiKey("cli".into()) ); } #[test] - fn default_login_org_name_falls_back_to_profile_name() { - let store = AuthStore::default(); - + fn auth_source_default_order_is_oauth_then_env_then_stored_api_key() { + assert_eq!( + auth_source(false, None, Some("env"), Some("oauth"), Some("ak")), + AuthSource::Oauth("oauth".into()) + ); + assert_eq!( + auth_source(false, None, Some("env"), None, Some("ak")), + AuthSource::EnvApiKey("env".into()) + ); assert_eq!( - default_login_org_name(&store, Some(" acme "), None).as_deref(), - Some("acme") + auth_source(false, None, None, None, Some("ak")), + AuthSource::ApiKey("ak".into()) ); + assert_eq!(auth_source(false, None, None, None, None), AuthSource::None); } #[test] - fn default_login_org_name_ignores_profile_when_org_requested() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, + fn auth_source_prefer_api_key_order_is_env_then_api_key_then_oauth() { + assert_eq!( + auth_source(true, None, Some("env"), Some("oauth"), Some("ak")), + AuthSource::EnvApiKey("env".into()) + ); + assert_eq!( + auth_source(true, None, None, Some("oauth"), Some("ak")), + AuthSource::ApiKey("ak".into()) ); - + // No env/stored API key, but OAuth for the org is available: fall back to it. assert_eq!( - default_login_org_name(&store, Some("work"), Some("other")), - None + auth_source(true, None, None, Some("oauth"), None), + AuthSource::Oauth("oauth".into()) ); + assert_eq!(auth_source(true, None, None, None, None), AuthSource::None); } #[test] - fn move_default_login_org_first_moves_matching_org() { - let mut orgs = vec![ - login_org("org_1", "acme"), - login_org("org_2", "beta"), - login_org("org_3", "gamma"), - ]; - - assert!(move_default_login_org_first(&mut orgs, Some("beta"))); - assert_eq!(orgs[0].name, "beta"); - assert_eq!(orgs[1].name, "acme"); + fn auth_source_ambiguous_selection_stops_the_ladder() { + let err = resolve_auth_source( + false, + None, + || None, + || bail!("multiple oauth logins"), + || Ok(Some("ak".to_string())), + ) + .expect_err("ambiguous oauth should stop the ladder"); + assert!(err.to_string().contains("multiple oauth logins")); } - #[test] - fn move_default_login_org_first_keeps_order_without_match() { - let mut orgs = vec![login_org("org_1", "acme"), login_org("org_2", "beta")]; + #[tokio::test] + async fn available_instances_dedupes_logins_by_app_url() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + for (slot, kind, app_url) in [ + ("oauth-a", AuthKind::Oauth, "https://one.example.test/"), + ("key-a", AuthKind::ApiKey, "https://one.example.test"), + ("key-b", AuthKind::ApiKey, "https://two.example.test"), + ] { + store.profiles.insert( + slot.into(), + AuthProfile { + auth_kind: kind, + app_url: Some(app_url.into()), + org_id: (kind == AuthKind::ApiKey).then(|| format!("org_{slot}")), + org_name: (kind == AuthKind::ApiKey).then(|| format!("org-{slot}")), + ..Default::default() + }, + ); + } + save_auth_store(&store).expect("save auth store"); - assert!(!move_default_login_org_first(&mut orgs, Some("missing"))); - assert_eq!(orgs[0].name, "acme"); - assert_eq!(orgs[1].name, "beta"); + let instances = available_instances(&BaseArgs::default()).expect("list instances"); + assert_eq!( + instances + .iter() + .map(|instance| instance.app_url.as_str()) + .collect::>(), + ["https://one.example.test", "https://two.example.test"] + ); + + let filtered = available_instances(&BaseArgs { + app_url: Some("https://two.example.test/".into()), + app_url_source: Some(crate::args::ArgValueSource::CommandLine), + ..Default::default() + }) + .expect("filter instances"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].app_url, "https://two.example.test"); } - #[test] - fn resolve_oauth_login_profile_name_reuses_most_recent_matching_profile() { + #[tokio::test] + async fn org_filter_includes_oauth_login_when_discovered_membership_matches() { + let _env = TestEnv::new(None, None).await; + let app_url = spawn_api_key_login_server("test-org"); let mut store = AuthStore::default(); + let oauth_slot = save_cached_oauth_login(&mut store, &app_url); store.profiles.insert( - "older".into(), + "other-key".into(), AuthProfile { - auth_kind: AuthKind::Oauth, - api_url: Some("https://api.acme.example".into()), - app_url: Some("https://www.acme.example".into()), - org_name: Some("acme".into()), - oauth_access_expires_at: Some(100), - user_name: Some("Alice".into()), - email: Some("alice@example.com".into()), - ..Default::default() + app_url: Some(app_url.clone()), + api_url: Some("https://api.example.test".into()), + ..org_profile(AuthKind::ApiKey, "org_other", "other-org") }, ); - store.profiles.insert( - "newer".into(), - AuthProfile { - auth_kind: AuthKind::Oauth, - api_url: Some("https://api.acme.example".into()), - app_url: Some("https://www.acme.example".into()), - org_name: Some("acme".into()), - oauth_access_expires_at: Some(200), - user_name: Some("Alice".into()), - email: Some("alice@example.com".into()), - ..Default::default() - }, + save_auth_store(&store).expect("save auth store"); + let base = BaseArgs::default(); + let candidates = filter_auth_store(&base, &store, None, None); + let filtered = filter_auth_store_for_org(&base, &mut store, candidates, Some("test-org")) + .await + .expect("filter by org"); + assert_eq!( + filtered.profiles.into_keys().collect::>(), + [oauth_slot] ); + } - let jwt_id = JwtIdentity { - name: Some("Alice".into()), - email: Some("alice@example.com".into()), + #[test] + fn command_auth_uses_builtin_url_defaults_when_urls_are_unset() { + let default_oauth = AuthProfile { + auth_kind: AuthKind::Oauth, + app_url: Some(DEFAULT_APP_URL.into()), + ..Default::default() }; - let (profile_name, should_confirm) = resolve_oauth_login_profile_name( - None, - Some("acme"), - "https://api.acme.example", - "https://www.acme.example", - &jwt_id, - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "newer"); - assert!(!should_confirm); + let custom_oauth = AuthProfile { + auth_kind: AuthKind::Oauth, + app_url: Some("https://www.example.test".into()), + ..Default::default() + }; + assert!(profile_matches_urls(&BaseArgs::default(), &default_oauth)); + assert!(!profile_matches_urls(&BaseArgs::default(), &custom_oauth)); + assert!(profile_matches_url_filters( + &BaseArgs::default(), + &custom_oauth + )); } #[test] - fn resolve_oauth_login_profile_name_updates_explicit_matching_profile_without_confirm() { + fn login_filter_matches_instance_and_auth_kind() { let mut store = AuthStore::default(); store.profiles.insert( - "work".into(), + "oauth".into(), AuthProfile { auth_kind: AuthKind::Oauth, - api_url: Some("https://api.test.example".into()), - app_url: Some("https://app.test.example".into()), - org_name: Some("test-org".into()), - user_name: Some("Test User".into()), - email: Some("user@test.example".into()), + app_url: Some("https://www.example.test".into()), ..Default::default() }, ); - let jwt_id = JwtIdentity { - name: Some("Test User".into()), - email: Some("user@test.example".into()), - }; - - let (profile_name, should_confirm) = resolve_oauth_login_profile_name( - Some("work"), - Some("test-org"), - "https://api.test.example", - "https://app.test.example", - &jwt_id, - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "work"); - assert!(!should_confirm); - } - - #[test] - fn resolve_oauth_login_profile_name_confirms_explicit_different_target() { - let mut store = AuthStore::default(); store.profiles.insert( - "work".into(), + "key".into(), AuthProfile { - auth_kind: AuthKind::Oauth, - api_url: Some("https://api.test.example".into()), - app_url: Some("https://app.test.example".into()), - org_name: Some("test-org".into()), - user_name: Some("Test User".into()), - email: Some("user@test.example".into()), - ..Default::default() + app_url: Some("https://www.example.test".into()), + api_url: Some("https://api.example.test".into()), + api_key_hint: Some("sk-****abcde".into()), + ..org_profile(AuthKind::ApiKey, "org_test", "test-org") }, ); - let jwt_id = JwtIdentity { - name: Some("Test User".into()), - email: Some("user@test.example".into()), + let base = BaseArgs { + app_url: Some("https://www.example.test/".into()), + api_url: Some("https://api.example.test/".into()), + ..Default::default() }; + let filtered = filter_auth_store(&base, &store, None, None); + assert_eq!(filtered.profiles.len(), 2); + let filtered = filter_auth_store(&base, &store, Some(AuthKind::ApiKey), None); + assert_eq!(filtered.profiles.into_keys().collect::>(), ["key"]); - let (profile_name, should_confirm) = resolve_oauth_login_profile_name( - Some("work"), - Some("other-org"), - "https://api.test.example", - "https://app.test.example", - &jwt_id, - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "work"); - assert!(should_confirm); - } - - fn login_org(id: &str, name: &str) -> LoginOrgInfo { - LoginOrgInfo { - id: id.to_string(), - name: name.to_string(), - api_url: None, - } + let mismatched_api = BaseArgs { + app_url: base.app_url.clone(), + api_url: Some("https://other-api.example.test".into()), + ..Default::default() + }; + let filtered = filter_auth_store(&mismatched_api, &store, None, None); + assert_eq!(filtered.profiles.into_keys().collect::>(), ["oauth"]); } #[tokio::test] - async fn persist_post_login_context_clears_stale_project_for_org_only_login() { + async fn post_login_context_preserves_only_same_org_projects() { let _env = TestEnv::new(None, None).await; - crate::config::save_global(&crate::config::Config { - profile: Some("old-profile".to_string()), - org: Some("old-org".to_string()), - project: Some("stale-project".to_string()), - project_id: Some("proj_stale".to_string()), - ..Default::default() - }) - .expect("save initial config"); + let save = |org: &str, org_id: &str| { + crate::config::save_global(&crate::config::Config { + org: Some(org.into()), + org_id: Some(org_id.into()), + project: Some("test-project".into()), + project_id: Some("proj_test".into()), + app_url: Some("https://www.example.test".into()), + api_url: Some("https://api.example.test".into()), + ..Default::default() + }) + .unwrap(); + }; + let persist = |org: Option| async move { + persist_post_login_context( + &make_base(), + "test-credential", + "https://api.example.test", + "https://www.example.test", + org.as_ref(), + &config::ScopeArgs { + global: true, + local: false, + }, + ) + .await + .unwrap(); + crate::config::load_global().unwrap() + }; - let update = persist_post_login_context( - &make_base(), - "work", - "test-api-key", - "https://api.example.test", - "https://www.example.test", - Some(&login_org("org_123", "acme")), - ) - .await - .expect("persist context"); - let cfg = crate::config::load_global().expect("load global config"); + save("old-org", "org_old"); + let cfg = persist(Some(login_org("org_test", "test-org"))).await; + assert_eq!((cfg.org.as_deref(), cfg.project), (Some("test-org"), None)); - assert_eq!(update.display, "acme"); - assert_eq!(cfg.profile.as_deref(), Some("work")); - assert_eq!(cfg.org.as_deref(), Some("acme")); - assert_eq!(cfg.project, None); - assert_eq!(cfg.project_id, None); + save("test-org", "org_test"); + let cfg = persist(Some(login_org("org_test", "test-org"))).await; + assert_eq!( + (cfg.project.as_deref(), cfg.project_id.as_deref()), + (Some("test-project"), Some("proj_test")) + ); } #[tokio::test] - async fn resolve_post_login_project_rejects_cross_org_default_project() { + async fn resolve_post_login_project_requires_an_org() { let mut base = make_base(); base.project = Some("demo-project".to_string()); @@ -4976,194 +5816,243 @@ mod tests { None, ) .await - .expect_err("cross-org project selection should fail"); - - assert!(err - .to_string() - .contains("cannot set a default project in cross-org mode")); - } - - #[test] - fn resolve_requested_org_for_api_key_login_keeps_matching_requested_org() { - let orgs = vec![login_org("org_1", "acme")]; - - let resolution = - resolve_requested_org_for_api_key_login(&orgs, Some("acme"), false, |_, _| { - panic!("prompt should not be called") - }) - .expect("resolve"); - - assert_eq!(resolution, RequestedOrgResolution::UseRequestedOrg); - } - - #[test] - fn resolve_requested_org_for_api_key_login_errors_without_prompt() { - let orgs = vec![login_org("org_1", "braintrustdata.com")]; + .expect_err("missing org should fail"); - let err = - resolve_requested_org_for_api_key_login(&orgs, Some("ced-test-1"), false, |_, _| { - panic!("prompt should not be called") - }) - .expect_err("should fail"); - - assert!(err - .to_string() - .contains("org 'ced-test-1' not found. Available: braintrustdata.com")); - } - - #[test] - fn resolve_requested_org_for_api_key_login_can_switch_to_oauth() { - let orgs = vec![login_org("org_1", "braintrustdata.com")]; - - let resolution = resolve_requested_org_for_api_key_login( - &orgs, - Some("ced-test-1"), - true, - |requested_org_name, available_orgs| { - assert_eq!(requested_org_name, "ced-test-1"); - assert_eq!(available_orgs.len(), 1); - Ok(ApiKeyOrgMismatchAction::UseOauth) - }, - ) - .expect("resolve"); - - assert_eq!(resolution, RequestedOrgResolution::SwitchToOauth); + assert!(err.to_string().contains("organization is required")); } #[test] - fn resolve_requested_org_for_api_key_login_can_continue_with_api_key() { - let orgs = vec![login_org("org_1", "braintrustdata.com")]; - - let resolution = resolve_requested_org_for_api_key_login( + fn requested_api_key_org_resolution() { + fn no_prompt(_: &str, _: &[LoginOrgInfo]) -> Result { + panic!("prompt should not be called") + } + let orgs = vec![login_org("org_test", "test-org")]; + assert_eq!( + resolve_requested_org_for_api_key_login(&orgs, Some("test-org"), false, no_prompt) + .unwrap(), + RequestedOrgResolution::UseRequestedOrg + ); + assert!(resolve_requested_org_for_api_key_login( &orgs, - Some("ced-test-1"), - true, - |requested_org_name, available_orgs| { - assert_eq!(requested_org_name, "ced-test-1"); - assert_eq!(available_orgs.len(), 1); - Ok(ApiKeyOrgMismatchAction::UseApiKey) - }, + Some("other-org"), + false, + no_prompt ) - .expect("resolve"); - - assert_eq!(resolution, RequestedOrgResolution::IgnoreRequestedOrg); - } - - #[test] - fn obscure_api_key_standard() { - assert_eq!(obscure_api_key("sk-LumEdp0BbLRzhJwO"), "sk-****zhJwO"); - } - - #[test] - fn obscure_api_key_short() { - assert_eq!(obscure_api_key("abc"), "****"); - } - - #[test] - fn obscure_api_key_no_dash() { - assert_eq!(obscure_api_key("abcdefghijklm"), "****ijklm"); - } - - #[test] - fn obscure_api_key_non_ascii() { - assert_eq!(obscure_api_key("sk-café-résumé-key"), "****"); - } + .unwrap_err() + .to_string() + .contains("org 'other-org' not found. Available: test-org")); - #[test] - fn decode_jwt_identity_extracts_claims() { - let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#); - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(r#"{"name":"Alice","email":"alice@example.com"}"#); - let token = format!("{header}.{payload}.sig"); - let id = decode_jwt_identity(&token); - assert_eq!(id.name.as_deref(), Some("Alice")); - assert_eq!(id.email.as_deref(), Some("alice@example.com")); + for (action, expected) in [ + ( + ApiKeyOrgMismatchAction::UseOauth, + RequestedOrgResolution::SwitchToOauth, + ), + ( + ApiKeyOrgMismatchAction::UseApiKey, + RequestedOrgResolution::IgnoreRequestedOrg, + ), + ] { + let actual = resolve_requested_org_for_api_key_login( + &orgs, + Some("other-org"), + true, + |requested, available| { + assert_eq!(requested, "other-org"); + assert_eq!(available.len(), 1); + Ok(action) + }, + ) + .unwrap(); + assert_eq!(actual, expected); + } } #[test] - fn decode_jwt_identity_handles_missing_claims() { - let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#); - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"sub":"123"}"#); - let token = format!("{header}.{payload}.sig"); - let id = decode_jwt_identity(&token); - assert_eq!(id.name, None); - assert_eq!(id.email, None); + fn obscure_api_keys() { + for (key, expected) in [ + ("sk-LumEdp0BbLRzhJwO", "sk-****zhJwO"), + ("abc", "****"), + ("abcdefghijklm", "****ijklm"), + // Late first dash leaves no maskable middle: fully mask rather than + // reveal the whole key (would otherwise be "abcdefg-****g-hij"). + ("abcdefg-hij", "****"), + ("sk-café-résumé-key", "****"), + ] { + assert_eq!(obscure_api_key(key), expected); + } } #[test] - fn decode_jwt_identity_handles_garbage() { + fn decode_jwt_identity_handles_claims_and_invalid_tokens() { + let encode = |payload| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload); + let header = encode(r#"{"alg":"RS256"}"#); + for (payload, expected) in [ + ( + r#"{"name":"Test User","email":"user@example.test"}"#, + (Some("Test User"), Some("user@example.test")), + ), + (r#"{"sub":"123"}"#, (None, None)), + ] { + let id = decode_jwt_identity(&format!("{header}.{}.sig", encode(payload))); + assert_eq!((id.name.as_deref(), id.email.as_deref()), expected); + } let id = decode_jwt_identity("not-a-jwt"); - assert_eq!(id.name, None); - assert_eq!(id.email, None); + assert_eq!((id.name, id.email), (None, None)); } #[test] - fn format_verification_line_ok_with_identity() { - let v = ProfileVerification { - name: "work".into(), + fn auth_logins_are_grouped_by_org() { + let verification = |name: &str, org: Option<&str>| ProfileVerification { + name: name.into(), + slot_hash: None, auth: "oauth".into(), - org: Some("acme".into()), - user_name: Some("Alice".into()), - user_email: Some("alice@example.com".into()), + org: org.map(str::to_string), + org_id: None, + user_name: None, + user_email: None, api_key_hint: None, + app_url: Some(DEFAULT_APP_URL.to_string()), + api_url: None, status: "ok".into(), error: None, }; + let mut verifications = vec![ + verification("profile-z", Some("test-org-a")), + verification("profile-a", Some("test-org-b")), + verification("profile-m", Some("test-org-a")), + verification("profile-x", None), + ]; + + sort_profile_verifications(&mut verifications); + + let order = verifications + .iter() + .map(|v| (v.org.as_deref(), v.name.as_str())) + .collect::>(); assert_eq!( - format_verification_line(&v), - "work — oauth — org: acme — Alice (alice@example.com)" + order, + vec![ + (None, "profile-x"), + (Some("test-org-a"), "profile-m"), + (Some("test-org-a"), "profile-z"), + (Some("test-org-b"), "profile-a"), + ] ); } #[test] - fn format_verification_line_ok_with_api_key_hint() { - let v = ProfileVerification { - name: "work".into(), - auth: "api_key".into(), - org: Some("acme".into()), - user_name: None, - user_email: None, - api_key_hint: Some("sk-****zhJwO".into()), - status: "ok".into(), - error: None, - }; + fn saved_auth_logins_are_grouped_by_org() { + let mut store = AuthStore::default(); + for (name, org) in [ + ("profile-z", "test-org-a"), + ("profile-a", "test-org-b"), + ("profile-m", "test-org-a"), + ] { + store.profiles.insert( + name.into(), + AuthProfile { + org_name: Some(org.into()), + ..Default::default() + }, + ); + } + + let order = profiles_grouped_by_org(&store) + .into_iter() + .map(|(name, profile)| (profile_org(profile), name)) + .collect::>(); assert_eq!( - format_verification_line(&v), - "work — api_key — org: acme — sk-****zhJwO" + order, + vec![ + ("test-org-a", "profile-m"), + ("test-org-a", "profile-z"), + ("test-org-b", "profile-a"), + ] ); } #[test] - fn format_verification_line_expired() { - let v = ProfileVerification { - name: "old".into(), - auth: "oauth".into(), - org: None, - user_name: None, - user_email: None, - api_key_hint: None, - status: "expired".into(), - error: None, - }; - assert_eq!(format_verification_line(&v), "old — oauth — token expired"); - } + fn verification_line_formatting() { + for (name, email, hint, expected) in [ + ( + Some("Test User"), + Some("user@example.test"), + None, + Some("Test User (user@example.test)"), + ), + ( + None, + Some("user@example.test"), + None, + Some("user@example.test"), + ), + (None, None, Some("sk-****abcde"), Some("sk-****abcde")), + (None, None, None, None), + ] { + assert_eq!(identity_label(name, email, hint).as_deref(), expected); + } - #[test] - fn format_verification_line_error() { - let v = ProfileVerification { - name: "bad".into(), - auth: "api_key".into(), - org: Some("corp".into()), - user_name: None, - user_email: None, - api_key_hint: None, - status: "error".into(), - error: Some("invalid API key".into()), + let verification = |auth: &str, + org: Option<&str>, + identity: Option<&str>, + hint: Option<&str>, + status: &str, + error: Option<&str>| ProfileVerification { + name: "test-profile".into(), + slot_hash: None, + auth: auth.into(), + org: org.map(str::to_string), + org_id: None, + user_name: identity.map(str::to_string), + user_email: identity.map(|_| "user@example.test".into()), + api_key_hint: hint.map(str::to_string), + app_url: (auth == "oauth").then(|| DEFAULT_APP_URL.to_string()), + api_url: None, + status: status.into(), + error: error.map(str::to_string), }; - assert_eq!( - format_verification_line(&v), - "bad — api_key — org: corp — invalid API key" - ); + let cases = [ + ( + verification( + "oauth", + Some("test-org"), + Some("Test User"), + None, + "ok", + None, + ), + "https://www.braintrust.dev — oauth — Test User (user@example.test)", + ), + ( + verification( + "api_key", + Some("test-org"), + None, + Some("sk-****abcde"), + "ok", + None, + ), + "test-org — api_key — sk-****abcde", + ), + ( + verification("oauth", None, None, None, "expired", None), + "https://www.braintrust.dev — oauth — token expired", + ), + ( + verification( + "api_key", + Some("test-org"), + None, + None, + "error", + Some("invalid API key"), + ), + "test-org — api_key — invalid API key", + ), + ]; + for (verification, expected) in cases { + assert_eq!(format_verification_line(&verification), expected); + } } #[tokio::test] @@ -5191,80 +6080,44 @@ mod tests { } } - #[tokio::test] - async fn oauth_callback_listener_responds_to_http_request() { + async fn assert_oauth_callback(stale_connection: bool, code: &str, state: &str) { use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::TcpStream; - let callback_server = bind_oauth_callback_server().expect("bind callback server"); - let addr = format!("127.0.0.1:{}", callback_server.port); - let callback = tokio::spawn(wait_for_oauth_callback(callback_server)); - - let mut stream = TcpStream::connect(addr) - .await - .expect("connect to callback listener"); - stream - .write_all( - b"GET /callback?code=test-code&state=test-state HTTP/1.1\r\nHost: 127.0.0.1\r\nUser-Agent: test\r\n\r\n", - ) - .await - .expect("write callback request"); + let server = bind_oauth_callback_server().unwrap(); + let addr = format!("127.0.0.1:{}", server.port); + let callback = tokio::spawn(wait_for_oauth_callback(server)); + if stale_connection { + drop(TcpStream::connect(&addr).await.unwrap()); + } + let mut stream = TcpStream::connect(addr).await.unwrap(); + let request = + format!("GET /callback?code={code}&state={state} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + stream.write_all(request.as_bytes()).await.unwrap(); let mut response = vec![0u8; 4096]; - let bytes_read = tokio::time::timeout(Duration::from_secs(1), stream.read(&mut response)) - .await - .expect("callback response timed out") - .expect("read callback response"); - let response = String::from_utf8_lossy(&response[..bytes_read]); - - let params = callback + let read = tokio::time::timeout(Duration::from_secs(1), stream.read(&mut response)) .await - .expect("callback task") - .expect("callback params"); - assert_eq!(params.code.as_deref(), Some("test-code")); - assert_eq!(params.state.as_deref(), Some("test-state")); + .unwrap() + .unwrap(); + let params = callback.await.unwrap().unwrap(); + assert_eq!( + (params.code.as_deref(), params.state.as_deref()), + (Some(code), Some(state)) + ); + let response = String::from_utf8_lossy(&response[..read]); assert!(response.starts_with("HTTP/1.1 200 OK")); assert!(response.contains("Authorization Successful")); } #[tokio::test] - async fn oauth_callback_listener_ignores_empty_connection() { - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; - use tokio::net::TcpStream; - - let callback_server = bind_oauth_callback_server().expect("bind callback server"); - let addr = format!("127.0.0.1:{}", callback_server.port); - let callback = tokio::spawn(wait_for_oauth_callback(callback_server)); - - let stale = TcpStream::connect(&addr) - .await - .expect("connect stale callback socket"); - drop(stale); - - let mut stream = TcpStream::connect(addr) - .await - .expect("connect to callback listener"); - stream - .write_all( - b"GET /callback?code=next-code&state=next-state HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", - ) - .await - .expect("write callback request"); - - let mut response = vec![0u8; 4096]; - let bytes_read = tokio::time::timeout(Duration::from_secs(1), stream.read(&mut response)) - .await - .expect("callback response timed out") - .expect("read callback response"); - let response = String::from_utf8_lossy(&response[..bytes_read]); + async fn oauth_callback_listener_responds_to_http_request() { + assert_oauth_callback(false, "test-code", "test-state").await; + } - let params = callback - .await - .expect("callback task") - .expect("callback params"); - assert_eq!(params.code.as_deref(), Some("next-code")); - assert_eq!(params.state.as_deref(), Some("next-state")); - assert!(response.starts_with("HTTP/1.1 200 OK")); + #[tokio::test] + async fn oauth_callback_listener_ignores_empty_connection() { + assert_oauth_callback(true, "next-code", "next-state").await; } #[tokio::test] @@ -5275,14 +6128,17 @@ mod tests { #[tokio::test] async fn login_read_only_cached_project_id_and_org_uses_fast_path() { - let env = TestEnv::new(Some("proj_123"), None).await; + let env = TestEnv::new(Some("proj_123"), Some("test-org")).await; + let mut base = base_args_for_path_probe(Some("test-org")); + base.app_url = Some(spawn_api_key_login_server("test-org")); + set_global_config_urls(base.app_url.as_deref().unwrap(), None); let ctx = env - .login_read_only_probe(Some("acme")) + .login_read_only_with_base(base) .await .expect("fast path should succeed"); - assert_eq!(ctx.login.org_name().as_deref(), Some("acme")); - assert_eq!(ctx.login.org_id().as_deref(), Some("")); + assert_eq!(ctx.login.org_name().as_deref(), Some("test-org")); + assert_eq!(ctx.login.org_id().as_deref(), Some("org_test")); assert_eq!(ctx.api_url, "not-a-valid-url"); } @@ -5292,12 +6148,6 @@ mod tests { assert_invalid_api_url(env.login_read_only_probe(None).await); } - #[tokio::test] - async fn login_read_only_cached_project_id_but_whitespace_org_falls_back_to_login() { - let env = TestEnv::new(Some("proj_123"), None).await; - assert_invalid_api_url(env.login_read_only_probe(Some(" ")).await); - } - #[tokio::test] async fn login_read_only_whitespace_project_id_is_treated_as_not_cached() { let env = TestEnv::new(Some(" "), None).await; // has_cached_project_id => false @@ -5323,9 +6173,12 @@ mod tests { ]); save_profile_secret_plaintext("acme-profile", "acme-secret").expect("save acme secret"); save_profile_secret_plaintext("other-profile", "other-secret").expect("save other secret"); + set_global_config_urls("https://www.acme.example", Some("https://api.acme.example")); + let mut base = make_base(); + crate::config::apply_base_config(&mut base); let ctx = env - .login_read_only_with_base(make_base()) + .login_read_only_with_base(base) .await .expect("fast path should succeed with cfg org"); @@ -5337,19 +6190,22 @@ mod tests { #[tokio::test] async fn login_read_only_cached_project_id_and_org_uses_default_urls() { - let env = TestEnv::new(Some("proj_123"), None).await; + let env = TestEnv::new(Some("proj_123"), Some("test-org")).await; let mut base = make_base(); base.api_key = Some("test-api-key".into()); - base.org_name = Some("acme".into()); + base.org_name = Some("test-org".into()); + let app_url = spawn_api_key_login_server("test-org"); + base.app_url = Some(app_url.clone()); + set_global_config_urls(&app_url, None); let ctx = env .login_read_only_with_base(base) .await .expect("fast path should succeed"); - assert_eq!(ctx.login.org_name().as_deref(), Some("acme")); - assert_eq!(ctx.api_url, DEFAULT_API_URL); - assert_eq!(ctx.app_url, DEFAULT_APP_URL); + assert_eq!(ctx.login.org_name().as_deref(), Some("test-org")); + assert_eq!(ctx.api_url, "https://api.example.test"); + assert_eq!(ctx.app_url, app_url); } #[tokio::test] diff --git a/src/config/mod.rs b/src/config/mod.rs index 779499d5..9d732d52 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, bail, Context, Result}; use clap::{Args, Subcommand}; use std::{ env, fs, @@ -8,7 +8,7 @@ use std::{ use serde::{Deserialize, Serialize}; -use crate::args::BaseArgs; +use crate::args::{BaseArgs, DEFAULT_APP_URL}; use crate::ui::{print_command_status, CommandStatus}; mod get; @@ -18,36 +18,74 @@ mod set; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct Config { - pub profile: Option, pub org: Option, + pub org_id: Option, pub project: Option, pub project_id: Option, + pub app_url: Option, + pub api_url: Option, #[serde(flatten)] pub extra: serde_json::Map, } -pub const KNOWN_KEYS: &[&str] = &["profile", "org", "project", "project_id"]; +pub const KNOWN_KEYS: &[&str] = &[ + "org", + "org_id", + "project", + "project_id", + "app_url", + "api_url", +]; impl Config { pub fn get_field(&self, key: &str) -> Option<&str> { match key { - "profile" => self.profile.as_deref(), "org" => self.org.as_deref(), + "org_id" => self.org_id.as_deref(), "project" => self.project.as_deref(), "project_id" => self.project_id.as_deref(), + "app_url" => self.app_url.as_deref(), + "api_url" => self.api_url.as_deref(), _ => None, } } pub fn set_field(&mut self, key: &str, value: String) -> bool { match key { - "profile" => self.profile = Some(value), - "org" => self.org = Some(value), + "org" => { + let value = value.trim().to_string(); + if self.org.as_ref() != Some(&value) { + self.org_id = None; + self.project = None; + self.project_id = None; + } + self.org = (!value.is_empty()).then_some(value); + } + "org_id" => self.org_id = self.org.as_ref().map(|_| value), "project" => { self.project = Some(value); self.project_id = None; } "project_id" => self.project_id = Some(value), + "app_url" => { + let value = value.trim().to_string(); + let previous = self.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let next = if !value.is_empty() { + value.as_str() + } else { + DEFAULT_APP_URL + }; + if !urls_equal(previous, next) { + self.org = None; + self.org_id = None; + self.project = None; + self.project_id = None; + } + self.app_url = (!value.is_empty()).then_some(value); + } + "api_url" => { + self.api_url = trimmed_option(Some(&value)).map(str::to_string); + } _ => return false, } true @@ -55,13 +93,32 @@ impl Config { pub fn unset_field(&mut self, key: &str) -> bool { match key { - "profile" => self.profile = None, - "org" => self.org = None, + "org" => { + self.org = None; + self.org_id = None; + self.project = None; + self.project_id = None; + } + "org_id" => self.org_id = None, "project" => { self.project = None; self.project_id = None; } "project_id" => self.project_id = None, + "app_url" => { + if self + .app_url + .as_deref() + .is_some_and(|url| !urls_equal(url, DEFAULT_APP_URL)) + { + self.org = None; + self.org_id = None; + self.project = None; + self.project_id = None; + } + self.app_url = None; + } + "api_url" => self.api_url = None, _ => return false, } true @@ -74,25 +131,105 @@ impl Config { .collect() } - pub(crate) fn merge(&self, other: &Config) -> Config { + pub(crate) fn set_context( + &mut self, + org: (&str, &str), + project: Option<(&str, &str)>, + app_url: &str, + api_url: &str, + ) { + self.org = Some(org.0.trim().to_string()); + self.org_id = Some(org.1.trim().to_string()); + (self.project, self.project_id) = project + .map(|(name, id)| (name.to_string(), id.to_string())) + .unzip(); + self.app_url = Some(app_url.to_string()); + self.api_url = Some(api_url.to_string()); + } + + pub(crate) fn merge(&self, local: &Config) -> Config { let mut extra = self.extra.clone(); - extra.extend(other.extra.clone()); - let project = other.project.clone().or_else(|| self.project.clone()); - let project_id = if other.project.is_some() { - other.project_id.clone() - } else { - self.project_id.clone() + extra.extend(local.extra.clone()); + + let app_url = local.app_url.clone().or_else(|| self.app_url.clone()); + let api_url = local.api_url.clone().or_else(|| self.api_url.clone()); + let global_app = self.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let merged_app = app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let same_instance = urls_equal(global_app, merged_app); + let same_org = same_instance && local.org == self.org; + let global_project_id = self.project.as_ref().and(self.project_id.clone()); + + let (org, org_id, project, project_id) = match (&local.org, &local.project) { + (Some(org), Some(project)) => ( + Some(org.clone()), + local.org_id.clone(), + Some(project.clone()), + local.project_id.clone(), + ), + (Some(org), None) if same_org => ( + Some(org.clone()), + local.org_id.clone().or_else(|| self.org_id.clone()), + self.project.clone(), + global_project_id, + ), + (Some(org), None) => (Some(org.clone()), local.org_id.clone(), None, None), + (None, Some(project)) => (None, None, Some(project.clone()), local.project_id.clone()), + (None, None) if same_instance => ( + self.org.clone(), + self.org_id.clone(), + self.project.clone(), + global_project_id, + ), + (None, None) => (None, None, None, None), }; Config { - profile: other.profile.clone().or_else(|| self.profile.clone()), - org: other.org.clone().or_else(|| self.org.clone()), + org, + org_id, project, project_id, + app_url, + api_url, extra, } } } +pub(crate) fn urls_equal(left: &str, right: &str) -> bool { + left.trim().trim_end_matches('/') == right.trim().trim_end_matches('/') +} + +/// Apply config-file URL and org-ID fallbacks after clap has resolved CLI/env. +pub fn apply_base_config(base: &mut BaseArgs) { + let cfg = load().unwrap_or_default(); + apply_config_to_base(base, &cfg); +} + +fn apply_config_to_base(base: &mut BaseArgs, cfg: &Config) { + if base.app_url.is_none() { + base.app_url = cfg.app_url.clone(); + } + + if base.api_url.is_none() { + base.api_url = cfg.api_url.clone(); + } + + let effective_app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let config_app = cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let same_instance = urls_equal(effective_app, config_app); + if base.org_name_source.is_none() { + if base.org_name.is_none() && same_instance { + base.org_name = cfg.org.clone(); + } + if same_instance && base.org_name == cfg.org { + base.org_id = cfg.org_id.clone(); + } else { + base.org_id = None; + } + } else { + base.org_id = None; + } +} + pub fn global_config_dir() -> Result { if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") { return Ok(PathBuf::from(xdg).join("bt")); @@ -119,7 +256,7 @@ pub fn load_file(path: &Path) -> Config { } }; - let config: Config = match serde_json::from_str(&file_contents) { + let mut config: Config = match serde_json::from_str(&file_contents) { Ok(c) => c, Err(e) => { print_command_status( @@ -130,6 +267,20 @@ pub fn load_file(path: &Path) -> Config { } }; + config.extra.remove("profile"); + + config.org = trimmed_option(config.org.as_deref()).map(str::to_string); + config.org_id = config + .org + .as_ref() + .and(trimmed_option(config.org_id.as_deref()).map(str::to_string)); + if config.org.is_none() { + config.project = None; + config.project_id = None; + } + config.app_url = trimmed_option(config.app_url.as_deref()).map(str::to_string); + config.api_url = trimmed_option(config.api_url.as_deref()).map(str::to_string); + for key in config.extra.keys() { print_command_status( CommandStatus::Error, @@ -181,20 +332,20 @@ pub(crate) fn project_from_config_for_context( } fn config_matches_context(base: &BaseArgs, cfg: &Config, resolved_org: Option<&str>) -> bool { - let selected_profile = trimmed_option(base.profile.as_deref()); - let cfg_profile = trimmed_option(cfg.profile.as_deref()); - let cfg_org = trimmed_option(cfg.org.as_deref()); - let resolved_org = trimmed_option(resolved_org); - - match selected_profile { - Some(profile) => { - cfg_profile == Some(profile) - || (cfg_profile.is_none() && cfg_org.is_some() && cfg_org == resolved_org) - } - None => cfg_org - .zip(resolved_org) - .is_none_or(|(cfg, resolved)| cfg == resolved), + let requested_app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let cfg_app = cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + if !urls_equal(requested_app, cfg_app) { + return false; } + + let cfg_org = org_option(cfg.org.as_deref()); + let requested_org = org_option(resolved_org).or_else(|| org_option(base.org_name.as_deref())); + + requested_org.is_none_or(|resolved| cfg_org == Some(resolved)) +} + +pub(crate) fn org_option(value: Option<&str>) -> Option<&str> { + trimmed_option(value) } pub(crate) fn trimmed_option(value: Option<&str>) -> Option<&str> { @@ -220,57 +371,116 @@ pub fn save_global(config: &Config) -> Result<()> { } pub fn find_local_config_dir() -> Option { - let home = dirs::home_dir(); - let mut current_dir = std::env::current_dir().ok()?; + find_local_config_dir_from(std::env::current_dir().ok()?, dirs::home_dir().as_deref()) +} + +enum ProjectBoundary { + Bt(PathBuf), + Git(PathBuf), + Home, + Root, +} - loop { - if current_dir.join(".bt").is_dir() { - return Some(current_dir.join(".bt")); +fn project_boundary(start: PathBuf, home: Option<&Path>) -> ProjectBoundary { + // `current_dir()` is the physical path (symlinks resolved) while `$HOME` may + // not be, so also compare canonicalized forms — exact equality alone can + // walk straight past a symlinked home boundary. + let home_canon = home.and_then(|h| fs::canonicalize(h).ok()); + for dir in start.ancestors() { + let at_home = + Some(dir) == home || (home_canon.is_some() && fs::canonicalize(dir).ok() == home_canon); + if at_home { + return ProjectBoundary::Home; } - if current_dir.join(".git").exists() { - return None; + if dir.parent().is_none() { + return ProjectBoundary::Root; } - if Some(¤t_dir) == home.as_ref() { - return None; + let bt = dir.join(".bt"); + if bt.is_dir() { + return ProjectBoundary::Bt(bt); } - if !current_dir.pop() { - return None; + if dir.join(".git").exists() { + return ProjectBoundary::Git(dir.to_path_buf()); } } + unreachable!("path ancestors always include a filesystem root") } -pub fn local_path() -> Option { - find_local_config_dir().map(|dir| dir.join("config.json")) -} - -pub enum WriteTarget { - Global(PathBuf), - Local(PathBuf), +fn find_local_config_dir_from(current_dir: PathBuf, home: Option<&Path>) -> Option { + match project_boundary(current_dir, home) { + ProjectBoundary::Bt(dir) if dir.join("config.json").is_file() => Some(dir), + _ => None, + } } -pub fn write_target() -> Result { - match local_path() { - Some(p) => Ok(WriteTarget::Local(p)), - None => Ok(WriteTarget::Global(global_path()?)), - } +pub fn local_path() -> Option { + find_local_config_dir().map(|dir| dir.join("config.json")) } /// Resolve which config file to write based on --global/--local flags. pub fn resolve_write_path(global: bool, local: bool) -> Result { if global { - global_path() - } else if local { - match local_path() { - Some(p) => Ok(p), - None => { - bail!("No local .bt directory found. Use bt init to initialize this directory.") - } + return global_path(); + } + match local_path() { + Some(path) => Ok(path), + None if local => { + bail!("No existing local .bt/config.json found. Run `bt init` first, or use --global.") } - } else { - match write_target()? { - WriteTarget::Local(p) | WriteTarget::Global(p) => Ok(p), + None => global_path(), + } +} + +/// Resolve the create/overwrite target for `bt init`. +pub fn init_target(here: bool, force: bool) -> Result { + init_target_from( + std::env::current_dir().context("could not read current directory")?, + dirs::home_dir().as_deref(), + here, + force, + ) +} + +fn init_target_from( + current_dir: PathBuf, + home: Option<&Path>, + here: bool, + force: bool, +) -> Result { + if here { + let path = current_dir.join(".bt/config.json"); + if path.exists() && !force { + bail!( + "{} already exists; rerun with --force to overwrite it", + path.display() + ); } + return Ok(path); + } + + let path = match project_boundary(current_dir, home) { + ProjectBoundary::Home => bail!( + "reached the home directory without finding a project git root; run `bt init` inside a repository, or pass --here" + ), + ProjectBoundary::Root => bail!( + "reached the filesystem root without finding a project git root; run `bt init` inside a repository, or pass --here" + ), + ProjectBoundary::Git(dir) => return Ok(dir.join(".bt/config.json")), + ProjectBoundary::Bt(dir) => dir.join("config.json"), + }; + if !path.is_file() { + bail!( + "found {} without config.json; remove the incomplete .bt directory, then rerun `bt init`", + path.parent().unwrap_or(&path).display() + ); + } + if !force { + bail!( + "{} already exists; use `bt switch` to change it, or rerun with --force to overwrite it", + path.display() + ); } + Ok(path) } pub fn local_save_path() -> Result { @@ -289,15 +499,53 @@ pub fn save_local(config: &Config, create_dir: bool) -> Result { // --- CLI commands --- -#[derive(Debug, Clone, Args)] +#[derive(Debug, Clone, Default, Args)] pub struct ScopeArgs { - /// Apply to global config (~/.config/bt/config.json) + /// Use global config (~/.config/bt/config.json) #[arg(long, short = 'g', conflicts_with = "local")] - global: bool, + pub(crate) global: bool, - /// Apply to local config (.bt/config.json) + /// Use local config (.bt/config.json) #[arg(long, short = 'l')] - local: bool, + pub(crate) local: bool, +} + +fn scope_labels(global: &Path, local: &Path) -> [String; 2] { + [ + format!("Global ({})", global.parent().unwrap_or(global).display()), + format!("Local ({})", local.parent().unwrap_or(local).display()), + ] +} + +type ResolvedScope = (PathBuf, &'static str); + +impl ScopeArgs { + pub(crate) fn preflight(&self, can_prompt: bool) -> Result<()> { + (!can_prompt) + .then(|| self.resolve(false, "")) + .transpose() + .map(drop) + } + + pub(crate) fn resolve(&self, can_prompt: bool, prompt: &str) -> Result { + if self.global || self.local { + let scope = if self.global { "global" } else { "local" }; + return resolve_write_path(self.global, self.local).map(|path| (path, scope)); + } + let Some(local) = local_path() else { + return Ok((global_path()?, "global")); + }; + if !can_prompt { + bail!("both global and local config scopes are available; pass --global or --local"); + } + let global = global_path()?; + let options = scope_labels(&global, &local); + Ok(if crate::ui::fuzzy_select(prompt, &options, 1)? == 0 { + (global, "global") + } else { + (local, "local") + }) + } } #[derive(Debug, Clone, Args)] @@ -318,14 +566,14 @@ enum ConfigCommands { }, /// Get a config value Get { - /// Config key (profile, org, project, project_id) + /// Config key (org, org_id, project, project_id, app_url, api_url) key: String, #[command(flatten)] scope: ScopeArgs, }, /// Set a config value Set { - /// Config key (profile, org, project, project_id) + /// Config key (org, org_id, project, project_id, app_url, api_url) key: String, /// Value to set value: String, @@ -334,7 +582,7 @@ enum ConfigCommands { }, /// Remove a config value Unset { - /// Config key (profile, org, project, project_id) + /// Config key (org, org_id, project, project_id, app_url, api_url) key: String, #[command(flatten)] scope: ScopeArgs, @@ -378,87 +626,211 @@ mod tests { use tempfile::TempDir; #[test] - fn merge_other_takes_precedence() { - let base = Config { - org: Some("base-org".into()), - project: Some("base-proj".into()), - ..Default::default() - }; - let other = Config { - org: Some("other-org".into()), - project: Some("other-proj".into()), + fn merge_keeps_org_and_project_contexts_together() { + let c = |org: Option<&str>, project: Option<&str>, id: Option<&str>| Config { + org: org.map(str::to_string), + project: project.map(str::to_string), + project_id: id.map(str::to_string), ..Default::default() }; - let merged = base.merge(&other); - assert_eq!(merged.org, Some("other-org".into())); - assert_eq!(merged.project, Some("other-proj".into())); + let g = || c(Some("global"), Some("global-proj"), Some("proj_g")); + let cases = [ + (Config::default(), Config::default(), Config::default()), + ( + g(), + c(Some("other"), Some("other-proj"), None), + c(Some("other"), Some("other-proj"), None), + ), + ( + c(Some("base"), None, None), + c(None, Some("local"), None), + c(None, Some("local"), None), + ), + (g(), c(Some("global"), None, None), g()), + ( + g(), + c(Some("local"), None, None), + c(Some("local"), None, None), + ), + ( + g(), + c(None, Some("local"), Some("proj_l")), + c(None, Some("local"), Some("proj_l")), + ), + (g(), c(Some(""), None, None), c(Some(""), None, None)), + (g(), Config::default(), g()), + ]; + for (global, local, expected) in cases { + assert_eq!(global.merge(&local), expected); + } } #[test] - fn merge_self_fills_when_other_none() { - let base = Config { - org: Some("base-org".into()), - project: Some("base-proj".into()), + fn merge_inherits_context_only_within_the_same_instance() { + let global = Config { + org: Some("test-org".into()), + org_id: Some("org_test".into()), + project: Some("test-project".into()), + project_id: Some("proj_test".into()), + app_url: Some("https://www.example.test".into()), + api_url: Some("https://api.example.test".into()), ..Default::default() }; - let other = Config::default(); - let merged = base.merge(&other); - assert_eq!(merged.org, Some("base-org".into())); - assert_eq!(merged.project, Some("base-proj".into())); - } + let same_instance = Config { + app_url: Some("https://www.example.test/".into()), + api_url: Some("https://proxy.example.test".into()), + ..Default::default() + }; + let merged = global.merge(&same_instance); + assert_eq!(merged.org.as_deref(), Some("test-org")); + assert_eq!(merged.org_id.as_deref(), Some("org_test")); + assert_eq!(merged.project_id.as_deref(), Some("proj_test")); + assert_eq!( + merged.api_url.as_deref(), + Some("https://proxy.example.test") + ); - #[test] - fn merge_both_none_stays_none() { - let base = Config::default(); - let other = Config::default(); - let merged = base.merge(&other); + let other_instance = Config { + app_url: Some("https://self-hosted.example.test".into()), + ..Default::default() + }; + let merged = global.merge(&other_instance); assert_eq!(merged.org, None); + assert_eq!(merged.org_id, None); assert_eq!(merged.project, None); + assert_eq!(merged.app_url, other_instance.app_url); + } + + #[test] + fn config_fills_urls_and_coupled_org_id_without_overriding_cli() { + let cfg = Config { + org: Some("config-org".into()), + org_id: Some("org_config".into()), + app_url: Some("https://www.example.test".into()), + api_url: Some("https://api.example.test".into()), + ..Default::default() + }; + let mut base = BaseArgs::default(); + apply_config_to_base(&mut base, &cfg); + assert_eq!(base.org_name.as_deref(), Some("config-org")); + assert_eq!(base.org_id.as_deref(), Some("org_config")); + assert_eq!(base.app_url, cfg.app_url); + assert_eq!(base.api_url, cfg.api_url); + + let mut base = BaseArgs { + org_name: Some("cli-org".into()), + org_name_source: Some(crate::args::ArgValueSource::CommandLine), + app_url: Some("https://cli.example.test".into()), + ..Default::default() + }; + apply_config_to_base(&mut base, &cfg); + assert_eq!(base.org_name.as_deref(), Some("cli-org")); + assert_eq!(base.org_id, None); + assert_eq!(base.app_url.as_deref(), Some("https://cli.example.test")); + assert_eq!(base.api_url, cfg.api_url); + + let mut same_instance = BaseArgs { + app_url: Some("https://www.example.test/".into()), + ..Default::default() + }; + apply_config_to_base(&mut same_instance, &cfg); + assert_eq!(same_instance.api_url, cfg.api_url); + + let mut other_instance = BaseArgs { + app_url: Some("https://other.example.test".into()), + ..Default::default() + }; + apply_config_to_base(&mut other_instance, &cfg); + assert_eq!(other_instance.org_name, None); + assert_eq!(other_instance.org_id, None); } #[test] - fn merge_partial_fill() { - let base = Config { - org: Some("base-org".into()), - project: None, + fn configured_project_does_not_cross_instance_boundaries() { + let cfg = Config { + org: Some("test-org".into()), + project: Some("test-project".into()), + app_url: Some("https://www.example.test".into()), ..Default::default() }; - let other = Config { - org: None, - project: Some("other-proj".into()), + let matching = BaseArgs { + org_name: Some("test-org".into()), + app_url: Some("https://www.example.test/".into()), ..Default::default() }; - let merged = base.merge(&other); - assert_eq!(merged.org, Some("base-org".into())); - assert_eq!(merged.project, Some("other-proj".into())); - } - - fn base_with_profile(profile: Option<&str>) -> BaseArgs { - BaseArgs { - json: false, - verbose: false, - verbose_source: None, - quiet: false, - quiet_source: None, - no_color: false, - no_input: false, - profile: profile.map(str::to_string), - profile_explicit: profile.is_some(), - org_name: None, - project: None, - api_key: None, - api_key_source: None, - prefer_profile: false, - api_url: None, - app_url: None, - ca_cert: None, - env_file: None, + assert_eq!( + project_from_config_for_context(&matching, &cfg, Some("test-org")).as_deref(), + Some("test-project") + ); + + let other = BaseArgs { + app_url: Some("https://other.example.test".into()), + ..matching + }; + assert_eq!( + project_from_config_for_context(&other, &cfg, Some("test-org")), + None + ); + } + + #[test] + fn changing_app_url_clears_coupled_context() { + let mut cfg = Config { + org: Some("test-org".into()), + org_id: Some("org_test".into()), + project: Some("test-project".into()), + project_id: Some("proj_test".into()), + app_url: Some("https://www.example.test".into()), + ..Default::default() + }; + assert!(cfg.set_field("app_url", "https://other.example.test".into())); + assert_eq!(cfg.org, None); + assert_eq!(cfg.org_id, None); + assert_eq!(cfg.project, None); + assert_eq!(cfg.project_id, None); + } + + #[test] + fn scope_labels_are_plain_text() { + let labels = scope_labels( + Path::new("/home/test-user/.config/bt/config.json"), + Path::new("/work/test-project/.bt/config.json"), + ); + assert_eq!(labels[1], "Local (/work/test-project/.bt)"); + assert!(labels.iter().all(|label| !label.contains('\u{1b}'))); + } + + #[test] + fn option_helpers_handle_empty_values() { + for (input, org, trimmed) in [ + (None, None, None), + (Some(""), None, None), + (Some(" "), None, None), + (Some("test-org"), Some("test-org"), Some("test-org")), + ] { + assert_eq!(org_option(input), org); + assert_eq!(trimmed_option(input), trimmed); } + + let mut cfg = Config::default(); + cfg.set_context( + ("test-org", "org_test"), + Some(("test-project", "proj_test")), + "https://www.example.test", + "https://api.example.test", + ); + assert_eq!(cfg.org.as_deref(), Some("test-org")); + assert_eq!(cfg.org_id.as_deref(), Some("org_test")); + assert_eq!(cfg.project.as_deref(), Some("test-project")); + assert_eq!(cfg.project_id.as_deref(), Some("proj_test")); } - fn config(profile: Option<&str>, org: Option<&str>, project: Option<&str>) -> Config { + fn base_args() -> BaseArgs { + BaseArgs::default() + } + + fn config(org: Option<&str>, project: Option<&str>) -> Config { Config { - profile: profile.map(str::to_string), org: org.map(str::to_string), project: project.map(str::to_string), ..Default::default() @@ -466,22 +838,18 @@ mod tests { } #[test] - fn project_config_matches_explicit_profile_or_legacy_org() { - let base = base_with_profile(Some("work")); - let cases = [ - (config(None, Some("acme"), Some("demo")), Some("demo")), - (config(None, Some("other"), Some("demo")), None), - (config(None, None, Some("demo")), None), - (config(Some("other"), Some("acme"), Some("demo")), None), - ( - config(Some("work"), Some("acme"), Some("demo")), - Some("demo"), - ), - ]; - - for (cfg, expected) in cases { + fn project_config_must_match_org_context() { + let base = base_args(); + for (config_org, resolved_org, expected) in [ + (Some("test-org"), "test-org", Some("test-project")), + (Some("other-org"), "test-org", None), + (None, "test-org", None), + (Some(""), "test-org", None), + (Some(""), "", Some("test-project")), + ] { + let cfg = config(config_org, Some("test-project")); assert_eq!( - project_from_config_for_context(&base, &cfg, Some("acme")).as_deref(), + project_from_config_for_context(&base, &cfg, Some(resolved_org)).as_deref(), expected ); } @@ -539,6 +907,37 @@ mod tests { assert!(config.extra.contains_key("another")); } + #[test] + fn legacy_profile_key_is_ignored_and_not_persisted() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.json"); + fs::write(&path, r#"{"org":"test-org","profile":"legacy-login"}"#).unwrap(); + + let config = load_file(&path); + assert_eq!(config.org.as_deref(), Some("test-org")); + assert!(!config.extra.contains_key("profile")); + + save_file(&path, &config).unwrap(); + let persisted = fs::read_to_string(&path).unwrap(); + assert!(!persisted.contains("profile")); + } + + #[test] + fn load_purges_obsolete_empty_org_context() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.json"); + fs::write( + &path, + r#"{"org":"","org_id":"org_old","project":"old","project_id":"proj_old"}"#, + ) + .unwrap(); + let loaded = load_file(&path); + assert_eq!(loaded.org, None); + assert_eq!(loaded.org_id, None); + assert_eq!(loaded.project, None); + assert_eq!(loaded.project_id, None); + } + #[test] fn unknown_keys_roundtrip_through_save() { let tmp = TempDir::new().unwrap(); @@ -571,4 +970,113 @@ mod tests { save_file(&path, &config).unwrap(); assert!(path.exists()); } + + #[test] + fn local_discovery_requires_config_json_and_stops_at_first_bt() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + let nested = repo.join("a").join("b"); + fs::create_dir_all(&nested).unwrap(); + fs::create_dir(repo.join(".git")).unwrap(); + fs::create_dir(repo.join(".bt")).unwrap(); + + assert_eq!(find_local_config_dir_from(nested.clone(), None), None); + + fs::write(repo.join(".bt/config.json"), "{}").unwrap(); + assert_eq!( + find_local_config_dir_from(nested, None), + Some(repo.join(".bt")) + ); + } + + #[test] + fn local_discovery_does_not_use_home_bt() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + fs::create_dir_all(home.join(".bt")).unwrap(); + fs::write(home.join(".bt/config.json"), "{}").unwrap(); + + assert_eq!( + find_local_config_dir_from(home.clone(), Some(home.as_path())), + None + ); + } + + #[test] + fn init_target_finds_nested_git_directory_or_file() { + for git_is_file in [false, true] { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + let nested = repo.join("nested").join("deeper"); + fs::create_dir_all(&nested).unwrap(); + if git_is_file { + fs::write(repo.join(".git"), "gitdir: synthetic").unwrap(); + } else { + fs::create_dir(repo.join(".git")).unwrap(); + } + + assert_eq!( + init_target_from(nested, Some(tmp.path()), false, false).unwrap(), + repo.join(".bt/config.json") + ); + } + } + + #[test] + fn init_target_existing_bt_requires_force_and_existing_config() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + let nested = repo.join("nested"); + fs::create_dir_all(repo.join(".bt")).unwrap(); + fs::create_dir_all(&nested).unwrap(); + + assert!(init_target_from(nested.clone(), Some(tmp.path()), false, true).is_err()); + + let target = repo.join(".bt/config.json"); + fs::write(&target, "{}").unwrap(); + assert!(init_target_from(nested.clone(), Some(tmp.path()), false, false).is_err()); + assert_eq!( + init_target_from(nested, Some(tmp.path()), false, true).unwrap(), + target + ); + } + + #[test] + fn init_target_here_bypasses_home_boundary_and_honors_force() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + fs::create_dir_all(&home).unwrap(); + let target = home.join(".bt/config.json"); + + assert_eq!( + init_target_from(home.clone(), Some(home.as_path()), true, false).unwrap(), + target + ); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(&target, "{}").unwrap(); + assert!(init_target_from(home.clone(), Some(home.as_path()), true, false).is_err()); + assert_eq!( + init_target_from(home, Some(tmp.path()), true, true).unwrap(), + target + ); + } + + #[test] + fn init_target_home_wins_over_git_marker() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + fs::create_dir_all(home.join(".git")).unwrap(); + assert!(init_target_from(home.clone(), Some(home.as_path()), false, false).is_err()); + } + + #[cfg(unix)] + #[test] + fn init_target_here_bypasses_filesystem_root_boundary() { + let root = PathBuf::from("/"); + assert_eq!( + init_target_from(root.clone(), None, true, true).unwrap(), + root.join(".bt/config.json") + ); + assert!(init_target_from(root, None, false, false).is_err()); + } } diff --git a/src/cost/mod.rs b/src/cost/mod.rs new file mode 100644 index 00000000..72bdb22e --- /dev/null +++ b/src/cost/mod.rs @@ -0,0 +1,1280 @@ +//! `bt cost` — estimate LLM spend across a project's logs, topics, experiments, +//! and playgrounds. +//! +//! Cost is aggregated with `SUM(cost) GROUP BY WHERE AND +//!