Compare commits
387 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fa768cc29 | |||
| b7f3bb5725 | |||
| 4b2899d13f | |||
| 035e4f3ce8 | |||
| ff03d1939b | |||
| e48d1f3c4d | |||
| ea708f0636 | |||
| 717941ce5e | |||
| 0a41e08160 | |||
| a76044409c | |||
| 7f675410fd | |||
| bd8ee7f88f | |||
| 7ab04aab2d | |||
| 1b528106b7 | |||
| 1bb16cde09 | |||
| 15d7cfa8d9 | |||
| 69edbfb717 | |||
| 6981de1bca | |||
| da86da8cc1 | |||
| 3eeac67634 | |||
| 42312ff76f | |||
| 280ea99628 | |||
| ad61350c2e | |||
| 530cd5f588 | |||
| 0e6d81b109 | |||
| 7ebe8cae22 | |||
| a1e90585b1 | |||
| c28f07901a | |||
| c080692df5 | |||
| 7c2a8f4fac | |||
| 56268d1482 | |||
| c6b1456133 | |||
| 75f76a28bf | |||
| 82deaeec63 | |||
| 5926206e67 | |||
| a43eb88228 | |||
| 8ddcb213a3 | |||
| d9065a737d | |||
| c8c7ee5472 | |||
| f8930fc9b2 | |||
| b2ae9c0901 | |||
| 54865dd258 | |||
| ccada02258 | |||
| c6db8d9c99 | |||
| 1f2ef70813 | |||
| 7f2acdecb9 | |||
| fd11d9ceca | |||
| 23962f98e3 | |||
| f50c8fb327 | |||
| aede79dfb1 | |||
| ef007a4a21 | |||
| ca9b86aada | |||
| 29f971c22d | |||
| 202c886793 | |||
| 796def4cfd | |||
| a3d8040ccc | |||
| 7123a3c734 | |||
| 978ed3a02e | |||
| c4ac6e85c0 | |||
| a61064ba3a | |||
| b2c5e28cba | |||
| 6cec445b4f | |||
| 50b1e5ea13 | |||
| 284ba76b6b | |||
| 851d70ad7a | |||
| d3b7d246da | |||
| 3256203876 | |||
| 2eb2209e40 | |||
| c5cd6c1526 | |||
| 1deb70c926 | |||
| 33a5773415 | |||
| 201fe16aae | |||
| 89761ec1e5 | |||
| 5327b0ef20 | |||
| 17d9c42521 | |||
| 101229d3f4 | |||
| 1661b2b4d0 | |||
| ed962fcab4 | |||
| 704f61cfcc | |||
| 6fe969438e | |||
| 9b3444f2f0 | |||
| 51c70cc1d0 | |||
| 79d878bd92 | |||
| 34d345158f | |||
| 076cead491 | |||
| 46f8e6cbeb | |||
| ffcce360fc | |||
| 18b23fcc0d | |||
| 20598ff5dc | |||
| 82b7b7278e | |||
| 1f0818d660 | |||
| 49c46c0d0b | |||
| 10433d5193 | |||
| a28e6a55cb | |||
| d98aa83b37 | |||
| 6fd62df2a8 | |||
| c7b33930db | |||
| 0f3ad2991e | |||
| 2d43db69c9 | |||
| bf270336d9 | |||
| 420e9416e5 | |||
| fa2a98765d | |||
| 95d9006d70 | |||
| 37b9ab4fbe | |||
| 17eb4f2e5e | |||
| e0864c6c2a | |||
| 4b0545ab23 | |||
| e34df9b96d | |||
| 6c98cc6917 | |||
| 9a83cee84d | |||
| 96f2f20591 | |||
| b938192f0d | |||
| 46585fb445 | |||
| 0e67879051 | |||
| 39cae4a557 | |||
| 8a4deb6249 | |||
| 4256d16426 | |||
| 4203c34dcf | |||
| 558bce385d | |||
| cd0a445614 | |||
| 929c85d5c8 | |||
| c54d234daa | |||
| 3a4dddd071 | |||
| 2f84a38b69 | |||
| a6e896ed89 | |||
| 73de4d4024 | |||
| a8dcd574fb | |||
| 26db91facf | |||
| 62b092cac7 | |||
| f4799d1c7e | |||
| f0d875ffa9 | |||
| 3ec7f34076 | |||
| 73bebee8f6 | |||
| f1de3dc948 | |||
| 4839cae319 | |||
| c597557a5e | |||
| fdad20a8d1 | |||
| 77f83a1dc1 | |||
| 6c8be5c5d8 | |||
| 4fa4bded7c | |||
| 5f68fcbd04 | |||
| 411d662676 | |||
| 66f28defe5 | |||
| 4cd5d5b132 | |||
| 8ac55ebcc2 | |||
| 3d2d97c1e6 | |||
| 9b7bd9ad03 | |||
| 7be6bed347 | |||
| 3b1a7c6993 | |||
| 88033a3b3e | |||
| f3f6a3b702 | |||
| b9ab17d1d3 | |||
| 84555aaf70 | |||
| ba10bf805d | |||
| 37bc3c4e2b | |||
| af511316ba | |||
| 5debf4dd05 | |||
| 899440fd8a | |||
| 153f9cb108 | |||
| 179165838a | |||
| 8d1f5284ba | |||
| dddbbac745 | |||
| c736b4f5a8 | |||
| 03cb109b40 | |||
| edf318ba67 | |||
| e03d4da925 | |||
| 624224f089 | |||
| 075c1762e2 | |||
| 247440a793 | |||
| c940b9b97a | |||
| 934c872753 | |||
| 6a909feb64 | |||
| 57c73b5cb2 | |||
| 5f187a6817 | |||
| dec311b143 | |||
| cdf9cbd8cc | |||
| 77c449f455 | |||
| 825d3cb138 | |||
| 7a9248a0f3 | |||
| 99a5d44fea | |||
| 1cb4cbc960 | |||
| d85f7ddf05 | |||
| 4d7b11ec96 | |||
| b92350a6ac | |||
| 2848d74929 | |||
| 22227ba523 | |||
| c69c327123 | |||
| f30c63146b | |||
| cf3cadce6c | |||
| e628720e7a | |||
| ae1a5c3ab5 | |||
| 53dd4be2dd | |||
| 6250a4c6d1 | |||
| fa75393e5f | |||
| 4dffad603f | |||
| 666ee8ffc2 | |||
| a726e5f172 | |||
| 834031a0fd | |||
| 45de5fb4c8 | |||
| f337bd007e | |||
| d08ee8ce88 | |||
| 2552107ff6 | |||
| 665ad906ec | |||
| 2b81d9fc3b | |||
| 51cf7691be | |||
| 9b6ce337e6 | |||
| 0c17058d21 | |||
| 70e817b44a | |||
| 47b7af784b | |||
| 82c355efc4 | |||
| feb749b7cf | |||
| e60d848293 | |||
| 2770c5935f | |||
| 1a4f0ea6f3 | |||
| 35e9c8c754 | |||
| d8981f8a75 | |||
| 78dc5ebc37 | |||
| 09e6260e32 | |||
| e3cd02aa7e | |||
| 05afde62dc | |||
| a8aaf65918 | |||
| b26ab3d85c | |||
| f1042877b2 | |||
| 01bf5a1188 | |||
| 757c1bbd31 | |||
| 6711214bb1 | |||
| 341496e314 | |||
| da94d308de | |||
| 191e749b14 | |||
| a6388e4353 | |||
| 31fbdfdc02 | |||
| 45ea011b7f | |||
| b5f80c5d2f | |||
| 4953272bcf | |||
| b40daf4f54 | |||
| fdd0a45124 | |||
| bd2c6d4f48 | |||
| bf56494336 | |||
| 8204a59dac | |||
| 7e4f3d142e | |||
| 6a0381b8e5 | |||
| d841cd0dda | |||
| 633af39c82 | |||
| dbc57a0419 | |||
| 7f15b2db9e | |||
| 04ed704e94 | |||
| d7e5ae95d6 | |||
| 420074f96a | |||
| 09604c6481 | |||
| 627023ffcc | |||
| 0bd47d9944 | |||
| d33c52d51d | |||
| 4667c82514 | |||
| e832db2efb | |||
| 2b488749c1 | |||
| 140548bb20 | |||
| 581d50c33c | |||
| 2f79eb8f48 | |||
| 5e20b58766 | |||
| 2d7c43c438 | |||
| 12b43542a9 | |||
| a491c246e0 | |||
| 4113515d0f | |||
| 1871b5e9ee | |||
| 3a7348f685 | |||
| b650941c06 | |||
| ab289165b5 | |||
| 516c3d2814 | |||
| c26e1ac258 | |||
| 94ca6b20d9 | |||
| 89785003ca | |||
| f5a658b4f0 | |||
| 9cee29d110 | |||
| ffa9cc87eb | |||
| 7aabf7077a | |||
| 564334e300 | |||
| ddbe5a0906 | |||
| 7b85227fae | |||
| 36075fe0e4 | |||
| 563e17c3df | |||
| 97ea969a29 | |||
| 86b61523bd | |||
| 4d91ccf48f | |||
| 84f4437ce0 | |||
| 439732a43a | |||
| b7f1aa86ec | |||
| db3a9cdb0a | |||
| 67139a99df | |||
| e007bee23d | |||
| f0b0934721 | |||
| 2db056817d | |||
| cb23f1eb96 | |||
| 3065b3100b | |||
| 44363c0b1c | |||
| 61718bce5a | |||
| dcc4401043 | |||
| 04bfc84c19 | |||
| 96ed1417b9 | |||
| 478100ac5d | |||
| 8adb1dc6f7 | |||
| a893ed01f5 | |||
| fd30e8f626 | |||
| 39ab77833e | |||
| 1d14bdca6b | |||
| ab2e603997 | |||
| 91854a4153 | |||
| 0cec8cc826 | |||
| e4d2f6adc9 | |||
| 40ef8d9cf8 | |||
| 354d88618c | |||
| a489e651c2 | |||
| e8a4a1138b | |||
| 85395b10f8 | |||
| 896078d99e | |||
| f45ace378a | |||
| 23ca2cda59 | |||
| 4721bc1948 | |||
| 7070231c3e | |||
| f33aa29c88 | |||
| be9ee95cbe | |||
| 0a1680f24e | |||
| 587584a8bf | |||
| 9fb69c1571 | |||
| 873683cac6 | |||
| bf2d61f55b | |||
| 7b7699cf8b | |||
| d757adb192 | |||
| d3ab565fff | |||
| c78b949050 | |||
| 2219d1249b | |||
| df2974bafa | |||
| 8820984229 | |||
| 9767d12966 | |||
| 944490d5d0 | |||
| 4dd09e1323 | |||
| e342bac29a | |||
| efbe445e6f | |||
| 26335e8d9b | |||
| 6821d0c64a | |||
| 1506ab9dfc | |||
| 9c6aafe940 | |||
| f0c9e0d14c | |||
| 3a17f9151f | |||
| ff3a44eef6 | |||
| aab06faa8a | |||
| 9d1f5347c2 | |||
| 8c5d585b7f | |||
| bc5b55d0ab | |||
| f5a957585a | |||
| c24c8cd0c4 | |||
| 6ad8676cf1 | |||
| 9e157be116 | |||
| c1447c8365 | |||
| f8b79906ed | |||
| 26a1397699 | |||
| 7baef66180 | |||
| 2d3abb9f3d | |||
| 1a6d7c5ddc | |||
| 8005510ad2 | |||
| ada2436e54 | |||
| 1ea75eb824 | |||
| 6296b04105 | |||
| 752288ca70 | |||
| a776ae4a73 | |||
| 50c3301606 | |||
| 0dc1d30962 | |||
| 85ffda67d3 | |||
| 39c88456b4 | |||
| aef87f582f | |||
| 0f72f2a6cb | |||
| 8a7e8dbd64 | |||
| 649a2ede0d | |||
| ccc4adb173 | |||
| d37d40a975 | |||
| b32b702d67 | |||
| 87639048ce | |||
| ef128ac25a | |||
| 7901a0365a | |||
| ceb37ad0d3 | |||
| 0021ac31d8 | |||
| d61758e0b4 | |||
| 4d2b566de2 | |||
| e9f17841e4 | |||
| 23a4601c30 | |||
| 08f354d469 | |||
| fb302b2a2c | |||
| 180e89effa |
@@ -1,2 +0,0 @@
|
||||
[build]
|
||||
jobs = 2
|
||||
+1
-32
@@ -24,44 +24,13 @@ CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16
|
||||
# Публичные узлы разрешены по умолчанию. Для внутренних API перечислите
|
||||
# допустимые имена или IP через запятую.
|
||||
CRANK_OUTBOUND_ALLOWED_HOSTS=
|
||||
CRANK_OUTBOUND_DENIED_HOSTS=
|
||||
CRANK_OUTBOUND_MAX_RESPONSE_BYTES=4194304
|
||||
CRANK_ENVIRONMENT=development
|
||||
CRANK_LOG_LEVEL=info
|
||||
# Пустое значение отключает канал критических ошибок.
|
||||
CRANK_SENTRY_DSN=
|
||||
# Prometheus endpoints use separate listeners and stay on loopback by default.
|
||||
CRANK_METRICS_ENABLED=true
|
||||
CRANK_ADMIN_METRICS_BIND=127.0.0.1:9464
|
||||
CRANK_MCP_METRICS_BIND=127.0.0.1:9465
|
||||
# Required when either metrics listener uses a non-loopback address.
|
||||
CRANK_METRICS_BEARER_TOKEN=
|
||||
CRANK_INVOCATION_LOG_RETENTION_DAYS=30
|
||||
# Пустой endpoint полностью отключает экспорт трасс.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=
|
||||
OTEL_EXPORTER_OTLP_TIMEOUT=10000
|
||||
OTEL_EXPORTER_OTLP_TRACES_TIMEOUT=
|
||||
OTEL_EXPORTER_OTLP_HEADERS=
|
||||
OTEL_EXPORTER_OTLP_TRACES_HEADERS=
|
||||
OTEL_BSP_MAX_QUEUE_SIZE=2048
|
||||
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
|
||||
OTEL_BSP_SCHEDULE_DELAY=5000
|
||||
OTEL_BSP_EXPORT_TIMEOUT=30000
|
||||
CRANK_MASTER_KEY=change-me-master-key
|
||||
CRANK_SESSION_SECRET=change-me-session-secret
|
||||
CRANK_PASSWORD_PEPPER=change-me-password-pepper
|
||||
CRANK_SESSION_TTL_HOURS=24
|
||||
# Trust X-Real-IP / X-Forwarded-For for client rate limiting. Enable only when
|
||||
# admin-api runs behind the bundled nginx (or another trusted reverse proxy).
|
||||
CRANK_TRUST_FORWARDED_HEADERS=true
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
||||
CRANK_DEMO_SEED=true
|
||||
CRANK_DEMO_SEED=false
|
||||
CRANK_BASE_URL=https://crank.example.com
|
||||
|
||||
+16
-378
@@ -11,71 +11,41 @@ env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUST_TEST_THREADS: "2"
|
||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust Checks
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
crank-rust-postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: crank_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d crank_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://postgres:postgres@crank-rust-postgres:5432/crank_test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: |
|
||||
set -eu
|
||||
toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n1)"
|
||||
if [ -z "$toolchain" ]; then
|
||||
echo "Unable to read Rust toolchain channel from rust-toolchain.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
rustup toolchain install "$toolchain" --profile minimal --component clippy --component rustfmt
|
||||
rustup default "$toolchain"
|
||||
host="$(rustc -vV | sed -n 's/^host: //p')"
|
||||
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/${toolchain}-${host}"
|
||||
toolchain_bin="$toolchain_dir/bin"
|
||||
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
||||
echo "Rust $toolchain was not installed at $toolchain_dir." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
||||
"$toolchain_bin/rustc" --version
|
||||
"$toolchain_bin/cargo" --version
|
||||
"$toolchain_bin/rustfmt" --version
|
||||
"$toolchain_bin/cargo-clippy" --version
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
python3 --version
|
||||
rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
cargo clippy --version
|
||||
docker --version
|
||||
docker info
|
||||
|
||||
- name: Install dependency policy tool
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
|
||||
- name: Run tooling unit tests
|
||||
run: python3 -m unittest discover -s tests/unit
|
||||
|
||||
- name: Check Community scope
|
||||
run: scripts/check-community-scope.sh
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all --check
|
||||
|
||||
- name: Check Rust code health
|
||||
run: scripts/check-rust-code-health.sh
|
||||
|
||||
- name: Check dependency licenses and advisories
|
||||
run: cargo deny --locked check advisories bans licenses sources
|
||||
|
||||
- name: Check Rust boundaries
|
||||
run: scripts/check-rust-boundaries.sh
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features --jobs "$CARGO_BUILD_JOBS" -- -D warnings
|
||||
|
||||
@@ -101,10 +71,6 @@ jobs:
|
||||
working-directory: apps/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Audit UI dependencies
|
||||
working-directory: apps/ui
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Build UI bundle
|
||||
working-directory: apps/ui
|
||||
run: npm run build
|
||||
@@ -137,29 +103,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: |
|
||||
set -eu
|
||||
toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n1)"
|
||||
if [ -z "$toolchain" ]; then
|
||||
echo "Unable to read Rust toolchain channel from rust-toolchain.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
rustup toolchain install "$toolchain" --profile minimal --component clippy --component rustfmt
|
||||
rustup default "$toolchain"
|
||||
host="$(rustc -vV | sed -n 's/^host: //p')"
|
||||
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/${toolchain}-${host}"
|
||||
toolchain_bin="$toolchain_dir/bin"
|
||||
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
||||
echo "Rust $toolchain was not installed at $toolchain_dir." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
||||
"$toolchain_bin/rustc" --version
|
||||
"$toolchain_bin/cargo" --version
|
||||
"$toolchain_bin/rustfmt" --version
|
||||
"$toolchain_bin/cargo-clippy" --version
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
rustc --version
|
||||
@@ -189,11 +132,9 @@ jobs:
|
||||
find .tmp/ui-e2e/logs -maxdepth 1 -type f -print -exec sed -n '1,220p' {} \; || true
|
||||
|
||||
deployment:
|
||||
name: Community Image Smoke
|
||||
name: Deployment Manifests
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust
|
||||
- ui
|
||||
needs: ui
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -201,306 +142,3 @@ jobs:
|
||||
|
||||
- name: Validate Community deployment manifest
|
||||
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
||||
|
||||
- name: Build Community images
|
||||
run: |
|
||||
docker build -f apps/admin-api/Dockerfile -t crank/admin-api:ci .
|
||||
docker build -f apps/mcp-server/Dockerfile -t crank/mcp-server:ci .
|
||||
docker build -f apps/ui/Dockerfile -t crank/ui:ci .
|
||||
|
||||
- name: Start Community image stack
|
||||
run: |
|
||||
mkdir -p .tmp
|
||||
cat > .tmp/community-smoke.env <<'EOF'
|
||||
COMPOSE_PROJECT_NAME=crank-ci-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_PUBLISH_PORT=0
|
||||
POSTGRES_DB=crank
|
||||
POSTGRES_USER=crank
|
||||
POSTGRES_PASSWORD=crank-ci-password
|
||||
CRANK_ADMIN_API_IMAGE=crank/admin-api:ci
|
||||
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:ci
|
||||
CRANK_UI_IMAGE=crank/ui:ci
|
||||
CRANK_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
CRANK_SESSION_SECRET=ci-session-secret
|
||||
CRANK_PASSWORD_PEPPER=ci-password-pepper
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=ci-admin-password
|
||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=CI Owner
|
||||
CRANK_BASE_URL=http://127.0.0.1
|
||||
CRANK_PUBLISH_BIND=127.0.0.1
|
||||
CRANK_ADMIN_PUBLISH_PORT=0
|
||||
CRANK_MCP_PUBLISH_PORT=0
|
||||
CRANK_UI_PUBLISH_PORT=0
|
||||
CRANK_DEMO_SEED=true
|
||||
EOF
|
||||
docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env --profile local-db up -d --wait
|
||||
ui_address="$(docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env port ui 3000)"
|
||||
printf 'http://%s\n' "$ui_address" > .tmp/community-smoke.url
|
||||
|
||||
- name: Run authenticated Community image smoke
|
||||
env:
|
||||
CRANK_STAGING_ADMIN_EMAIL: owner@crank.test
|
||||
CRANK_STAGING_ADMIN_PASSWORD: ci-admin-password
|
||||
run: scripts/authenticated-product-smoke.sh "$(cat .tmp/community-smoke.url)"
|
||||
|
||||
- name: Show Community image logs
|
||||
if: failure()
|
||||
run: |
|
||||
docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env --profile local-db ps || true
|
||||
docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env --profile local-db logs --no-color || true
|
||||
|
||||
- name: Stop Community image stack
|
||||
if: always()
|
||||
run: |
|
||||
docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env --profile local-db down -v --remove-orphans || true
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust
|
||||
- ui
|
||||
- frontend-e2e
|
||||
- deployment
|
||||
if: ${{ gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
REGISTRY: git.itexp.me
|
||||
IMAGE_TAG: ${{ gitea.sha }}
|
||||
ADMIN_API_IMAGE: git.itexp.me/bsodfather/crank-community-admin-api
|
||||
MCP_SERVER_IMAGE: git.itexp.me/bsodfather/crank-community-mcp-server
|
||||
UI_IMAGE: git.itexp.me/bsodfather/crank-community-ui
|
||||
OPENBAO_ENV_FILE: .openbao-env
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
docker --version
|
||||
command -v bao
|
||||
bao version
|
||||
|
||||
- name: Load deployment secrets from OpenBao
|
||||
env:
|
||||
BAO_ADDR: ${{ secrets.BAO_ADDR }}
|
||||
BAO_ROLE_ID: ${{ secrets.BAO_ROLE_ID }}
|
||||
BAO_SECRET_ID: ${{ secrets.BAO_SECRET_ID }}
|
||||
OPENBAO_APP: crank
|
||||
run: scripts/load-openbao-env.sh
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | \
|
||||
docker login '${{ env.REGISTRY }}' -u "$DEPLOY_REGISTRY_USER" --password-stdin
|
||||
|
||||
- name: Build and push images
|
||||
run: |
|
||||
docker build -f apps/admin-api/Dockerfile \
|
||||
-t '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.ADMIN_API_IMAGE }}:main' \
|
||||
.
|
||||
docker build -f apps/mcp-server/Dockerfile \
|
||||
-t '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.MCP_SERVER_IMAGE }}:main' \
|
||||
.
|
||||
docker build -f apps/ui/Dockerfile \
|
||||
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.UI_IMAGE }}:main' \
|
||||
.
|
||||
scripts/scan-images.sh \
|
||||
'${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
'${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
'${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:main'
|
||||
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.MCP_SERVER_IMAGE }}:main'
|
||||
docker push '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.UI_IMAGE }}:main'
|
||||
|
||||
- name: Configure SSH key
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
- name: Configure known hosts
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
else
|
||||
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > ~/.ssh/known_hosts
|
||||
fi
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Sync deployment files to server
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"mkdir -p '$DEPLOY_PATH' && \
|
||||
if [ -f '$DEPLOY_PATH/docker-compose.yml' ]; then \
|
||||
cp '$DEPLOY_PATH/docker-compose.yml' '$DEPLOY_PATH/docker-compose.previous.yml'; \
|
||||
fi"
|
||||
rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
|
||||
rsync -az -e "ssh -p $DEPLOY_PORT" scripts/deploy-community.sh \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/deploy-community.sh"
|
||||
|
||||
- name: Write environment file
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
tmp_env="$(mktemp)"
|
||||
append_if_set() {
|
||||
if [ -n "$2" ]; then
|
||||
printf '%s=%s\n' "$1" "$2" >> "$tmp_env"
|
||||
fi
|
||||
}
|
||||
: > "$tmp_env"
|
||||
append_if_set POSTGRES_DB "$POSTGRES_DB"
|
||||
append_if_set POSTGRES_USER "$POSTGRES_USER"
|
||||
append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
|
||||
append_if_set POSTGRES_HOST "$POSTGRES_HOST"
|
||||
append_if_set POSTGRES_MAX_CONNECTIONS "${POSTGRES_MAX_CONNECTIONS:-}"
|
||||
append_if_set POSTGRES_MIN_CONNECTIONS "${POSTGRES_MIN_CONNECTIONS:-}"
|
||||
append_if_set POSTGRES_ACQUIRE_TIMEOUT_MS "${POSTGRES_ACQUIRE_TIMEOUT_MS:-}"
|
||||
append_if_set POSTGRES_IDLE_TIMEOUT_MS "${POSTGRES_IDLE_TIMEOUT_MS:-}"
|
||||
append_if_set POSTGRES_MAX_LIFETIME_MS "${POSTGRES_MAX_LIFETIME_MS:-}"
|
||||
if [ -n "${POSTGRES_PORT:-}" ]; then
|
||||
append_if_set POSTGRES_PORT "$POSTGRES_PORT"
|
||||
elif [ -n "${PGBOUNCER_PORT:-}" ]; then
|
||||
append_if_set POSTGRES_PORT "$PGBOUNCER_PORT"
|
||||
fi
|
||||
append_if_set CRANK_STORAGE_ROOT "$CRANK_STORAGE_ROOT"
|
||||
append_if_set CRANK_PUBLISH_BIND "$CRANK_PUBLISH_BIND"
|
||||
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND"
|
||||
append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND"
|
||||
append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS"
|
||||
append_if_set CRANK_ADMIN_RATE_LIMIT_RPS "${CRANK_ADMIN_RATE_LIMIT_RPS:-}"
|
||||
append_if_set CRANK_ADMIN_RATE_LIMIT_BURST "${CRANK_ADMIN_RATE_LIMIT_BURST:-}"
|
||||
append_if_set CRANK_MCP_RATE_LIMIT_RPS "${CRANK_MCP_RATE_LIMIT_RPS:-}"
|
||||
append_if_set CRANK_MCP_RATE_LIMIT_BURST "${CRANK_MCP_RATE_LIMIT_BURST:-}"
|
||||
append_if_set CRANK_RUNTIME_MAX_CONCURRENT_UNARY "${CRANK_RUNTIME_MAX_CONCURRENT_UNARY:-}"
|
||||
append_if_set CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS "${CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS:-}"
|
||||
append_if_set CRANK_OUTBOUND_ALLOWED_HOSTS "${CRANK_OUTBOUND_ALLOWED_HOSTS:-}"
|
||||
append_if_set CRANK_OUTBOUND_DENIED_HOSTS "${CRANK_OUTBOUND_DENIED_HOSTS:-}"
|
||||
append_if_set CRANK_OUTBOUND_MAX_RESPONSE_BYTES "${CRANK_OUTBOUND_MAX_RESPONSE_BYTES:-}"
|
||||
append_if_set CRANK_ENVIRONMENT "${CRANK_ENVIRONMENT:-production}"
|
||||
append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL"
|
||||
append_if_set CRANK_SENTRY_DSN "${CRANK_SENTRY_DSN:-}"
|
||||
append_if_set CRANK_METRICS_ENABLED "${CRANK_METRICS_ENABLED:-}"
|
||||
append_if_set CRANK_ADMIN_METRICS_BIND "${CRANK_ADMIN_METRICS_BIND:-}"
|
||||
append_if_set CRANK_MCP_METRICS_BIND "${CRANK_MCP_METRICS_BIND:-}"
|
||||
append_if_set CRANK_METRICS_BEARER_TOKEN "${CRANK_METRICS_BEARER_TOKEN:-}"
|
||||
append_if_set CRANK_INVOCATION_LOG_RETENTION_DAYS "${CRANK_INVOCATION_LOG_RETENTION_DAYS:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_ENDPOINT "${OTEL_EXPORTER_OTLP_ENDPOINT:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT "${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_PROTOCOL "${OTEL_EXPORTER_OTLP_PROTOCOL:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_PROTOCOL "${OTEL_EXPORTER_OTLP_TRACES_PROTOCOL:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_TIMEOUT "${OTEL_EXPORTER_OTLP_TIMEOUT:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_TIMEOUT "${OTEL_EXPORTER_OTLP_TRACES_TIMEOUT:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_HEADERS "${OTEL_EXPORTER_OTLP_HEADERS:-}"
|
||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_HEADERS "${OTEL_EXPORTER_OTLP_TRACES_HEADERS:-}"
|
||||
append_if_set OTEL_BSP_MAX_QUEUE_SIZE "${OTEL_BSP_MAX_QUEUE_SIZE:-}"
|
||||
append_if_set OTEL_BSP_MAX_EXPORT_BATCH_SIZE "${OTEL_BSP_MAX_EXPORT_BATCH_SIZE:-}"
|
||||
append_if_set OTEL_BSP_SCHEDULE_DELAY "${OTEL_BSP_SCHEDULE_DELAY:-}"
|
||||
append_if_set OTEL_BSP_EXPORT_TIMEOUT "${OTEL_BSP_EXPORT_TIMEOUT:-}"
|
||||
append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY"
|
||||
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
|
||||
append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND"
|
||||
append_if_set CRANK_CACHE_URL "$CRANK_CACHE_URL"
|
||||
append_if_set CRANK_CACHE_DEFAULT_TTL_MS "$CRANK_CACHE_DEFAULT_TTL_MS"
|
||||
append_if_set CRANK_SESSION_SECRET "$CRANK_SESSION_SECRET"
|
||||
append_if_set CRANK_PASSWORD_PEPPER "$CRANK_PASSWORD_PEPPER"
|
||||
append_if_set CRANK_SESSION_TTL_HOURS "$CRANK_SESSION_TTL_HOURS"
|
||||
append_if_set CRANK_BOOTSTRAP_ADMIN_EMAIL "$CRANK_BOOTSTRAP_ADMIN_EMAIL"
|
||||
append_if_set CRANK_BOOTSTRAP_ADMIN_PASSWORD "$CRANK_BOOTSTRAP_ADMIN_PASSWORD"
|
||||
append_if_set CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME "$CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME"
|
||||
append_if_set CRANK_DEMO_SEED "$CRANK_DEMO_SEED"
|
||||
{
|
||||
printf 'COMPOSE_PROJECT_NAME=community\n'
|
||||
printf 'CRANK_ADMIN_API_IMAGE=%s:%s\n' '${{ env.ADMIN_API_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||
printf 'CRANK_MCP_SERVER_IMAGE=%s:%s\n' '${{ env.MCP_SERVER_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||
printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||
} >> "$tmp_env"
|
||||
cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"mkdir -p '$DEPLOY_PATH' && \
|
||||
if [ -f '$DEPLOY_PATH/.env' ]; then \
|
||||
cp '$DEPLOY_PATH/.env' '$DEPLOY_PATH/.env.previous'; \
|
||||
fi && cat > '$DEPLOY_PATH/.env'"
|
||||
rm -f "$tmp_env"
|
||||
|
||||
- name: Validate required environment variables
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||
set -e
|
||||
cd '$DEPLOY_PATH'
|
||||
required_vars='
|
||||
POSTGRES_HOST
|
||||
POSTGRES_PORT
|
||||
POSTGRES_DB
|
||||
POSTGRES_USER
|
||||
POSTGRES_PASSWORD
|
||||
CRANK_MASTER_KEY
|
||||
CRANK_SESSION_SECRET
|
||||
CRANK_PASSWORD_PEPPER
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD
|
||||
CRANK_BASE_URL
|
||||
'
|
||||
for var in \$required_vars; do
|
||||
value=\$(grep -E \"^\${var}=\" .env | tail -n1 | cut -d= -f2- || true)
|
||||
if [ -z \"\$value\" ]; then
|
||||
echo \"missing required env: \$var\" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
"
|
||||
|
||||
- name: Deploy with Docker Compose
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | ssh -p "$DEPLOY_PORT" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"chmod 700 '$DEPLOY_PATH/deploy-community.sh' && \
|
||||
'$DEPLOY_PATH/deploy-community.sh' '$DEPLOY_PATH'"
|
||||
|
||||
- name: Verify health endpoints
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||
set -e
|
||||
cd '$DEPLOY_PATH'
|
||||
for attempt in \$(seq 1 30); do
|
||||
if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \
|
||||
&& curl --fail --silent http://127.0.0.1:3001/ready >/dev/null \
|
||||
&& curl --fail --silent http://127.0.0.1:3002/ready >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo 'deployment health verification failed' >&2
|
||||
docker compose ps >&2
|
||||
exit 1
|
||||
"
|
||||
|
||||
- name: Run authenticated product smoke
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
CRANK_STAGING_ADMIN_EMAIL="$CRANK_BOOTSTRAP_ADMIN_EMAIL" \
|
||||
CRANK_STAGING_ADMIN_PASSWORD="$CRANK_BOOTSTRAP_ADMIN_PASSWORD" \
|
||||
scripts/authenticated-product-smoke.sh "$CRANK_BASE_URL"
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.itexp.me
|
||||
IMAGE_TAG: ${{ gitea.sha }}
|
||||
ADMIN_API_IMAGE: git.itexp.me/bsodfather/crank-community-admin-api
|
||||
MCP_SERVER_IMAGE: git.itexp.me/bsodfather/crank-community-mcp-server
|
||||
UI_IMAGE: git.itexp.me/bsodfather/crank-community-ui
|
||||
OPENBAO_ENV_FILE: .openbao-env
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
docker --version
|
||||
command -v bao
|
||||
bao version
|
||||
|
||||
- name: Load deployment secrets from OpenBao
|
||||
env:
|
||||
BAO_ADDR: ${{ secrets.BAO_ADDR }}
|
||||
BAO_ROLE_ID: ${{ secrets.BAO_ROLE_ID }}
|
||||
BAO_SECRET_ID: ${{ secrets.BAO_SECRET_ID }}
|
||||
OPENBAO_APP: crank
|
||||
run: scripts/load-openbao-env.sh
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | \
|
||||
docker login '${{ env.REGISTRY }}' -u "$DEPLOY_REGISTRY_USER" --password-stdin
|
||||
|
||||
- name: Build and push images
|
||||
run: |
|
||||
docker build -f apps/admin-api/Dockerfile \
|
||||
-t '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.ADMIN_API_IMAGE }}:main' \
|
||||
.
|
||||
docker build -f apps/mcp-server/Dockerfile \
|
||||
-t '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.MCP_SERVER_IMAGE }}:main' \
|
||||
.
|
||||
docker build -f apps/ui/Dockerfile \
|
||||
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.UI_IMAGE }}:main' \
|
||||
.
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:main'
|
||||
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.MCP_SERVER_IMAGE }}:main'
|
||||
docker push '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.UI_IMAGE }}:main'
|
||||
|
||||
- name: Configure SSH key
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
- name: Configure known hosts
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
else
|
||||
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > ~/.ssh/known_hosts
|
||||
fi
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Sync deployment files to server
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"mkdir -p '$DEPLOY_PATH'"
|
||||
rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
|
||||
|
||||
- name: Write environment file
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
tmp_env="$(mktemp)"
|
||||
append_if_set() {
|
||||
if [ -n "$2" ]; then
|
||||
printf '%s=%s\n' "$1" "$2" >> "$tmp_env"
|
||||
fi
|
||||
}
|
||||
: > "$tmp_env"
|
||||
append_if_set POSTGRES_DB "$POSTGRES_DB"
|
||||
append_if_set POSTGRES_USER "$POSTGRES_USER"
|
||||
append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
|
||||
append_if_set POSTGRES_HOST "$POSTGRES_HOST"
|
||||
if [ -n "${POSTGRES_PORT:-}" ]; then
|
||||
append_if_set POSTGRES_PORT "$POSTGRES_PORT"
|
||||
elif [ -n "${PGBOUNCER_PORT:-}" ]; then
|
||||
append_if_set POSTGRES_PORT "$PGBOUNCER_PORT"
|
||||
fi
|
||||
append_if_set CRANK_STORAGE_ROOT "$CRANK_STORAGE_ROOT"
|
||||
append_if_set CRANK_PUBLISH_BIND "$CRANK_PUBLISH_BIND"
|
||||
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND"
|
||||
append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND"
|
||||
append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS"
|
||||
append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL"
|
||||
append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY"
|
||||
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
|
||||
append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND"
|
||||
append_if_set CRANK_CACHE_URL "$CRANK_CACHE_URL"
|
||||
append_if_set CRANK_CACHE_DEFAULT_TTL_MS "$CRANK_CACHE_DEFAULT_TTL_MS"
|
||||
append_if_set CRANK_SESSION_SECRET "$CRANK_SESSION_SECRET"
|
||||
append_if_set CRANK_PASSWORD_PEPPER "$CRANK_PASSWORD_PEPPER"
|
||||
append_if_set CRANK_SESSION_TTL_HOURS "$CRANK_SESSION_TTL_HOURS"
|
||||
append_if_set CRANK_BOOTSTRAP_ADMIN_EMAIL "$CRANK_BOOTSTRAP_ADMIN_EMAIL"
|
||||
append_if_set CRANK_BOOTSTRAP_ADMIN_PASSWORD "$CRANK_BOOTSTRAP_ADMIN_PASSWORD"
|
||||
append_if_set CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME "$CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME"
|
||||
append_if_set CRANK_DEMO_SEED "$CRANK_DEMO_SEED"
|
||||
{
|
||||
printf 'COMPOSE_PROJECT_NAME=community\n'
|
||||
printf 'CRANK_ADMIN_API_IMAGE=%s:%s\n' '${{ env.ADMIN_API_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||
printf 'CRANK_MCP_SERVER_IMAGE=%s:%s\n' '${{ env.MCP_SERVER_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||
printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||
} >> "$tmp_env"
|
||||
cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"mkdir -p '$DEPLOY_PATH' && cat > '$DEPLOY_PATH/.env'"
|
||||
rm -f "$tmp_env"
|
||||
|
||||
- name: Validate required environment variables
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||
set -e
|
||||
cd '$DEPLOY_PATH'
|
||||
required_vars='
|
||||
POSTGRES_HOST
|
||||
POSTGRES_PORT
|
||||
POSTGRES_DB
|
||||
POSTGRES_USER
|
||||
POSTGRES_PASSWORD
|
||||
CRANK_MASTER_KEY
|
||||
CRANK_SESSION_SECRET
|
||||
CRANK_PASSWORD_PEPPER
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD
|
||||
CRANK_BASE_URL
|
||||
'
|
||||
for var in \$required_vars; do
|
||||
value=\$(grep -E \"^\${var}=\" .env | tail -n1 | cut -d= -f2- || true)
|
||||
if [ -z \"\$value\" ]; then
|
||||
echo \"missing required env: \$var\" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
"
|
||||
|
||||
- name: Deploy with Docker Compose
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||
set -e
|
||||
cd '$DEPLOY_PATH'
|
||||
compose_profiles=''
|
||||
cache_backend=\$(grep -E '^CRANK_CACHE_BACKEND=' .env | tail -n1 | cut -d= -f2- || true)
|
||||
if [ \"\$cache_backend\" = 'valkey' ] || [ \"\$cache_backend\" = 'redis' ]; then
|
||||
compose_profiles='--profile cache'
|
||||
fi
|
||||
echo '$DEPLOY_REGISTRY_TOKEN' | docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin
|
||||
docker compose \$compose_profiles config -q
|
||||
docker compose \$compose_profiles pull
|
||||
docker compose \$compose_profiles down --remove-orphans
|
||||
for container in \
|
||||
crank-ui-1 \
|
||||
crank-admin-api-1 \
|
||||
crank-mcp-server-1 \
|
||||
crank-postgres-1 \
|
||||
crank-valkey-1 \
|
||||
crank-community-ui-1 \
|
||||
crank-community-admin-api-1 \
|
||||
crank-community-mcp-server-1 \
|
||||
crank-community-postgres-1 \
|
||||
crank-community-valkey-1; do
|
||||
if docker ps -a --format '{{.Names}}' | grep -Fx \"\$container\" >/dev/null; then
|
||||
docker rm -f \"\$container\"
|
||||
fi
|
||||
done
|
||||
echo 'Docker containers before freeing required ports:'
|
||||
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Ports}}'
|
||||
for port in 3000 3001 3002; do
|
||||
container_ids=\$(docker ps -aq --filter \"publish=\$port\")
|
||||
if [ -n \"\$container_ids\" ]; then
|
||||
echo \"Removing containers publishing port \$port\"
|
||||
docker inspect --format '{{.Name}} {{json .NetworkSettings.Ports}}' \$container_ids || true
|
||||
docker rm -f \$container_ids
|
||||
fi
|
||||
done
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltnp '( sport = :3000 or sport = :3001 or sport = :3002 )' || true
|
||||
fi
|
||||
docker compose \$compose_profiles up -d --remove-orphans
|
||||
"
|
||||
|
||||
- name: Verify health endpoints
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||
set -e
|
||||
cd '$DEPLOY_PATH'
|
||||
for attempt in \$(seq 1 30); do
|
||||
if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \
|
||||
&& curl --fail --silent http://127.0.0.1:3001/health >/dev/null \
|
||||
&& curl --fail --silent http://127.0.0.1:3002/health >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo 'deployment health verification failed' >&2
|
||||
docker compose ps >&2
|
||||
exit 1
|
||||
"
|
||||
@@ -22,23 +22,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Use preinstalled Rust toolchain
|
||||
run: |
|
||||
set -eu
|
||||
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/1.96.1-x86_64-unknown-linux-gnu"
|
||||
toolchain_bin="$toolchain_dir/bin"
|
||||
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
||||
echo "Rust 1.96.1 is not preinstalled at $toolchain_dir." >&2
|
||||
echo "Install it in the Gitea runner image/host before running CI:" >&2
|
||||
echo "rustup toolchain install 1.96.1 --profile minimal --component clippy --component rustfmt" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
||||
"$toolchain_bin/rustc" --version
|
||||
"$toolchain_bin/cargo" --version
|
||||
"$toolchain_bin/rustfmt" --version
|
||||
"$toolchain_bin/cargo-clippy" --version
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
rustc --version
|
||||
@@ -49,16 +32,6 @@ jobs:
|
||||
command -v bao
|
||||
bao version
|
||||
|
||||
- name: Install dependency policy tool
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
|
||||
- name: Run release quality gates
|
||||
run: |
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
cargo test --workspace --all-targets
|
||||
cargo deny --locked check advisories bans licenses sources
|
||||
|
||||
- name: Build release binaries
|
||||
run: cargo build --release -p admin-api -p mcp-server
|
||||
|
||||
@@ -66,25 +39,10 @@ jobs:
|
||||
working-directory: apps/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Audit UI dependencies
|
||||
working-directory: apps/ui
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Build UI dist
|
||||
working-directory: apps/ui
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright browser
|
||||
working-directory: apps/ui
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run release end-to-end tests
|
||||
working-directory: apps/ui
|
||||
run: npm run e2e
|
||||
|
||||
- name: Validate deployment manifest
|
||||
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
||||
|
||||
- name: Package release artifacts
|
||||
run: |
|
||||
mkdir -p dist/release
|
||||
@@ -130,10 +88,6 @@ jobs:
|
||||
docker build -f apps/ui/Dockerfile \
|
||||
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.UI_IMAGE }}:latest' .
|
||||
scripts/scan-images.sh \
|
||||
'${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
'${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
'${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:latest'
|
||||
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
|
||||
@@ -18,8 +18,5 @@ apps/ui/test-results
|
||||
apps/ui/vite.config.js
|
||||
apps/ui/vite.config.d.ts
|
||||
*.log
|
||||
__pycache__/
|
||||
__*.md
|
||||
diploma/
|
||||
AGENTS.md
|
||||
TASKS.md
|
||||
|
||||
+4
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2\n order by ov.version asc",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2\n order by ov.version asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -115,21 +115,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"ordinal": 23,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"ordinal": 24,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
@@ -164,10 +159,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb"
|
||||
"hash": "10ac4260e678ac458750988f95e54d3a84d9870e10e86bbe0d9c9fd99bf8a1f5"
|
||||
}
|
||||
+4
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n order by o.name asc",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n order by o.name asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -115,21 +115,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"ordinal": 23,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"ordinal": 24,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
@@ -161,10 +156,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4"
|
||||
"hash": "67a73cb5211a0c23d2e8d8361a8e37c488eb2420207e9c4fa682993eda20cd62"
|
||||
}
|
||||
+4
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n where po.operation_id = $1",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n where po.operation_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -115,21 +115,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"ordinal": 23,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"ordinal": 24,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
@@ -163,10 +158,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce"
|
||||
"hash": "7ad59ce20834c3de2b0345c1be28d84377b417efb30c7bdc38d4e3746a0c1a7a"
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n protocol,\n mode,\n status,\n cursor_json,\n state_json,\n expires_at as \"expires_at!: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n closed_at as \"closed_at: OffsetDateTime\"\n from stream_sessions\n where id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "mode",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "cursor_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "expires_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "last_poll_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "closed_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a1d2c9b16b61d226701449fc22146db6f0f7d17e6867f929c44027990fb94b57"
|
||||
}
|
||||
+4
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2 and ov.version = $3",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2 and ov.version = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -115,21 +115,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"ordinal": 23,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"ordinal": 24,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
@@ -165,10 +160,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b"
|
||||
"hash": "a24c36f988151016f9ffa834a6a5658f7f094844b892c999e6f58d6f92fbe0c2"
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n protocol,\n mode,\n status,\n cursor_json,\n state_json,\n expires_at as \"expires_at!: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n closed_at as \"closed_at: OffsetDateTime\"\n from stream_sessions\n where workspace_id = $1\n and ($2::text is null or agent_id = $2)\n and ($3::text is null or operation_id = $3)\n and ($4::text is null or status = $4)\n and ($5::text is null or mode = $5)\n order by created_at desc\n limit $6",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "mode",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "cursor_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "expires_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "last_poll_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "closed_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b68377d8ffd5bdc9b8e417170f4940b65d2167dada6b6110985ce0bd95480b93"
|
||||
}
|
||||
+4
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n w.id as workspace_id,\n w.slug as workspace_slug,\n a.id as agent_id,\n a.slug as agent_slug,\n b.tool_name,\n b.tool_title,\n coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",\n o.id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version\n join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version\n join operations o on o.id = ov.operation_id and o.workspace_id = w.id\n where w.slug = $1 and a.slug = $2 and b.enabled = true\n order by b.tool_name asc",
|
||||
"query": "select\n w.id as workspace_id,\n w.slug as workspace_slug,\n a.id as agent_id,\n a.slug as agent_slug,\n b.tool_name,\n b.tool_title,\n coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",\n o.id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version\n join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version\n join operations o on o.id = ov.operation_id and o.workspace_id = w.id\n where w.slug = $1 and a.slug = $2 and b.enabled = true\n order by b.tool_name asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -145,21 +145,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"name": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"ordinal": 29,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"ordinal": 30,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
@@ -200,10 +195,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec"
|
||||
"hash": "dd4a415a042b863b1034727737f3d14019ba405a07a8360b68e8c9c5597e17ce"
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n status,\n progress_json,\n result_json,\n error_json,\n expires_at as \"expires_at: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n finished_at as \"finished_at: OffsetDateTime\"\n from async_jobs\n where workspace_id = $1\n and ($2::text is null or agent_id = $2)\n and ($3::text is null or operation_id = $3)\n and ($4::text is null or status = $4)\n order by updated_at desc\n limit $5",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "progress_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "result_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "error_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "expires_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "last_poll_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "finished_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e86682005480df007b488b8543adc8a6d4068e33a5509f9e314dd0d97eac178b"
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n status,\n progress_json,\n result_json,\n error_json,\n expires_at as \"expires_at: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n finished_at as \"finished_at: OffsetDateTime\"\n from async_jobs\n where id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "progress_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "result_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "error_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "expires_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "last_poll_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "finished_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f551a9cd253b7fd0dc9a387a141a53d8a109b8aa766ab1c2b11f9b52e250adc8"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# AGENTS
|
||||
|
||||
## Purpose
|
||||
|
||||
This repository is developed through agent-assisted workflow. Follow the repository documents first, then implement code.
|
||||
|
||||
## Source of truth
|
||||
|
||||
Use the documents in this order when there is ambiguity:
|
||||
|
||||
1. `docs/architecture.md`
|
||||
2. `docs/module-decomposition.md`
|
||||
3. `docs/data-model.md`
|
||||
4. `docs/database-schema.md`
|
||||
5. `docs/admin-api.md`
|
||||
6. `docs/mcp-interface.md`
|
||||
7. `docs/rust-design.md`
|
||||
8. `docs/development-rules.md`
|
||||
9. `docs/rust-code-rules.md`
|
||||
10. `docs/implementation-plan.md`
|
||||
11. `docs/product-editions.md`
|
||||
12. `docs/commercial-boundaries.md`
|
||||
13. `docs/frontend-roadmap.md`
|
||||
14. `docs/refactoring-roadmap.md`
|
||||
|
||||
If code and docs diverge, update docs first or together with code.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Follow `Red -> Green -> Refactor -> Commit`.
|
||||
- Backend changes follow `TDD` strictly. Frontend layout, copy, and UX cleanup do not require strict `TDD`, but working frontend behavior should still be verified before commit.
|
||||
- Large features use their own branch: `feat/<feature-name>`.
|
||||
- Commits must be atomic.
|
||||
- Push periodically after one or more logically complete `RGR + commit` cycles.
|
||||
- Do not wait for the whole feature to be finished before pushing.
|
||||
- Delete merged feature branches both locally and in `origin`.
|
||||
- During the current refactoring and small-fix phase, changes are merged directly into `main` on GitHub without opening PRs by default.
|
||||
|
||||
## Language rules
|
||||
|
||||
- Commit messages must be in English.
|
||||
- Code identifiers must be in English.
|
||||
- Code comments are avoided by default.
|
||||
- If a code comment is truly unavoidable, it must be in English.
|
||||
|
||||
## Code rules
|
||||
|
||||
- Prefer self-documenting code.
|
||||
- Keep domain logic separate from storage, transport, and orchestration.
|
||||
- Do not create god-structs or giant services.
|
||||
- Keep `pub` surface minimal.
|
||||
- Avoid `unwrap`, `expect`, `todo`, `dbg`, and `panic` in production code.
|
||||
- `unsafe` is forbidden by default.
|
||||
|
||||
## Commands
|
||||
|
||||
Use the canonical commands from `justfile`:
|
||||
|
||||
- `just fmt`
|
||||
- `just fmt-check`
|
||||
- `just check`
|
||||
- `just clippy`
|
||||
- `just test`
|
||||
- `just verify`
|
||||
|
||||
## Current execution mode
|
||||
|
||||
- Build the Rust workspace first.
|
||||
- Keep the UI as a separate app outside the Cargo workspace.
|
||||
- Implement one vertical slice at a time.
|
||||
|
||||
## Task tracking
|
||||
|
||||
- Check `TASKS.md` before starting a new piece of work.
|
||||
- Update `TASKS.md` when a task starts, finishes, or gets blocked.
|
||||
@@ -1,63 +0,0 @@
|
||||
# Contributor License Agreement
|
||||
|
||||
Этот документ описывает условия, на которых проект Crank принимает внешние вклады в код, документацию, тесты, примеры и другие материалы.
|
||||
|
||||
Перед отправкой pull request автор вклада должен согласиться с этими условиями. Если вклад сделан от имени компании, автор подтверждает, что имеет право передать вклад на этих условиях.
|
||||
|
||||
## 1. Что считается вкладом
|
||||
|
||||
Вкладом считается любой материал, намеренно отправленный в проект Crank:
|
||||
|
||||
- исходный код;
|
||||
- тесты;
|
||||
- документация;
|
||||
- примеры конфигурации;
|
||||
- исправления ошибок;
|
||||
- предложения, если они оформлены как конкретные изменения в репозитории.
|
||||
|
||||
## 2. Права на вклад
|
||||
|
||||
Автор вклада подтверждает, что:
|
||||
|
||||
- он является правообладателем вклада или имеет необходимые права для его передачи;
|
||||
- вклад не нарушает права третьих лиц;
|
||||
- вклад не содержит кода или материалов, которые нельзя использовать в проекте Crank на условиях этого соглашения.
|
||||
|
||||
## 3. Лицензия на вклад
|
||||
|
||||
Автор предоставляет владельцу проекта Crank бессрочную, всемирную, безотзывную, неисключительную, безвозмездную лицензию на использование вклада.
|
||||
|
||||
Эта лицензия включает право:
|
||||
|
||||
- использовать вклад;
|
||||
- копировать вклад;
|
||||
- изменять вклад;
|
||||
- объединять вклад с другими материалами;
|
||||
- распространять вклад;
|
||||
- публиковать вклад;
|
||||
- сублицензировать вклад;
|
||||
- включать вклад в проект Crank и производные работы.
|
||||
|
||||
## 4. Патенты
|
||||
|
||||
Если вклад затрагивает патентуемые решения, автор предоставляет владельцу проекта и пользователям Crank безвозмездную лицензию на патентные притязания автора, необходимые для использования вклада в составе Crank.
|
||||
|
||||
## 5. Отсутствие гарантий
|
||||
|
||||
Вклад передается без гарантий. Автор не отвечает за убытки, возникшие из-за использования вклада, если иное прямо не установлено законом или отдельным письменным соглашением.
|
||||
|
||||
## 6. Лицензия проекта
|
||||
|
||||
Crank Community распространяется по лицензии GNU Affero General Public License v3.0 only.
|
||||
|
||||
Владелец проекта может использовать принятые вклады в проекте Crank и производных работах.
|
||||
|
||||
## 7. Как подтвердить согласие
|
||||
|
||||
Отправляя pull request, автор подтверждает согласие с этим CLA.
|
||||
|
||||
В комментарии к pull request можно указать:
|
||||
|
||||
```text
|
||||
I agree to the Crank Contributor License Agreement.
|
||||
```
|
||||
@@ -1,49 +0,0 @@
|
||||
# Участие в разработке
|
||||
|
||||
Crank Community принимает исправления ошибок, улучшения документации, тесты и доработки открытой версии проекта.
|
||||
|
||||
## Лицензия вкладов
|
||||
|
||||
Crank Community распространяется по лицензии GNU Affero General Public License v3.0 only.
|
||||
|
||||
Перед отправкой pull request нужно согласиться с [Contributor License Agreement](./CLA.md). Это нужно, чтобы права на принятые изменения были оформлены явно и проект мог развиваться без юридических неопределенностей.
|
||||
|
||||
## Перед pull request
|
||||
|
||||
Проверьте форматирование и тесты:
|
||||
|
||||
```bash
|
||||
just fmt-check
|
||||
just clippy
|
||||
just test
|
||||
```
|
||||
|
||||
Для изменений веб-интерфейса также выполните:
|
||||
|
||||
```bash
|
||||
cd apps/ui
|
||||
npm ci
|
||||
npm run build
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
## Требования к изменениям
|
||||
|
||||
- Не добавляйте функциональность вне границ Community-версии.
|
||||
- Не добавляйте секреты, токены, приватные адреса и локальные настройки.
|
||||
- Не коммитьте `AGENTS.md`, `TASKS.md`, `.env` и временные файлы.
|
||||
- Для изменений SQL-запросов обновляйте `.sqlx`, если это требуется SQLx.
|
||||
- Для пользовательских изменений обновляйте документацию или примеры.
|
||||
|
||||
## Границы проекта
|
||||
|
||||
В этом репозитории поддерживаются:
|
||||
|
||||
- REST API как источник MCP-инструментов;
|
||||
- простая авторизация администратора;
|
||||
- ключи агентов для MCP-доступа;
|
||||
- одно самостоятельное развертывание;
|
||||
- PostgreSQL как основное хранилище;
|
||||
- необязательный Valkey или Redis для служебного кэша.
|
||||
|
||||
Функции за пределами перечисленного набора не должны попадать в этот репозиторий.
|
||||
Generated
+675
-2054
File diff suppressed because it is too large
Load Diff
+6
-38
@@ -5,69 +5,37 @@ members = [
|
||||
"crates/crank-community-auth",
|
||||
"crates/crank-community-mcp",
|
||||
"crates/crank-core",
|
||||
"crates/crank-import",
|
||||
"crates/crank-schema",
|
||||
"crates/crank-mapping",
|
||||
"crates/crank-observability",
|
||||
"crates/crank-registry",
|
||||
"crates/crank-runtime",
|
||||
"crates/crank-test-support",
|
||||
"crates/crank-trace",
|
||||
"crates/crank-adapter-rest",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
rust-version = "1.96"
|
||||
license = "MIT"
|
||||
rust-version = "1.85"
|
||||
version = "0.3.1"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
axum = "0.8"
|
||||
axum-extra = { version = "0.12", features = ["cookie"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||
base64 = "0.22"
|
||||
hkdf = "0.12"
|
||||
metrics = "0.24.6"
|
||||
metrics-exporter-prometheus = { version = "0.18.3", default-features = false }
|
||||
opentelemetry = { version = "0.32.0", default-features = false, features = ["trace"] }
|
||||
opentelemetry-otlp = { version = "0.32.0", default-features = false, features = ["http-proto", "reqwest-blocking-client", "reqwest-rustls", "trace"] }
|
||||
opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["gen-tonic-messages", "trace"] }
|
||||
opentelemetry_sdk = { version = "0.32.1", default-features = false, features = ["trace"] }
|
||||
percent-encoding = "2"
|
||||
prost = "0.14"
|
||||
rand = "0.10"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
sentry = { version = "0.49.0", default-features = false, features = ["backtrace", "panic", "rustls", "ureq"] }
|
||||
sha2 = "0.10"
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "json", "time", "uuid"] }
|
||||
subtle = "2.6"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "macros", "json", "time"] }
|
||||
thiserror = "2"
|
||||
time = { version = "0.3.53", features = ["formatting", "parsing", "serde"] }
|
||||
time = { version = "0.3", features = ["formatting", "parsing", "serde"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
tower = "0.5"
|
||||
tracing = "0.1"
|
||||
tracing-opentelemetry = { version = "0.33.0", default-features = false }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["serde", "v7"] }
|
||||
testcontainers = { version = "0.27", features = ["blocking"] }
|
||||
testcontainers-modules = { version = "0.15", features = ["postgres", "blocking"] }
|
||||
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
|
||||
[profile.test]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.test.package."*"]
|
||||
debug = false
|
||||
|
||||
@@ -1,661 +1,21 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 bsodfather
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Crank
|
||||
Copyright 2026 bsodfather
|
||||
|
||||
This product includes software developed for the Crank project.
|
||||
|
||||
Crank Community is licensed under the GNU Affero General Public License
|
||||
version 3 only.
|
||||
@@ -1,281 +1,162 @@
|
||||
# Crank
|
||||
|
||||

|
||||
|
||||
Crank - это свободная платформа для создания MCP-инструментов из REST API эндпоинтов.
|
||||
|
||||
Сервис ставится на собственный сервер, подключается к PostgreSQL и дает веб-интерфейс, в котором можно описать REST API, проверить вызов, опубликовать его как инструмент и подключить к MCP-клиенту.
|
||||
|
||||
## Что умеет Crank
|
||||
|
||||
- Создавать REST-инструменты через веб-интерфейс или YAML.
|
||||
- Принимать параметры от MCP-клиента и подставлять их в путь, query string, заголовки или тело REST-запроса.
|
||||
- Преобразовывать ответ REST API в структурированный результат для MCP-клиента.
|
||||
- Публиковать только выбранные инструменты для конкретного агента.
|
||||
- Хранить версии, черновики, примеры запросов и ответов, секреты, журналы вызовов и статистику в PostgreSQL.
|
||||
- Работать с простой авторизацией администратора: email, пароль и браузерная сессия.
|
||||
- Запускаться через Docker Compose.
|
||||
|
||||
## Быстрый запуск через Docker
|
||||
|
||||
Требования:
|
||||
|
||||
- Docker;
|
||||
- Docker Compose;
|
||||
- свободные порты `3000`, `3001`, `3002`;
|
||||
- доступ к опубликованным Docker-образам Crank.
|
||||
|
||||
Создайте рабочую папку:
|
||||
|
||||
```bash
|
||||
mkdir -p crank
|
||||
cd crank
|
||||
```
|
||||
|
||||
Скачайте compose-файл и пример настроек:
|
||||
|
||||
```bash
|
||||
curl -fsSLo docker-compose.yml https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/docker-compose.images.yml
|
||||
curl -fsSLo .env.example https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/.env.images.example
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Откройте скачанный `.env.example` и перенесите нужные значения в `.env`.
|
||||
|
||||
Минимально нужно заменить:
|
||||
|
||||
- `POSTGRES_PASSWORD`;
|
||||
- `CRANK_MASTER_KEY`;
|
||||
- `CRANK_SESSION_SECRET`;
|
||||
- `CRANK_PASSWORD_PEPPER`;
|
||||
- `CRANK_BOOTSTRAP_ADMIN_EMAIL`;
|
||||
- `CRANK_BOOTSTRAP_ADMIN_PASSWORD`;
|
||||
- `CRANK_BASE_URL`.
|
||||
|
||||
Секреты можно сгенерировать так:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Запустите Crank:
|
||||
|
||||
```bash
|
||||
docker compose --profile local-db up -d
|
||||
```
|
||||
|
||||
Если registry требует авторизацию, сначала выполните `docker login git.itexp.me`.
|
||||
|
||||
После запуска:
|
||||
|
||||
- веб-интерфейс: `http://localhost:3000`;
|
||||
- HTTP API панели управления: `http://localhost:3001`;
|
||||
- MCP-сервер: `http://localhost:3002`.
|
||||
|
||||
По умолчанию порты публикуются только на `127.0.0.1`. Это удобно, если перед Crank стоит nginx, Caddy или другой обратный прокси. Если нужно открыть порты наружу напрямую, укажите в `.env`:
|
||||
|
||||
```env
|
||||
CRANK_PUBLISH_BIND=0.0.0.0
|
||||
```
|
||||
|
||||
Проверить состояние контейнеров:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Посмотреть журналы:
|
||||
|
||||
```bash
|
||||
docker compose logs -f admin-api mcp-server ui
|
||||
```
|
||||
|
||||
Остановить сервис:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Обновить Crank до свежих образов:
|
||||
|
||||
```bash
|
||||
docker compose --profile local-db pull
|
||||
docker compose --profile local-db up -d
|
||||
```
|
||||
|
||||
## Запуск с внешним PostgreSQL
|
||||
|
||||
Если PostgreSQL уже запущен отдельно, профиль `local-db` не нужен.
|
||||
|
||||
В `.env` укажите параметры вашей базы:
|
||||
|
||||
- `POSTGRES_HOST`;
|
||||
- `POSTGRES_PORT`;
|
||||
- `POSTGRES_DB`;
|
||||
- `POSTGRES_USER`;
|
||||
- `POSTGRES_PASSWORD`.
|
||||
|
||||
Затем запустите только приложения Crank:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Если используется PgBouncer, укажите его адрес в `POSTGRES_HOST` и порт в `POSTGRES_PORT`.
|
||||
|
||||
## Запуск из исходников
|
||||
|
||||
Если нужно собрать образы самостоятельно, клонируйте репозиторий и используйте корневой [`docker-compose.yml`](./docker-compose.yml):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bsodfather/crank-community.git crank
|
||||
cd crank
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Этот вариант удобен для локальной проверки изменений перед отправкой патча.
|
||||
|
||||
## Как пользоваться
|
||||
|
||||
Обычный сценарий:
|
||||
|
||||
1. Зайти в веб-интерфейс.
|
||||
2. Создать REST-инструмент.
|
||||
3. Описать входные параметры и правила вызова REST API.
|
||||
4. Выполнить пробный запрос.
|
||||
5. Опубликовать инструмент.
|
||||
6. Привязать инструмент к агенту.
|
||||
7. Создать ключ агента.
|
||||
8. Подключить MCP-клиент к адресу агента.
|
||||
|
||||
Каждый агент имеет свой каталог инструментов. MCP-клиент видит только те REST-инструменты, которые явно привязаны к этому агенту.
|
||||
|
||||
Рекомендации по названиям, описаниям, схемам, ошибкам и опасным операциям описаны в документе [Проектирование MCP-инструментов](./docs/tool-design.md).
|
||||
|
||||
## Что входит в эту версию
|
||||
|
||||
Этот репозиторий содержит открытую версию Crank:
|
||||
|
||||
- REST API как источник инструментов;
|
||||
- MCP через Streamable HTTP;
|
||||
- веб-интерфейс администратора;
|
||||
- простая авторизация администратора;
|
||||
- ключи агентов для доступа к MCP;
|
||||
- PostgreSQL как основное хранилище;
|
||||
- необязательный Valkey или Redis для служебного кэша.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```text
|
||||
apps/
|
||||
admin-api/ HTTP API для веб-интерфейса
|
||||
mcp-server/ MCP-сервер
|
||||
ui/ веб-интерфейс
|
||||
|
||||
crates/
|
||||
crank-core/ общая модель данных
|
||||
crank-registry/ работа с PostgreSQL
|
||||
crank-runtime/ выполнение REST-инструментов
|
||||
crank-adapter-rest/ REST-адаптер
|
||||
crank-community-auth/ пароли и сессии
|
||||
crank-community-mcp/ слой MCP
|
||||
crank-mapping/ преобразование данных через JSONPath
|
||||
crank-schema/ проверка схем
|
||||
|
||||
deploy/community/
|
||||
docker-compose.yml запуск с внешним PostgreSQL
|
||||
docker-compose.images.yml запуск готовых образов без исходников
|
||||
.env.example пример настроек для серверного запуска из исходников
|
||||
.env.images.example пример настроек для запуска готовых образов
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
Для разработки нужны:
|
||||
|
||||
- Rust toolchain из [`rust-toolchain.toml`](./rust-toolchain.toml);
|
||||
- Node.js и npm;
|
||||
- PostgreSQL, если запускаете сервисы вручную;
|
||||
- Docker для полного стенда и Rust-тестов с временной PostgreSQL.
|
||||
|
||||
Быстрее всего поднять окружение так же, как для обычного запуска:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bsodfather/crank-community.git crank
|
||||
cd crank
|
||||
cp .env.example .env
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
Приложения читают настройки из переменных окружения. Можно использовать `.env` через свое окружение разработки, `direnv`, IDE или любой другой привычный способ загрузки переменных.
|
||||
|
||||
Rust-тесты сами поднимают временный PostgreSQL через Testcontainers. Отдельно запускать тестовую БД или задавать URL тестовой базы не нужно.
|
||||
|
||||
Сервер панели управления:
|
||||
|
||||
```bash
|
||||
cargo run -p admin-api
|
||||
```
|
||||
|
||||
MCP-сервер:
|
||||
|
||||
```bash
|
||||
cargo run -p mcp-server
|
||||
```
|
||||
|
||||
Веб-интерфейс:
|
||||
|
||||
```bash
|
||||
cd apps/ui
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Проверки:
|
||||
|
||||
```bash
|
||||
just fmt-check
|
||||
just clippy
|
||||
just test
|
||||
```
|
||||
|
||||
Сборка веб-интерфейса:
|
||||

|
||||
|
||||
Crank - платформа для публикации внешних API в виде MCP tools без написания отдельного backend-кода под каждую интеграцию. Целевая модель проекта строится вокруг связки `workspace -> agent -> operations`.
|
||||
|
||||
## Цели
|
||||
|
||||
- Разработать MCP server на Rust.
|
||||
- Поддержать динамическое добавление интеграций через UI или конфигурацию.
|
||||
- Обеспечить единый сценарий работы оператора для REST, GraphQL, gRPC, WebSocket и SOAP.
|
||||
- Нормализовать внешние протоколы в единую внутреннюю модель операции.
|
||||
- Ограничивать набор tools на уровне конкретного агента, а не отдавать один глобальный каталог.
|
||||
- Поддержать workspace-изоляцию, platform access и observability.
|
||||
|
||||
## Целевая модель продукта
|
||||
|
||||
- `Workspace` как tenant boundary.
|
||||
- `Operation` как интеграционный контракт.
|
||||
- `Agent` как curated MCP surface для LLM.
|
||||
- Поддержка REST для `GET`, `POST`, `PUT`, `PATCH` и `DELETE`.
|
||||
- Поддержка GraphQL для `query` и `mutation`.
|
||||
- Поддержка unary и bounded server-streaming для gRPC.
|
||||
- Поддержка WebSocket upstream integrations в bounded execution modes.
|
||||
- Поддержка SOAP/WSDL enterprise integrations.
|
||||
- Поддержка controlled streaming modes поверх MCP `Streamable HTTP`.
|
||||
- Platform API keys и membership layer.
|
||||
- Observability: invocation logs, usage aggregates, latency/error metrics.
|
||||
- Импорт и экспорт operation-конфигураций в `YAML`.
|
||||
- Использование `JSONPath` для точечного маппинга.
|
||||
|
||||
## Структура документации
|
||||
|
||||
- `docs/architecture.md` - целевая архитектура системы.
|
||||
- `docs/as-is-to-be.md` - переход `as is -> to be`, page-by-page gap analysis и архитектурные конфликты.
|
||||
- `docs/backend-gap-plan.md` - конкретный backend-план: сущности, API, БД и порядок реализации.
|
||||
- `docs/operations-workspace-contracts.md` - точные `workspace-scoped` контракты для экранов `Operations` и `Wizard`.
|
||||
- `docs/alpine-ui-integration-plan.md` - постраничный план подключения нового Alpine UI к реальному backend.
|
||||
- `docs/module-decomposition.md` - декомпозиция crates и модулей.
|
||||
- `docs/data-model.md` - целевая модель данных.
|
||||
- `docs/database-schema.md` - целевая схема БД.
|
||||
- `docs/admin-api.md` - целевые HTTP-контракты административного API.
|
||||
- `docs/diagrams.md` - диаграммы компонентов, сущностей и БД.
|
||||
- `docs/mcp-interface.md` - модель MCP transport и agent-scoped publishing.
|
||||
- `docs/testing-strategy.md` - стратегия тестирования.
|
||||
- `docs/manual-regression-checklist.md` - post-integration regression baseline и ручной smoke checklist.
|
||||
- `docs/runtime-config.md` - конфигурация окружения.
|
||||
- `docs/deployment.md` - деплой, reverse proxy и CI/CD.
|
||||
- `docs/deploy-and-staging-smoke.md` - канонический post-deploy smoke pass для staging/production-like окружения.
|
||||
- `docs/authenticated-staging-pass.md` - browser-authenticated pass для UI flows, secrets, wizard и protocol smoke на стенде.
|
||||
- `docs/staging-regression-notes.md` - журнал реальных замечаний и результатов post-deploy проверок на стенде.
|
||||
- `docs/demo-runbook.md` - демонстрационный сценарий.
|
||||
- `docs/public-smoke-targets.md` - готовые публичные upstream-сервисы и payload-ы для smoke-проверки MCP.
|
||||
- `docs/secrets-auth-plan.md` - целевая модель upstream secrets, auth profiles и пошаговый план реализации.
|
||||
- `docs/streaming-mcp-plan.md` - целевая модель MCP transport streaming, upstream streaming и поэтапный план реализации.
|
||||
- `docs/streaming-admin-api.md` - точные HTTP-контракты и DTO для streaming configuration, sessions и jobs.
|
||||
- `docs/streaming-runtime-design.md` - функция-за-функцией разложенная streaming runtime architecture.
|
||||
- `docs/streaming-ui-contract.md` - точный UI-контракт для streaming configuration и test flows.
|
||||
- `docs/protocol-capability-matrix.md` - capability matrix по всем protocol families и execution modes.
|
||||
- `docs/streaming-implementation-spec.md` - execution-oriented план реализации по срезам, файлам, тестам и DoD.
|
||||
- `docs/rust-design.md` - правила распределения поведения в Rust.
|
||||
- `docs/development-rules.md` - правила разработки и workflow.
|
||||
- `docs/rust-code-rules.md` - Rust-specific coding rules.
|
||||
- `docs/implementation-plan.md` - порядок перехода от текущего состояния к целевой модели.
|
||||
- `docs/protocols/rest.md` - требования и ограничения для REST.
|
||||
- `docs/protocols/graphql.md` - требования и ограничения для GraphQL.
|
||||
- `docs/protocols/grpc.md` - требования и ограничения для gRPC.
|
||||
- `docs/protocols/websocket.md` - требования и ограничения для WebSocket.
|
||||
- `docs/protocols/soap.md` - требования и ограничения для SOAP.
|
||||
|
||||
## Ключевая идея продукта
|
||||
|
||||
Система строится вокруг трех уровней:
|
||||
|
||||
- `Workspace` - граница данных и доступа команды.
|
||||
- `Agent` - curated MCP endpoint для конкретного сценария LLM.
|
||||
- `Operation` - низкоуровневый интеграционный контракт.
|
||||
|
||||
`Operation` описывает:
|
||||
|
||||
- внешний протокол;
|
||||
- целевой endpoint или метод;
|
||||
- входную схему;
|
||||
- правила маппинга входных данных;
|
||||
- параметры выполнения;
|
||||
- правила маппинга выходных данных;
|
||||
- метаданные MCP tool.
|
||||
|
||||
`Agent` собирает ограниченный набор опубликованных операций в одну MCP-поверхность. Именно это решает проблему, когда один агент теряется в слишком большом наборе tools.
|
||||
|
||||
## CI/CD статус
|
||||
|
||||
В репозитории настроены:
|
||||
|
||||
- `CI` для Rust, UI и deployment manifests;
|
||||
- `CD`, который на `push` в `main` собирает versioned images, пушит их в registry Gitea и деплоит Community через `deploy/community/docker-compose.yml`;
|
||||
- tag-based release workflow для сборки release bundle и versioned images;
|
||||
- containerized Community deployment через `deploy/community/docker-compose.yml`.
|
||||
|
||||
Важно:
|
||||
|
||||
- workflows лежат в `.gitea/workflows`;
|
||||
- Gitea Actions в этом репозитории рассчитаны только на `self-hosted` runner;
|
||||
- Gitea secrets содержат только AppRole-доступ к OpenBao: `BAO_ADDR`, `BAO_ROLE_ID`, `BAO_SECRET_ID`;
|
||||
- внешние GitHub-specific механики вроде `workflow_run`, `actions/upload-artifact`, `softprops/action-gh-release` и `ghcr.io` intentionally не используются.
|
||||
|
||||
## Поддерживаемые протоколы
|
||||
|
||||
В целевой модели платформа ориентируется на:
|
||||
|
||||
- REST
|
||||
- GraphQL
|
||||
- gRPC
|
||||
- WebSocket
|
||||
- SOAP
|
||||
|
||||
Все пять протокольных семейств входят в целевой product scope. Разница только в очередности реализации.
|
||||
|
||||
## Frontend e2e
|
||||
|
||||
Для UI настроен Playwright-контур, который поднимает локальный стек:
|
||||
|
||||
- `postgres` в отдельном Docker-контейнере;
|
||||
- `admin-api` и `mcp-server` через `cargo run`;
|
||||
- `apps/ui` через локальный Node static+proxy server для e2e;
|
||||
- `CRANK_DEMO_SEED=true` для предсказуемых demo-данных.
|
||||
|
||||
Локальный запуск:
|
||||
|
||||
```bash
|
||||
cd apps/ui
|
||||
npm ci
|
||||
npm run build
|
||||
npm run e2e:install
|
||||
npm run e2e
|
||||
```
|
||||
|
||||
E2E-проверки:
|
||||
Или через `just`:
|
||||
|
||||
```bash
|
||||
cd apps/ui
|
||||
npx playwright test
|
||||
just ui-e2e
|
||||
```
|
||||
|
||||
## Документация
|
||||
Для post-deploy smoke:
|
||||
|
||||
- [Документация](docs/README.md)
|
||||
- [Введение](docs/intro.md)
|
||||
- [Установка](docs/installation.md)
|
||||
- [Первый инструмент](docs/quickstart.md)
|
||||
- [Веб-интерфейс](docs/ui.md)
|
||||
- [MCP-интерфейс](docs/mcp-interface.md)
|
||||
- [Admin API](docs/admin-api.md)
|
||||
- [Практические API-примеры](docs/api-examples.md)
|
||||
- [Production checklist](docs/production-checklist.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
- [Настройки запуска](docs/runtime-config.md)
|
||||
- [Английский README](docs/en/README.md)
|
||||
```bash
|
||||
just staging-smoke https://<domain>
|
||||
```
|
||||
|
||||
## Участие в разработке
|
||||
Для browser-authenticated smoke на реальном стенде:
|
||||
|
||||
Перед отправкой pull request нужно согласиться с [Contributor License Agreement](CLA.md).
|
||||
```bash
|
||||
export CRANK_STAGING_ADMIN_EMAIL=owner@example.com
|
||||
export CRANK_STAGING_ADMIN_PASSWORD=secret
|
||||
just authenticated-staging-smoke https://<domain>
|
||||
```
|
||||
|
||||
Правила участия описаны в [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
Чтобы быстро подготовить запись для `docs/staging-regression-notes.md`:
|
||||
|
||||
## Лицензия
|
||||
|
||||
GNU Affero General Public License v3.0 only
|
||||
```bash
|
||||
just staging-note-block <domain> <deploy-sha> "codex + operator"
|
||||
```
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# TASKS
|
||||
|
||||
## Current
|
||||
|
||||
### `feat/community-finalization`
|
||||
|
||||
Status: done
|
||||
|
||||
Goal:
|
||||
- довести `crank-community` до окончательного самостоятельного состояния как открытой `REST-only` редакции;
|
||||
- убрать transitional split-логику, legacy premium ballast и скрытые assumptions из эпохи общего репозитория.
|
||||
|
||||
Main code areas:
|
||||
- `TASKS.md`
|
||||
- `docs/community-source-whitelist.md`
|
||||
- `docs/implementation-plan.md`
|
||||
- `docs/repository-split-map.md`
|
||||
- `docs/community-release-checklist.md`
|
||||
- `docs/commercial-boundaries.md`
|
||||
- workspace manifests
|
||||
- Community backend/UI tests and fixtures
|
||||
- Community UI copy and localization
|
||||
|
||||
Implementation slices:
|
||||
1. привести документацию и backlog к реальному состоянию `crank-community`;
|
||||
2. убрать premium зависимости из Community workspace manifests;
|
||||
3. очистить Community test boundary и убрать зависимость от `test = false` как способа скрывать legacy tests;
|
||||
4. удалить premium backend ballast, который больше не относится к Community;
|
||||
5. дочистить UI, локализацию и e2e fixtures до честного Community surface;
|
||||
6. пройти финальную верификацию Community release path.
|
||||
|
||||
DoD:
|
||||
- `crank-community` описывает себя как самостоятельный public repository, а не как промежуточный этап split;
|
||||
- Community manifests и workspace dependencies соответствуют `REST-only` product boundary;
|
||||
- Community tests и fixtures проверяют только Community functionality;
|
||||
- UI и docs не содержат рабочих premium flows и misleading copy;
|
||||
- Community release path воспроизводим и проверяем без ссылок на transitional import procedure.
|
||||
|
||||
Verification:
|
||||
- docs consistency pass;
|
||||
- `cargo metadata --no-deps`;
|
||||
- `just fmt`
|
||||
- `just check`
|
||||
- `just test`
|
||||
- UI build and Community-targeted smoke/e2e pass.
|
||||
|
||||
Progress:
|
||||
- done:
|
||||
- `deploy/community/*` already acts as the canonical Community delivery contour
|
||||
- Community capability model is already constrained to:
|
||||
- `REST`
|
||||
- static agent key
|
||||
- `security_level = standard`
|
||||
- public wizard HTML surface is already reduced to the Community `REST` flow
|
||||
- transitional docs and backlog now describe `crank-community` as the current source of truth instead of a future split artifact
|
||||
- premium protocol toolchain dependencies have been removed from the Community workspace manifest while keeping the `REST-only` workspace green under `cargo check`
|
||||
- Community backend crates no longer rely on `test = false`; `just test` now runs against a real Community-only test surface on the isolated local test database
|
||||
- premium protocol and streaming ballast has been removed from Community backend test/dev paths, and Community demo expectations now match the actual `REST-only` demo seed
|
||||
- dormant premium UI modules, fixtures, and translation strings have been removed, while the remaining Community wizard and admin pages stay green under local Playwright smoke
|
||||
- final Community verification pass completed:
|
||||
- `cargo metadata --no-deps`
|
||||
- `just fmt`
|
||||
- `just check`
|
||||
- `just test`
|
||||
- `apps/ui` build
|
||||
- Community-targeted Playwright smoke
|
||||
|
||||
## Next
|
||||
|
||||
### `feat/community-release-hardening`
|
||||
|
||||
Status: done
|
||||
|
||||
Goal:
|
||||
- закрепить воспроизводимый Community release path и release checklist без скрытых переходных допущений.
|
||||
|
||||
Main code areas:
|
||||
- `.github/workflows/ci.yml`
|
||||
- `.github/workflows/deploy.yml`
|
||||
- `deploy/community/*`
|
||||
- `docs/community-release-checklist.md`
|
||||
- `docs/deploy-and-staging-smoke.md`
|
||||
- `docs/authenticated-staging-pass.md`
|
||||
- `docs/public-smoke-targets.md`
|
||||
- `docs/staging-regression-notes.md`
|
||||
- `scripts/staging-smoke.sh`
|
||||
- `scripts/authenticated-staging-smoke.sh`
|
||||
- `scripts/staging-note-block.sh`
|
||||
|
||||
Implementation slices:
|
||||
1. проверить соответствие CI Community manifests;
|
||||
2. зафиксировать Community-only smoke baseline;
|
||||
3. обновить release checklist после финальной cleanup-фазы.
|
||||
|
||||
DoD:
|
||||
- Community release path не зависит от private repositories;
|
||||
- checklist соответствует реальному Community deploy surface;
|
||||
- пост-деплойная проверка повторяема.
|
||||
|
||||
Verification:
|
||||
- `python3` YAML parse for `.github/workflows/ci.yml`
|
||||
- `docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q`
|
||||
- `docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example --profile cache config -q`
|
||||
- `just check`
|
||||
- `apps/ui` build
|
||||
- Community-targeted Playwright smoke including `staging-authenticated.spec.js`
|
||||
|
||||
Progress:
|
||||
- done:
|
||||
- `UI Checks` in `CI` now run real `npm ci` and `npm run build` before the container image build
|
||||
- Community release docs and helper scripts now describe only the real `REST-only` deploy smoke baseline
|
||||
- staging templates and regression notes no longer require `GraphQL`, `gRPC`, `WebSocket`, `SOAP`, or streaming scenarios in Community
|
||||
- Community public smoke targets are reduced to the canonical `REST -> Open-Meteo` path
|
||||
- obsolete split-helper artifact `scripts/export-community.sh` and stale premium smoke payloads have been removed from the public repo
|
||||
|
||||
### `feat/common-public-improvements`
|
||||
|
||||
Status: in_progress
|
||||
|
||||
Goal:
|
||||
- вносить общие улучшения в публичную базу, которые потом синхронно переносятся в `enterprise` и `cloud`.
|
||||
- начать Wave 1 modularization из `modular_decomposition.xml` без изменения поведения Community runtime.
|
||||
|
||||
Main code areas:
|
||||
- общий Community runtime/UI/API surface
|
||||
- `crates/crank-core`
|
||||
- `apps/mcp-server`
|
||||
- `docs/modular_decomposition.xml`
|
||||
|
||||
Implementation slices:
|
||||
1. выполнять общие изменения сначала в `crank-community`;
|
||||
2. брать архитектурные slices из `modular_decomposition.xml`, а не из legacy cleanup backlog;
|
||||
3. после стабилизации базовой границы подтягивать ее в private repos по зафиксированной sync model.
|
||||
|
||||
DoD:
|
||||
- общий функционал не расходится между тремя редакциями без необходимости;
|
||||
- Community остается source base для общей open-core логики.
|
||||
|
||||
Verification:
|
||||
- `cargo check --workspace`
|
||||
- таргетные compile/test checks для затронутого бинаря или crate
|
||||
|
||||
Progress:
|
||||
- done:
|
||||
- Phase 0 / task `0.1`: создан `crank-core::ext` module skeleton
|
||||
- Phase 0 / task `0.2`: `MachineCredentialVerifier` и связанные типы вынесены из `apps/mcp-server` в public seam `crank_core::ext::auth`
|
||||
- Phase 0 / task `0.3`: введены `MachineTokenIssuer`, `NoMachineTokenIssuer`, `TokenIssuerActor` и `TokenIssuerError` в public seam `crank_core::ext::auth`
|
||||
- Phase 0 / task `0.4`: введены `IdentityProvider`, `IdentityProviderKind`, `IdentityError`, `LoginPayload`, `LoginOutcome` и public forwarder type `AuthenticatedIdentity` в `crank_core::ext::auth`
|
||||
- Phase 0 / task `0.5`: введены `PolicyEngine`, `SessionActor`, `PolicyAction`, `PolicyScope`, `PolicyDecision` и default `OwnerOnlyPolicyEngine` в public seam `crank_core::ext::access`
|
||||
- Phase 0 / task `0.6`: введены `AuditSink`, `NoopAuditSink`, `AuditEventId` и базовые audit-типы в public seam `crank_core::ext::audit`
|
||||
- Phase 0 / task `0.7`: введены `CapabilityProfile` и `CommunityCapabilityProfile`, а `admin-api` capability payload теперь собирается через public seam вместо локального literal
|
||||
- Phase 0 / task `0.8`: введены `ProtocolAdapter`, `ProtocolAdapterError`, `AdapterRegistry` и базовые transport-типы (`PreparedRequest`, `AdapterResponse`, `WindowExecutionResult`, `RuntimeRequestContext`) в `crank_core::ext::protocol`
|
||||
- Phase 0 / task `0.9`: `crank-adapter-rest::RestAdapter` реализует новый `ProtocolAdapter` contract, а seam дополнен явным `Target` и window parameters для реального adapter wiring
|
||||
- Phase 0 / task `0.10`: `RuntimeExecutor` переведен с hardcoded adapter fields на `AdapterRegistry`, `crank-runtime` больше не держит protocol feature flags, а общий `PreparedRequest` seam дополнен `timeout_ms` для корректного runtime dispatch
|
||||
- Phase 0 / task `0.11`: добавлены `RuntimeExecutorBuilder` и `community_default()`, а default community runtime wiring вынесен из `RuntimeExecutor` в отдельный builder layer
|
||||
- Phase 0 / task `0.12`: `apps/admin-api` и `apps/mcp-server` переведены на `community_default().with_limits(...).with_response_cache(...).build()` вместо прямой сборки runtime через `RuntimeExecutor::with_limits(...)`
|
||||
- Phase 0 / task `0.13`: введены `RegistryExtension`, `ExtensionMigration` и `apply_extension_migrations(...)` в `crank-registry` как отдельный public seam для additive private migrations
|
||||
- Phase 0 / task `0.14`: добавлены `AdminServiceBuilder`, seam slots (`identity_provider`, `policy_engine`, `audit_sink`, `token_issuer`, `capability_profile`) и community defaults для них; `apps/admin-api/src/main.rs` переведен на builder
|
||||
- Phase 0 / task `0.15`: route delegation переведен на public seams — `capabilities` route идет через `capability_profile`, write handlers в `routes/access.rs` проверяют `policy_engine` и пишут generic audit events через `audit_sink`, а `machine_auth` route использует `token_issuer` seam и сохраняет текущий Community `403` contract
|
||||
- Phase 0 / task `0.16`: phase verification gate пройден — `just fmt`, `just check`, `just test`, таргетные Community machine-auth tests и MCP initialize smoke (`requires_initialized_notification_before_tool_methods`) зеленые; runtime regression test обновлен под новый `RuntimeError::ProtocolAdapter` contract
|
||||
- Phase 1 / tasks `1.1`–`1.3`: strict-REST Community cleanup уже был закрыт ранее — non-REST adapters и `crank-proto` удалены из workspace, descriptor/premium UI surface убран из Community build, wizard и i18n приведены к REST-only baseline
|
||||
- Phase 1 / task `1.4`: создан crate `crank-community-auth`; в него вынесены password hashing/session cookie primitives и `PasswordIdentityProvider`, `admin-api` переключен на этот crate, а `login()` теперь реально делегирует credential check через `IdentityProvider` seam
|
||||
- Phase 1 / task `1.5`: создан crate `crank-community-mcp`; в него вынесены `build_app`, `catalog`, `jsonrpc`, `session` и `auth` из `apps/mcp-server`, а `apps/mcp-server/src/main.rs` стал thin launcher с env parsing и DI wiring
|
||||
- Phase 1 / task `1.6`: Community test surface уже приведен к честному baseline — `test = false` удален ранее, premium-only tests вычищены, `just verify` проходит на текущем составе workspace
|
||||
- Phase 1 / task `1.7`: workspace version bumped to `0.2.0`; release gate пройден (`just verify`, `npm run build`, `npm run e2e`), release commit и tag `v0.2.0` подготовлены в `crank-community`
|
||||
- Phase 2 dependency slice: `IdentityProvider` seam widened to cover `SSO/TOTP` with default `NotSupportedForProvider` methods and shared public DTOs for authorize/callback/two-factor flows; workspace version bumped to `0.3.0` for downstream enterprise consumption
|
||||
- Phase 2 dependency slice: `apps/admin-api` now exposes a reusable lib target (`src/lib.rs`), so downstream private repos can depend on `build_app`, `AppState`, `AdminServiceBuilder`, and auth/state modules without copying the Community admin app
|
||||
- Phase 3 dependency slice: added public tenancy seam in `crank-core` — `TenantId`, `TenantResolutionContext`, `TenantController`, `TenancyError`, and `SingleTenantController` — so `cloud` can build hosted tenant routing without bypassing the shared extension model
|
||||
- Phase 3 dependency slice: added public metering seam in `crank-core` — `MeteringEvent`, `MeteringSink`, `SharedMeteringSink`, and `NoopMeteringSink` — so `cloud` can add hosted usage capture without introducing private-only contracts
|
||||
- Phase 3 dependency slice: added public billing seam in `crank-core` — `BillingHook`, `BillingGate`, `BillingError`, `SharedBillingHook`, and `NoopBillingHook` — so `cloud` can layer billing policy without private-only contracts in the base repo
|
||||
- Phase 3 runtime slice: `RuntimeRequestContext` now carries optional metering context (`workspace_id`, `agent_id`, `source`), `RuntimeExecutorBuilder` accepts a `MeteringSink`, and `RuntimeExecutor` emits `MeteringEvent` on invocation completion while Community apps keep using the default `NoopMeteringSink`
|
||||
- Phase 3 dependency slice: added `CacheBackendFactory` in `crank-runtime` with `BuiltinCacheBackendFactory`; unlike the original draft, the seam lives in runtime rather than core to avoid a `crank-core -> crank-runtime` dependency cycle around `RuntimeCacheStores`
|
||||
- Phase 5 / task `5.1`: added Community UI overlay foundation — `slot-registry.js`, `overlay-loader.js`, optional `CRANK_UI_OVERLAY_DIR` copy path in the UI build, settings slot mounts, and wizard protocol-card slot mounts; Community build stays noop without any private overlay package
|
||||
- backward-compatible alias `CommunityMachineCredentialVerifier` сохранен, поведение Community не изменено
|
||||
|
||||
### `feat/community-release-pipeline`
|
||||
|
||||
Status: in_progress
|
||||
|
||||
Goal:
|
||||
- закрыть `Phase 6.1` и сделать reproducible tag-release flow для `crank-community`.
|
||||
|
||||
Main code areas:
|
||||
- `.github/workflows/release.yml`
|
||||
- `apps/admin-api/Dockerfile`
|
||||
- `apps/mcp-server/Dockerfile`
|
||||
- `apps/ui/Dockerfile`
|
||||
|
||||
Implementation slices:
|
||||
1. build release binaries and UI dist on `push tag v*`;
|
||||
2. publish Community images to `GHCR`;
|
||||
3. attach checksums and SBOM to GitHub Release.
|
||||
|
||||
DoD:
|
||||
- `crank-community` выпускается из собственного repo без private inputs;
|
||||
- tag push `v*` produces GitHub Release artifacts and container images.
|
||||
|
||||
Verification:
|
||||
- `python3` YAML parse for `.github/workflows/release.yml`
|
||||
@@ -3,7 +3,6 @@ name = "admin-api"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -17,14 +16,10 @@ axum-extra.workspace = true
|
||||
base64.workspace = true
|
||||
crank-community-auth = { path = "../../crates/crank-community-auth" }
|
||||
crank-core = { path = "../../crates/crank-core" }
|
||||
crank-import = { path = "../../crates/crank-import" }
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
crank-observability = { path = "../../crates/crank-observability" }
|
||||
crank-registry = { path = "../../crates/crank-registry" }
|
||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
crank-trace = { path = "../../crates/crank-trace" }
|
||||
metrics.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -40,10 +35,5 @@ uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
crank-test-support = { path = "../../crates/crank-test-support" }
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
reqwest.workspace = true
|
||||
serial_test = "3"
|
||||
tower.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.96.1-bookworm AS deps
|
||||
FROM rust:1.85-bookworm AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -36,7 +36,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
SQLX_OFFLINE=true cargo build --release -p admin-api
|
||||
|
||||
FROM rust:1.96.1-bookworm AS builder
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+3807
-38
File diff suppressed because it is too large
Load Diff
@@ -1,563 +0,0 @@
|
||||
use crank_core::{
|
||||
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
|
||||
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
|
||||
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
|
||||
ToolSelectionPolicy, UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
|
||||
};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_registry::{
|
||||
PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown, UsageOperationBreakdown,
|
||||
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
|
||||
};
|
||||
use crank_schema::Schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct LoginPayload {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SessionResponse {
|
||||
pub user: crank_core::User,
|
||||
pub memberships: Vec<WorkspaceMembershipRecord>,
|
||||
pub current_workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UpdateProfilePayload {
|
||||
pub display_name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ChangePasswordPayload {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UpdateCurrentWorkspacePayload {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct OperationPayload {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
#[serde(default = "default_operation_category")]
|
||||
pub category: String,
|
||||
pub protocol: Protocol,
|
||||
#[serde(default)]
|
||||
pub security_level: OperationSecurityLevel,
|
||||
pub target: Target,
|
||||
pub input_schema: Schema,
|
||||
pub output_schema: Schema,
|
||||
pub input_mapping: MappingSet,
|
||||
pub output_mapping: MappingSet,
|
||||
pub execution_config: crank_core::ExecutionConfig,
|
||||
pub tool_description: crank_core::ToolDescription,
|
||||
#[serde(default)]
|
||||
pub wizard_state: Option<WizardState>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct NewVersionPayload {
|
||||
#[serde(flatten)]
|
||||
pub operation: OperationPayload,
|
||||
#[serde(default)]
|
||||
pub change_note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct PublishPayload {
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct TestRunPayload {
|
||||
pub version: u32,
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct TestRunResult {
|
||||
pub ok: bool,
|
||||
pub mode: ExecutionMode,
|
||||
pub request_preview: Value,
|
||||
pub response_preview: Value,
|
||||
pub errors: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AuthProfilePayload {
|
||||
pub name: String,
|
||||
pub kind: AuthKind,
|
||||
pub config: AuthConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UpstreamPayload {
|
||||
pub name: String,
|
||||
pub base_url: String,
|
||||
#[serde(default)]
|
||||
pub static_headers: Value,
|
||||
#[serde(default)]
|
||||
pub auth_profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SecretPayload {
|
||||
pub name: String,
|
||||
pub kind: SecretKind,
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct RotateSecretPayload {
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct WorkspacePayload {
|
||||
pub slug: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub settings: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UpdateWorkspacePayload {
|
||||
pub slug: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub status: Option<WorkspaceStatus>,
|
||||
pub settings: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AgentPayload {
|
||||
pub slug: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub instructions: Value,
|
||||
#[serde(default)]
|
||||
pub tool_selection_policy: ToolSelectionPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UpdateAgentPayload {
|
||||
pub slug: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AgentBindingPayload {
|
||||
pub operation_id: String,
|
||||
pub operation_version: u32,
|
||||
pub tool_name: String,
|
||||
pub tool_title: String,
|
||||
pub tool_description_override: Option<String>,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ToolSearchPreviewPayload {
|
||||
pub query: String,
|
||||
#[serde(default)]
|
||||
pub group_ids: Vec<String>,
|
||||
pub bindings: Vec<AgentBindingPayload>,
|
||||
pub tool_selection_policy: ToolSelectionPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum AgentCatalogPayload {
|
||||
Bindings(Vec<AgentBindingPayload>),
|
||||
Config {
|
||||
bindings: Vec<AgentBindingPayload>,
|
||||
tool_selection_policy: ToolSelectionPolicy,
|
||||
},
|
||||
}
|
||||
|
||||
impl AgentCatalogPayload {
|
||||
pub fn into_parts(self) -> (Vec<AgentBindingPayload>, Option<ToolSelectionPolicy>) {
|
||||
match self {
|
||||
Self::Bindings(bindings) => (bindings, None),
|
||||
Self::Config {
|
||||
bindings,
|
||||
tool_selection_policy,
|
||||
} => (bindings, Some(tool_selection_policy)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<AgentBindingPayload>> for AgentCatalogPayload {
|
||||
fn from(bindings: Vec<AgentBindingPayload>) -> Self {
|
||||
Self::Bindings(bindings)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CreatedAgentResponse {
|
||||
pub agent_id: String,
|
||||
pub workspace_id: String,
|
||||
pub version: u32,
|
||||
pub status: AgentStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PublishAgentResponse {
|
||||
pub agent_id: String,
|
||||
pub workspace_id: String,
|
||||
pub published_version: u32,
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AgentSummaryView {
|
||||
pub id: String,
|
||||
pub workspace_id: String,
|
||||
pub slug: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
pub status: AgentStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub published_at: Option<String>,
|
||||
pub operation_count: usize,
|
||||
pub operation_ids: Vec<String>,
|
||||
pub tool_selection_policy: ToolSelectionPolicy,
|
||||
pub key_count: usize,
|
||||
pub calls_today: u64,
|
||||
pub mcp_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AgentMutationResult {
|
||||
pub agent_id: String,
|
||||
pub workspace_id: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct PlatformApiKeyPayload {
|
||||
pub name: String,
|
||||
#[serde(default = "default_platform_api_key_kind")]
|
||||
pub key_kind: PlatformApiKeyKind,
|
||||
pub scopes: Vec<PlatformApiKeyScope>,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_platform_api_key_kind() -> PlatformApiKeyKind {
|
||||
PlatformApiKeyKind::McpClient
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CreatedPlatformApiKeyResponse {
|
||||
pub api_key: PlatformApiKeyRecord,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct WorkspaceCatalogSnapshotResponse {
|
||||
pub kind: String,
|
||||
pub format_version: String,
|
||||
pub restorable: bool,
|
||||
pub included: Vec<String>,
|
||||
pub excluded: Vec<String>,
|
||||
pub workspace: WorkspaceRecord,
|
||||
pub operations: Vec<OperationSummaryView>,
|
||||
pub agents: Vec<AgentSummaryView>,
|
||||
pub platform_api_keys: Vec<PlatformApiKeyRecord>,
|
||||
pub exported_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct LogsQuery {
|
||||
pub level: Option<InvocationLevel>,
|
||||
pub search: Option<String>,
|
||||
pub source: Option<InvocationSource>,
|
||||
pub operation_id: Option<String>,
|
||||
pub agent_id: Option<String>,
|
||||
pub period: Option<UsagePeriod>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ApprovalsQuery {
|
||||
pub status: Option<ApprovalRequestStatus>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UsageRequestQuery {
|
||||
pub period: Option<UsagePeriod>,
|
||||
pub source: Option<InvocationSource>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct UsageOverviewResponse {
|
||||
pub summary: UsageSummary,
|
||||
pub timeline: Vec<UsageTimelinePoint>,
|
||||
pub operations: Vec<UsageOperationBreakdown>,
|
||||
pub agents: Vec<UsageAgentBreakdown>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ProtocolCapabilityView {
|
||||
pub protocol: Protocol,
|
||||
pub supports_execution_modes: Vec<ExecutionMode>,
|
||||
pub supports_transport_behaviors: Vec<String>,
|
||||
pub supports_auth_kinds: Vec<String>,
|
||||
pub supports_upload_artifacts: Vec<String>,
|
||||
pub supports_cursor_path: bool,
|
||||
pub supports_done_path: bool,
|
||||
pub supports_aggregation_mode: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct GenerateDraftPayload {
|
||||
#[serde(default)]
|
||||
pub sources: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DraftGenerationResult {
|
||||
pub generated_draft: GeneratedDraft,
|
||||
pub input_schema: Schema,
|
||||
pub output_schema: Schema,
|
||||
pub input_mapping: MappingSet,
|
||||
pub output_mapping: MappingSet,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ImportQuery {
|
||||
#[serde(default)]
|
||||
pub mode: ImportMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImportMode {
|
||||
#[default]
|
||||
Create,
|
||||
Upsert,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ExportQuery {
|
||||
pub version: Option<u32>,
|
||||
#[serde(default = "default_export_mode")]
|
||||
pub mode: ExportMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct YamlOperationDocument {
|
||||
pub format_version: String,
|
||||
pub kind: String,
|
||||
pub operation: RegistryOperation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct OpenApiImportPreviewPayload {
|
||||
pub document: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OpenApiImportPreviewResponse {
|
||||
pub job_id: String,
|
||||
pub expires_at: String,
|
||||
pub preview: crank_import::rest::ImportPreview,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct OpenApiImportCreatePayload {
|
||||
pub selected_operation_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub server_url: Option<String>,
|
||||
#[serde(default = "default_openapi_conflict_mode")]
|
||||
pub conflict_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OpenApiImportCreateResponse {
|
||||
pub created: Vec<OpenApiImportCreatedOperation>,
|
||||
pub skipped: Vec<OpenApiImportSkippedOperation>,
|
||||
pub findings: Vec<crank_import::rest::ImportFinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OpenApiImportCreatedOperation {
|
||||
pub operation_id: String,
|
||||
pub name: String,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OpenApiImportSkippedOperation {
|
||||
pub operation_key: String,
|
||||
pub name: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CreatedOperationResponse {
|
||||
pub operation_id: String,
|
||||
pub workspace_id: String,
|
||||
pub version: u32,
|
||||
pub status: OperationStatus,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PublishResponse {
|
||||
pub operation_id: String,
|
||||
pub workspace_id: String,
|
||||
pub published_version: u32,
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UpdateOperationPayload {
|
||||
pub display_name: String,
|
||||
#[serde(default = "default_operation_category")]
|
||||
pub category: String,
|
||||
#[serde(default)]
|
||||
pub security_level: OperationSecurityLevel,
|
||||
pub target: Target,
|
||||
pub input_schema: Schema,
|
||||
pub output_schema: Schema,
|
||||
pub input_mapping: MappingSet,
|
||||
pub output_mapping: MappingSet,
|
||||
pub execution_config: crank_core::ExecutionConfig,
|
||||
pub tool_description: crank_core::ToolDescription,
|
||||
#[serde(default)]
|
||||
pub wizard_state: Option<WizardState>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OperationUsageSummaryView {
|
||||
pub calls_today: u64,
|
||||
pub error_rate_pct: f64,
|
||||
pub avg_latency_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OperationAgentRefView {
|
||||
pub agent_id: String,
|
||||
pub agent_slug: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OperationSummaryView {
|
||||
pub id: String,
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub category: String,
|
||||
pub protocol: Protocol,
|
||||
pub security_level: OperationSecurityLevel,
|
||||
pub target_url: String,
|
||||
pub target_action: String,
|
||||
pub status: OperationStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub published_at: Option<String>,
|
||||
pub usage_summary: OperationUsageSummaryView,
|
||||
pub agent_refs: Vec<OperationAgentRefView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OperationDetailView {
|
||||
pub id: String,
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub category: String,
|
||||
pub protocol: Protocol,
|
||||
pub security_level: OperationSecurityLevel,
|
||||
pub status: OperationStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub published_at: Option<String>,
|
||||
pub draft_version_ref: VersionRef,
|
||||
pub published_version_ref: Option<VersionRef>,
|
||||
pub agent_refs: Vec<OperationAgentRefView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct VersionRef {
|
||||
pub version: u32,
|
||||
pub status: OperationStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OperationMutationResult {
|
||||
pub operation_id: String,
|
||||
pub workspace_id: String,
|
||||
pub version: u32,
|
||||
pub status: OperationStatus,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ImportResponse {
|
||||
pub operation_id: String,
|
||||
pub workspace_id: String,
|
||||
pub version: u32,
|
||||
pub import_mode: ImportMode,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DescriptorUploadResponse {
|
||||
pub descriptor_id: String,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
fn default_operation_category() -> String {
|
||||
"general".to_owned()
|
||||
}
|
||||
|
||||
fn default_openapi_conflict_mode() -> String {
|
||||
"rename".to_owned()
|
||||
}
|
||||
|
||||
fn default_export_mode() -> ExportMode {
|
||||
ExportMode::Portable
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) struct InvocationRecordRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub agent_id: Option<&'a AgentId>,
|
||||
pub operation: &'a RegistryOperation,
|
||||
pub request_id: Option<&'a str>,
|
||||
pub source: InvocationSource,
|
||||
pub level: InvocationLevel,
|
||||
pub status: InvocationStatus,
|
||||
pub message: String,
|
||||
pub status_code: Option<u16>,
|
||||
pub error_kind: Option<String>,
|
||||
pub duration_ms: u64,
|
||||
pub request_preview: Value,
|
||||
pub response_preview: Value,
|
||||
}
|
||||
+141
-61
@@ -109,6 +109,13 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn forbidden_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::Forbidden {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
||||
@@ -137,24 +144,16 @@ impl ApiError {
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
match &self {
|
||||
Self::Internal { .. } => {
|
||||
error!(
|
||||
name: "admin.response.internal_error",
|
||||
error_code = self.code(),
|
||||
"internal API error response"
|
||||
)
|
||||
Self::Internal { message, .. } => {
|
||||
error!(error_code = self.code(), error_message = %message)
|
||||
}
|
||||
Self::Unauthorized { .. }
|
||||
| Self::Forbidden { .. }
|
||||
| Self::Validation { .. }
|
||||
| Self::NotFound { .. }
|
||||
| Self::Conflict { .. }
|
||||
| Self::RateLimited { .. } => {
|
||||
warn!(
|
||||
name: "admin.response.rejected",
|
||||
error_code = self.code(),
|
||||
"API request rejected"
|
||||
)
|
||||
Self::Unauthorized { message, .. }
|
||||
| Self::Forbidden { message, .. }
|
||||
| Self::Validation { message, .. }
|
||||
| Self::NotFound { message, .. }
|
||||
| Self::Conflict { message, .. }
|
||||
| Self::RateLimited { message, .. } => {
|
||||
warn!(error_code = self.code(), error_message = %message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +224,14 @@ impl From<RegistryError> for ApiError {
|
||||
format!("secret {secret_id} was not found"),
|
||||
json!({ "secret_id": secret_id }),
|
||||
),
|
||||
RegistryError::StreamSessionNotFound { session_id } => Self::not_found_with_context(
|
||||
format!("stream session {session_id} was not found"),
|
||||
json!({ "session_id": session_id }),
|
||||
),
|
||||
RegistryError::AsyncJobNotFound { job_id } => Self::not_found_with_context(
|
||||
format!("async job {job_id} was not found"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context(
|
||||
format!("invocation log {log_id} was not found"),
|
||||
json!({ "log_id": log_id }),
|
||||
@@ -292,6 +299,28 @@ impl From<RegistryError> for ApiError {
|
||||
"auth_profile_id": auth_profile_id,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidStreamSessionTransition {
|
||||
session_id,
|
||||
from,
|
||||
to,
|
||||
} => Self::conflict_with_context(
|
||||
format!("invalid stream session transition for {session_id}: {from} -> {to}"),
|
||||
json!({
|
||||
"session_id": session_id,
|
||||
"from": from,
|
||||
"to": to,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidAsyncJobTransition { job_id, from, to } => {
|
||||
Self::conflict_with_context(
|
||||
format!("invalid async job transition for {job_id}: {from} -> {to}"),
|
||||
json!({
|
||||
"job_id": job_id,
|
||||
"from": from,
|
||||
"to": to,
|
||||
}),
|
||||
)
|
||||
}
|
||||
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
|
||||
format!("user with email {email} already exists"),
|
||||
json!({ "email": email }),
|
||||
@@ -318,18 +347,6 @@ impl From<RegistryError> for ApiError {
|
||||
"actual": actual,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidAgentVersionSequence {
|
||||
agent_id,
|
||||
expected,
|
||||
actual,
|
||||
} => Self::validation_with_context(
|
||||
format!("agent {agent_id} expected next version {expected}, got {actual}"),
|
||||
json!({
|
||||
"agent_id": agent_id,
|
||||
"expected": expected,
|
||||
"actual": actual,
|
||||
}),
|
||||
),
|
||||
RegistryError::ImmutableOperationFieldChanged {
|
||||
operation_id,
|
||||
field,
|
||||
@@ -355,14 +372,6 @@ impl From<RegistryError> for ApiError {
|
||||
format!("yaml import job {job_id} was not found"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::ImportJobNotFound { job_id } => Self::not_found_with_context(
|
||||
format!("import job {job_id} was not found"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::ImportJobAlreadyApplied { job_id } => Self::conflict_with_context(
|
||||
format!("import job {job_id} was already applied with different parameters"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::Storage(_) | RegistryError::Serialization(_) => {
|
||||
Self::internal(value.to_string())
|
||||
}
|
||||
@@ -411,19 +420,18 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
RuntimeError::Schema(_) => "runtime_schema_error",
|
||||
RuntimeError::Mapping(_) => "runtime_mapping_error",
|
||||
RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error",
|
||||
RuntimeError::GrpcAdapter(_) => "runtime_grpc_error",
|
||||
RuntimeError::RestAdapter(_) => "runtime_rest_error",
|
||||
RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error",
|
||||
RuntimeError::SoapAdapter(_) => "runtime_soap_error",
|
||||
RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
||||
RuntimeError::ConfirmationRequired { .. } => "runtime_confirmation_required",
|
||||
RuntimeError::InvalidConfirmationToken { .. } => "runtime_confirmation_error",
|
||||
RuntimeError::ConfirmationStoreUnavailable { .. } => "runtime_confirmation_unavailable",
|
||||
RuntimeError::IdempotencyStoreUnavailable { .. } => "runtime_idempotency_unavailable",
|
||||
RuntimeError::IdempotencyInProgress { .. } => "runtime_idempotency_in_progress",
|
||||
RuntimeError::IdempotencyConflict { .. } => "runtime_idempotency_conflict",
|
||||
RuntimeError::IdempotencyOutcomeUnknown { .. } => "runtime_idempotency_outcome_unknown",
|
||||
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
|
||||
RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"runtime_secret_error"
|
||||
@@ -439,23 +447,9 @@ pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
|
||||
"field": field,
|
||||
"reason": reason,
|
||||
})),
|
||||
RuntimeError::ConfirmationRequired {
|
||||
confirmation_token,
|
||||
expires_in_ms,
|
||||
safety_class,
|
||||
..
|
||||
} => Some(json!({
|
||||
"confirmation_token": confirmation_token,
|
||||
"expires_in_ms": expires_in_ms,
|
||||
"safety_class": safety_class,
|
||||
})),
|
||||
RuntimeError::InvalidConfirmationToken { operation_id }
|
||||
| RuntimeError::ConfirmationStoreUnavailable { operation_id }
|
||||
| RuntimeError::IdempotencyStoreUnavailable { operation_id }
|
||||
| RuntimeError::IdempotencyInProgress { operation_id }
|
||||
| RuntimeError::IdempotencyConflict { operation_id }
|
||||
| RuntimeError::IdempotencyOutcomeUnknown { operation_id } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
RuntimeError::InvalidStreamingPayload { field, reason } => Some(json!({
|
||||
"field": field,
|
||||
"reason": reason,
|
||||
})),
|
||||
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
@@ -475,6 +469,9 @@ pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
|
||||
"secret_id": secret_id,
|
||||
"version": version,
|
||||
})),
|
||||
RuntimeError::MissingStreamingConfig { operation_id } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
})),
|
||||
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
"mode": mode,
|
||||
@@ -489,3 +486,86 @@ pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{ApiError, runtime_error_context, runtime_test_failure};
|
||||
use crank_registry::RegistryError;
|
||||
use crank_runtime::RuntimeError;
|
||||
|
||||
#[test]
|
||||
fn runtime_test_failure_includes_structured_context() {
|
||||
let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest {
|
||||
field: "request.headers".to_owned(),
|
||||
reason: "must be an object".to_owned(),
|
||||
});
|
||||
|
||||
assert_eq!(payload["code"], "runtime_request_error");
|
||||
assert_eq!(
|
||||
payload["context"],
|
||||
json!({
|
||||
"field": "request.headers",
|
||||
"reason": "must be an object"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_error_context_includes_secret_crypto_operation() {
|
||||
let context = runtime_error_context(&RuntimeError::SecretCrypto {
|
||||
operation: "decode secret envelope",
|
||||
details: "bad base64".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
context,
|
||||
json!({
|
||||
"operation": "decode secret envelope",
|
||||
"details": "bad base64"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_test_failure_includes_runtime_overload_context() {
|
||||
let payload = runtime_test_failure(&RuntimeError::ConcurrencyLimitExceeded {
|
||||
kind: "window",
|
||||
limit: 16,
|
||||
});
|
||||
|
||||
assert_eq!(payload["code"], "runtime_overloaded");
|
||||
assert_eq!(
|
||||
payload["context"],
|
||||
json!({
|
||||
"kind": "window",
|
||||
"limit": 16
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_errors_preserve_structured_context_in_api_error() {
|
||||
let error = ApiError::from(RegistryError::InvalidAsyncJobTransition {
|
||||
job_id: "job_123".to_owned(),
|
||||
from: "running".to_owned(),
|
||||
to: "completed".to_owned(),
|
||||
});
|
||||
|
||||
match error {
|
||||
ApiError::Conflict { context, .. } => {
|
||||
assert_eq!(
|
||||
context,
|
||||
Some(json!({
|
||||
"job_id": "job_123",
|
||||
"from": "running",
|
||||
"to": "completed"
|
||||
}))
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected error variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
use crank_core::{HttpMethod, Target};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_registry::RegistryOperation;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
|
||||
pub fn import_guidance_warnings(operation: &RegistryOperation) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if is_generic_tool_name(&operation.name) {
|
||||
warnings.push(
|
||||
"Имя инструмента слишком общее. Используйте конкретное действие и объект, например get_customer_by_email.".to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
if operation
|
||||
.tool_description
|
||||
.description
|
||||
.trim()
|
||||
.chars()
|
||||
.count()
|
||||
< 40
|
||||
{
|
||||
warnings.push(
|
||||
"Описание инструмента короткое. Добавьте, когда его вызывать, какие входные данные нужны и что означает успешный ответ.".to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
if schema_has_multi_action_field(&operation.input_schema) {
|
||||
warnings.push(
|
||||
"Входная схема похожа на endpoint с несколькими действиями. Если параметр action/mode/type выбирает разные сценарии, лучше разделить это на несколько MCP-инструментов.".to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
if output_mapping_returns_broad_response(&operation.output_mapping) {
|
||||
warnings.push(
|
||||
"Настройка результата может отдавать агенту слишком широкий ответ. Выберите только поля, которые нужны модели для следующего шага.".to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
match &operation.target {
|
||||
Target::Rest(target) => {
|
||||
if operation.category == "general" {
|
||||
warnings.push(
|
||||
"Операция импортирована в общей категории. Перед привязкой к агенту сгруппируйте похожие endpoint-ы по конкретной задаче.".to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
if target.method != HttpMethod::Get && operation.execution_config.idempotency.is_none()
|
||||
{
|
||||
warnings.push(
|
||||
"Операция меняет состояние и не задает ключ идемпотентности. Для POST, PUT и PATCH добавьте стабильный ключ, если повторный вызов может создать дубль.".to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
if target.method == HttpMethod::Delete {
|
||||
warnings.push(
|
||||
"DELETE будет выполняться через двухшаговое подтверждение: первый вызов выдаст токен, второй выполнит запрос.".to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warnings
|
||||
}
|
||||
|
||||
fn is_generic_tool_name(name: &str) -> bool {
|
||||
matches!(
|
||||
name.trim().to_ascii_lowercase().as_str(),
|
||||
"call_api" | "execute" | "execute_request" | "manage" | "manage_resource" | "request"
|
||||
)
|
||||
}
|
||||
|
||||
fn schema_has_multi_action_field(schema: &Schema) -> bool {
|
||||
if !matches!(schema.kind, SchemaKind::Object) {
|
||||
return false;
|
||||
}
|
||||
|
||||
["action", "mode", "operation", "type"]
|
||||
.iter()
|
||||
.any(|field_name| schema.fields.contains_key(*field_name))
|
||||
}
|
||||
|
||||
fn output_mapping_returns_broad_response(mapping: &MappingSet) -> bool {
|
||||
mapping.rules.iter().any(|rule| {
|
||||
let source = rule.source.trim();
|
||||
let target = rule.target.trim();
|
||||
matches!(source, "$.response" | "$.response.body" | "$.response.data")
|
||||
|| matches!(target, "$.output" | "$")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_core::{
|
||||
ExecutionConfig, HttpMethod, Operation, OperationId, OperationSecurityLevel,
|
||||
OperationStatus, Protocol, RestTarget, Target, ToolDescription,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::RegistryOperation;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use super::import_guidance_warnings;
|
||||
|
||||
#[test]
|
||||
fn flags_generic_multi_action_delete() {
|
||||
let operation = imported_delete_operation();
|
||||
|
||||
let warnings = import_guidance_warnings(&operation);
|
||||
let joined = warnings.join("\n");
|
||||
|
||||
assert!(joined.contains("Имя инструмента слишком общее"));
|
||||
assert!(joined.contains("несколькими действиями"));
|
||||
assert!(joined.contains("Настройка результата"));
|
||||
assert!(joined.contains("ключ идемпотентности"));
|
||||
assert!(joined.contains("DELETE будет выполняться"));
|
||||
assert!(joined.contains("сгруппируйте похожие endpoint-ы"));
|
||||
}
|
||||
|
||||
fn imported_delete_operation() -> RegistryOperation {
|
||||
Operation {
|
||||
id: OperationId::new("op_imported_delete"),
|
||||
name: "call_api".to_owned(),
|
||||
display_name: "Call API".to_owned(),
|
||||
category: "general".to_owned(),
|
||||
protocol: Protocol::Rest,
|
||||
security_level: OperationSecurityLevel::Standard,
|
||||
status: OperationStatus::Draft,
|
||||
version: 1,
|
||||
target: Target::Rest(RestTarget {
|
||||
base_url: "http://example.invalid".to_owned(),
|
||||
method: HttpMethod::Delete,
|
||||
path_template: "/items/{id}".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
}),
|
||||
input_schema: Schema {
|
||||
kind: SchemaKind::Object,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::from([("action".to_owned(), string_schema())]),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
},
|
||||
output_schema: Schema {
|
||||
kind: SchemaKind::Object,
|
||||
description: None,
|
||||
required: false,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::new(),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
},
|
||||
input_mapping: MappingSet { rules: Vec::new() },
|
||||
output_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.response.body".to_owned(),
|
||||
target: "$.output".to_owned(),
|
||||
required: false,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Call API".to_owned(),
|
||||
description: "Calls API".to_owned(),
|
||||
tags: Vec::new(),
|
||||
examples: Vec::new(),
|
||||
},
|
||||
samples: None,
|
||||
generated_draft: None,
|
||||
config_export: None,
|
||||
wizard_state: None,
|
||||
created_at: OffsetDateTime::UNIX_EPOCH,
|
||||
updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn string_schema() -> Schema {
|
||||
Schema {
|
||||
kind: SchemaKind::String,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::new(),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod dto;
|
||||
pub mod error;
|
||||
pub mod import_guidance;
|
||||
pub mod rate_limit;
|
||||
pub mod request_context;
|
||||
pub mod routes;
|
||||
|
||||
+15
-113
@@ -1,4 +1,4 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf, time::Duration};
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
@@ -7,50 +7,22 @@ use admin_api::{
|
||||
state::AppState,
|
||||
};
|
||||
use crank_community_auth::PasswordIdentityProvider;
|
||||
use crank_observability::{
|
||||
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
|
||||
capture_critical_error,
|
||||
};
|
||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||
use crank_runtime::{
|
||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||
RuntimeLimits, SecretCrypto,
|
||||
RuntimeLimits, SecretCrypto, community_default,
|
||||
};
|
||||
use sqlx::{PgPool, postgres::PgConnectOptions};
|
||||
use sqlx::postgres::PgConnectOptions;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let observability = crank_observability::init(ObservabilityConfig::from_env(
|
||||
"admin-api",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"admin_api=info,tower_http=info",
|
||||
)?)?;
|
||||
let mut startup_completed = false;
|
||||
let result = run(&observability, &mut startup_completed).await;
|
||||
if result.is_err() {
|
||||
capture_critical_error(if startup_completed {
|
||||
CriticalErrorCategory::Internal
|
||||
} else {
|
||||
CriticalErrorCategory::Startup
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn run(
|
||||
observability: &ObservabilityLifecycle,
|
||||
startup_completed: &mut bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let metrics_config =
|
||||
MetricsConfig::from_env("CRANK_ADMIN_METRICS_BIND", "127.0.0.1:9464".parse()?)?;
|
||||
let metrics_enabled = metrics_config.enabled();
|
||||
let metrics_server = if metrics_config.enabled() {
|
||||
Some(observability.metrics_surface(metrics_config).bind().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
env::var("CRANK_LOG_LEVEL").unwrap_or_else(|_| "admin_api=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let storage_root = PathBuf::from(
|
||||
env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()),
|
||||
@@ -64,9 +36,6 @@ async fn run(
|
||||
pool_config,
|
||||
)
|
||||
.await?;
|
||||
if metrics_enabled {
|
||||
spawn_postgres_pool_metrics(registry.pool().clone());
|
||||
}
|
||||
let auth_settings = AuthSettings {
|
||||
session_secret: env::var("CRANK_SESSION_SECRET")?,
|
||||
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?,
|
||||
@@ -87,11 +56,9 @@ async fn run(
|
||||
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
||||
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::from_env()?;
|
||||
let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy.clone())
|
||||
let runtime = community_default()
|
||||
.with_limits(runtime_limits)
|
||||
.with_response_cache(cache_stores.response.clone())
|
||||
.with_coordination_store(cache_stores.coordination.clone())
|
||||
.build();
|
||||
let identity_provider =
|
||||
PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone());
|
||||
@@ -102,16 +69,12 @@ async fn run(
|
||||
secret_crypto,
|
||||
runtime,
|
||||
)
|
||||
.with_outbound_http_policy(outbound_http_policy)
|
||||
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
||||
.build();
|
||||
let invocation_log_retention_days =
|
||||
positive_i64_from_env("CRANK_INVOCATION_LOG_RETENTION_DAYS", 30)?;
|
||||
service.bootstrap_admin_user().await?;
|
||||
if env_flag("CRANK_DEMO_SEED") {
|
||||
service.seed_demo_assets().await?;
|
||||
}
|
||||
spawn_invocation_log_cleanup(service.clone(), invocation_log_retention_days);
|
||||
let state = AppState {
|
||||
service,
|
||||
api_rate_limiter: if cache_config.backend.is_external() {
|
||||
@@ -119,15 +82,15 @@ async fn run(
|
||||
} else {
|
||||
RequestRateLimiter::new(api_rate_limit)
|
||||
},
|
||||
trust_forwarded_headers: env_flag("CRANK_TRUST_FORWARDED_HEADERS"),
|
||||
};
|
||||
let app = build_app(state);
|
||||
let listener = TcpListener::bind(socket_addr).await?;
|
||||
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
|
||||
|
||||
info!(
|
||||
name: "admin.postgres_pool.configured",
|
||||
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
|
||||
runtime_max_concurrent_window = runtime_limits.max_concurrent_window,
|
||||
runtime_max_concurrent_sessions = runtime_limits.max_concurrent_sessions,
|
||||
runtime_max_concurrent_jobs = runtime_limits.max_concurrent_jobs,
|
||||
admin_rate_limit_rps = api_rate_limit.requests_per_second,
|
||||
admin_rate_limit_burst = api_rate_limit.burst,
|
||||
cache_backend = %cache_config.backend,
|
||||
@@ -136,76 +99,15 @@ async fn run(
|
||||
acquire_timeout_ms = pool_config.acquire_timeout_ms,
|
||||
idle_timeout_ms = pool_config.idle_timeout_ms,
|
||||
max_lifetime_ms = pool_config.max_lifetime_ms,
|
||||
invocation_log_retention_days,
|
||||
"postgres pool configured"
|
||||
);
|
||||
info!(
|
||||
name: "admin.server.listening",
|
||||
bind_address = %socket_addr,
|
||||
"admin-api listening"
|
||||
);
|
||||
*startup_completed = true;
|
||||
info!("admin-api listening on {}", socket_addr);
|
||||
|
||||
if let Some(metrics_server) = metrics_server {
|
||||
tokio::select! {
|
||||
result = axum::serve(listener, make_service) => result?,
|
||||
result = metrics_server.serve() => result?,
|
||||
}
|
||||
} else {
|
||||
axum::serve(listener, make_service).await?;
|
||||
}
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn positive_i64_from_env(
|
||||
name: &'static str,
|
||||
default: i64,
|
||||
) -> Result<i64, Box<dyn std::error::Error>> {
|
||||
let value = match env::var(name) {
|
||||
Ok(raw) => raw.parse::<i64>()?,
|
||||
Err(env::VarError::NotPresent) => default,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if value <= 0 {
|
||||
return Err(format!("{name} must be greater than zero").into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn spawn_invocation_log_cleanup(service: admin_api::service::AdminService, retention_days: i64) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60 * 60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let cutoff = time::OffsetDateTime::now_utc() - time::Duration::days(retention_days);
|
||||
match service.cleanup_invocation_logs_before(cutoff).await {
|
||||
Ok(removed) if removed > 0 => info!(
|
||||
name: "admin.invocation_log_cleanup.completed",
|
||||
removed,
|
||||
"expired invocation logs removed"
|
||||
),
|
||||
Ok(_) => {}
|
||||
Err(_) => warn!(
|
||||
name: "admin.invocation_log_cleanup.failed",
|
||||
error_category = "registry_cleanup",
|
||||
"failed to remove expired invocation logs"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_postgres_pool_metrics(pool: PgPool) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
crank_observability::record_db_pool_connections(pool.size(), pool.num_idle());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
matches!(
|
||||
env::var(name)
|
||||
|
||||
@@ -1,40 +1,24 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Request, State},
|
||||
http::HeaderMap,
|
||||
extract::{Request, State},
|
||||
http::header::{COOKIE, HeaderMap},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use crank_runtime::{RateLimitCheckError, RateLimitRejection};
|
||||
use crank_runtime::RateLimitRejection;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
use crate::{auth::SESSION_COOKIE_NAME, error::ApiError, state::AppState};
|
||||
|
||||
pub async fn apply_api_rate_limit(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let peer_ip = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ConnectInfo(address)| address.ip());
|
||||
let key = rate_limit_key(
|
||||
request.headers(),
|
||||
request.uri().path(),
|
||||
peer_ip,
|
||||
state.trust_forwarded_headers,
|
||||
);
|
||||
if let Err(error) = state.api_rate_limiter.check(&key).await {
|
||||
return match error {
|
||||
RateLimitCheckError::Rejected(rejection) => Err(ApiError::rate_limited_with_context(
|
||||
"request rate limit exceeded",
|
||||
rejection_context(rejection),
|
||||
)),
|
||||
RateLimitCheckError::StoreUnavailable => {
|
||||
Err(ApiError::internal("rate limit service unavailable"))
|
||||
}
|
||||
};
|
||||
let key = rate_limit_key(request.headers(), request.uri().path());
|
||||
if let Err(rejection) = state.api_rate_limiter.check(&key).await {
|
||||
return Err(ApiError::rate_limited_with_context(
|
||||
"request rate limit exceeded",
|
||||
rejection_context(rejection),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
@@ -46,64 +30,56 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_limit_key(
|
||||
headers: &HeaderMap,
|
||||
path: &str,
|
||||
peer_ip: Option<IpAddr>,
|
||||
trust_forwarded_headers: bool,
|
||||
) -> String {
|
||||
if trust_forwarded_headers && let Some(client_ip) = forwarded_client_ip(headers) {
|
||||
return format!("ip:{client_ip}");
|
||||
fn rate_limit_key(headers: &HeaderMap, path: &str) -> String {
|
||||
if let Some(session_id) = session_id_from_headers(headers) {
|
||||
return format!("session:{session_id}");
|
||||
}
|
||||
|
||||
if let Some(peer_ip) = peer_ip {
|
||||
return format!("ip:{peer_ip}");
|
||||
if let Some(forwarded_for) = header_value(headers, "x-forwarded-for") {
|
||||
let ip = forwarded_for
|
||||
.split(',')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("unknown");
|
||||
return format!("ip:{ip}");
|
||||
}
|
||||
|
||||
if let Some(real_ip) = header_value(headers, "x-real-ip") {
|
||||
return format!("ip:{real_ip}");
|
||||
}
|
||||
|
||||
format!("anonymous:{path}")
|
||||
}
|
||||
|
||||
/// Resolves the client IP from proxy headers, assuming a single trusted proxy.
|
||||
///
|
||||
/// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the
|
||||
/// real peer address. For `X-Forwarded-For` the proxy *appends* the observed
|
||||
/// peer, so the last entry is the trustworthy hop; taking the first entry (as
|
||||
/// naive implementations do) would let a client spoof its address by sending a
|
||||
/// pre-populated header.
|
||||
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
||||
if let Some(real_ip) = header_value(headers, "x-real-ip").and_then(parse_ip) {
|
||||
return Some(real_ip);
|
||||
fn session_id_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
let cookies = headers.get(COOKIE)?.to_str().ok()?;
|
||||
for part in cookies.split(';') {
|
||||
let (name, value) = part.trim().split_once('=')?;
|
||||
if name != SESSION_COOKIE_NAME {
|
||||
continue;
|
||||
}
|
||||
let (session_id, _) = value.split_once('.')?;
|
||||
if !session_id.is_empty() {
|
||||
return Some(session_id.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
header_value(headers, "x-forwarded-for")?
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.rfind(|value| !value.is_empty())
|
||||
.and_then(parse_ip)
|
||||
None
|
||||
}
|
||||
|
||||
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
|
||||
headers.get(name)?.to_str().ok().map(str::trim)
|
||||
}
|
||||
|
||||
fn parse_ip(value: &str) -> Option<IpAddr> {
|
||||
value.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
||||
|
||||
use super::rate_limit_key;
|
||||
|
||||
fn peer() -> Option<IpAddr> {
|
||||
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unverified_session_cookie_cannot_change_client_key() {
|
||||
fn keys_by_session_cookie_first() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
COOKIE,
|
||||
@@ -112,86 +88,19 @@ mod tests {
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:10.0.0.5"
|
||||
rate_limit_key(&headers, "/api/auth/login"),
|
||||
"session:sess_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_forwarded_headers_when_untrusted() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), false),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_real_ip_when_trusted() {
|
||||
fn falls_back_to_forwarded_ip() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
|
||||
);
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:10.0.0.9"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_last_forwarded_hop_when_trusted() {
|
||||
// A client can prepend spoofed entries; the trusted proxy appends the
|
||||
// real peer, so the last entry is authoritative.
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
|
||||
HeaderValue::from_static("10.0.0.5, 10.0.0.6"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:10.0.0.6"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_peer_ip_without_headers() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_invalid_forwarded_ip_values() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("not-an-ip"));
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("198.51.100.8, also-not-an-ip"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_path_without_peer() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", None, false),
|
||||
"anonymous:/api/auth/login"
|
||||
);
|
||||
assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{HeaderName, HeaderValue},
|
||||
http::{HeaderMap, HeaderName, HeaderValue},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use crank_observability::{RequestId, set_remote_trace_parent, with_request_correlation};
|
||||
use tracing::{Instrument, info, info_span};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
|
||||
const MAX_REQUEST_ID_LEN: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestContext {
|
||||
@@ -16,48 +17,149 @@ pub struct RequestContext {
|
||||
|
||||
pub async fn apply_request_context(mut request: Request, next: Next) -> Response {
|
||||
let context = RequestContext {
|
||||
request_id: RequestId::resolve_from_headers(request.headers()).into_string(),
|
||||
request_id: resolve_request_id(request.headers()),
|
||||
};
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_owned();
|
||||
let span = info_span!(
|
||||
target: "crank::trace",
|
||||
"http.request",
|
||||
request_id = %context.request_id,
|
||||
);
|
||||
set_remote_trace_parent(&span, request.headers());
|
||||
request.extensions_mut().insert(context.clone());
|
||||
|
||||
with_request_correlation(context.request_id.clone(), async move {
|
||||
let mut response = next.run(request).instrument(span).await;
|
||||
info!(
|
||||
name: "admin.request.completed",
|
||||
request_id = %context.request_id,
|
||||
method = %method,
|
||||
path,
|
||||
status = response.status().as_u16(),
|
||||
"admin request completed"
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&context.request_id) {
|
||||
response.headers_mut().insert(REQUEST_ID_HEADER, value);
|
||||
}
|
||||
response
|
||||
})
|
||||
.await
|
||||
let mut response = next.run(request).await;
|
||||
info!(
|
||||
request_id = %context.request_id,
|
||||
method = %method,
|
||||
path,
|
||||
status = response.status().as_u16(),
|
||||
"admin request completed"
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&context.request_id) {
|
||||
response.headers_mut().insert(REQUEST_ID_HEADER, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn resolve_request_id(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get(&REQUEST_ID_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| is_valid_request_id(value))
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| Uuid::now_v7().to_string())
|
||||
}
|
||||
|
||||
fn is_valid_request_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_REQUEST_ID_LEN
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use reqwest::Client;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
||||
|
||||
use super::{REQUEST_ID_HEADER, apply_request_context, is_valid_request_id};
|
||||
|
||||
#[test]
|
||||
fn accepts_visible_ascii_request_ids() {
|
||||
assert!(crank_observability::RequestId::is_valid("req_test_123"));
|
||||
assert!(crank_observability::RequestId::is_valid("trace-123/abc"));
|
||||
assert!(is_valid_request_id("req_test_123"));
|
||||
assert!(is_valid_request_id("trace-123/abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_or_control_request_ids() {
|
||||
assert!(!crank_observability::RequestId::is_valid(""));
|
||||
assert!(!crank_observability::RequestId::is_valid("bad value"));
|
||||
assert!(!crank_observability::RequestId::is_valid("bad\nvalue"));
|
||||
assert!(!is_valid_request_id(""));
|
||||
assert!(!is_valid_request_id("bad value"));
|
||||
assert!(!is_valid_request_id("bad\nvalue"));
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_request_completion_with_request_id() {
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = tracing_subscriber::registry().with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(writer.clone())
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.with_filter(LevelFilter::INFO),
|
||||
);
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let app = Router::new()
|
||||
.route("/probe", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(apply_request_context));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let response = Client::new()
|
||||
.get(format!("http://{address}/probe"))
|
||||
.header(REQUEST_ID_HEADER.as_str(), "req_admin_trace_123")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[REQUEST_ID_HEADER.as_str()]
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"req_admin_trace_123"
|
||||
);
|
||||
|
||||
let logs = writer.output();
|
||||
assert!(logs.contains("admin request completed"));
|
||||
assert!(logs.contains("req_admin_trace_123"));
|
||||
assert!(logs.contains("GET"));
|
||||
assert!(logs.contains("/probe"));
|
||||
assert!(logs.contains("status=200"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,43 +3,19 @@ pub mod agents;
|
||||
pub mod auth;
|
||||
pub mod auth_profiles;
|
||||
pub mod capabilities;
|
||||
pub mod imports;
|
||||
pub mod machine_auth;
|
||||
pub mod observability;
|
||||
pub mod operations;
|
||||
pub mod secrets;
|
||||
pub mod upstreams;
|
||||
pub mod streaming;
|
||||
pub mod workspaces;
|
||||
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn health() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"service": "admin-api",
|
||||
"status": "ok"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn readiness(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match state.service.readiness().await {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"service": "admin-api",
|
||||
"status": "ready",
|
||||
"checks": { "postgres": "ready" }
|
||||
})),
|
||||
),
|
||||
Err(error) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"service": "admin-api",
|
||||
"status": "not_ready",
|
||||
"checks": { "postgres": "not_ready" },
|
||||
"error": error.to_string()
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,287 @@
|
||||
use axum::{
|
||||
Json,
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use crank_core::{
|
||||
AuditActor, AuditEvent, AuditEventId, AuditTarget, AuditTargetKind, PolicyAction,
|
||||
PolicyDecision, PolicyScope, SessionActor,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{InvitationPayload, UpdateMembershipPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceInvitationPath {
|
||||
pub workspace_id: String,
|
||||
pub invitation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceMembershipPath {
|
||||
pub workspace_id: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_memberships(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_memberships(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn update_membership(
|
||||
Path(path): Path<WorkspaceMembershipPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<UpdateMembershipPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||
enforce_workspace_policy(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
PolicyAction::WriteWorkspaceAccess,
|
||||
)?;
|
||||
let items = state
|
||||
.service
|
||||
.update_membership_role(
|
||||
&workspace_id,
|
||||
&session.user.id,
|
||||
&path.user_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
record_access_audit(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
"membership.role_updated",
|
||||
AuditTargetKind::Membership,
|
||||
path.user_id.clone(),
|
||||
json!({ "user_id": path.user_id }),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn delete_membership(
|
||||
Path(path): Path<WorkspaceMembershipPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||
enforce_workspace_policy(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
PolicyAction::WriteWorkspaceAccess,
|
||||
)?;
|
||||
state
|
||||
.service
|
||||
.remove_membership(
|
||||
&workspace_id,
|
||||
&session.user.id,
|
||||
&path.user_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
record_access_audit(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
"membership.removed",
|
||||
AuditTargetKind::Membership,
|
||||
path.user_id.clone(),
|
||||
json!({ "user_id": path.user_id }),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_invitations(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_invitations(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_invitation(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<InvitationPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||
enforce_workspace_policy(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
PolicyAction::WriteWorkspaceAccess,
|
||||
)?;
|
||||
let created = state
|
||||
.service
|
||||
.create_invitation(&workspace_id, payload)
|
||||
.await?;
|
||||
record_access_audit(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
"invitation.created",
|
||||
AuditTargetKind::Invitation,
|
||||
created.invitation.invitation.id.as_str().to_owned(),
|
||||
json!({ "invitation_id": created.invitation.invitation.id.as_str() }),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn delete_invitation(
|
||||
Path(path): Path<WorkspaceInvitationPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||
enforce_workspace_policy(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
PolicyAction::WriteWorkspaceAccess,
|
||||
)?;
|
||||
state
|
||||
.service
|
||||
.delete_invitation(&workspace_id, &path.invitation_id.as_str().into())
|
||||
.await?;
|
||||
record_access_audit(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
"invitation.deleted",
|
||||
AuditTargetKind::Invitation,
|
||||
path.invitation_id.clone(),
|
||||
json!({ "invitation_id": path.invitation_id }),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn export_workspace(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let exported = state
|
||||
.service
|
||||
.export_workspace_catalog_snapshot(&path.workspace_id.as_str().into())
|
||||
.export_workspace(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!(exported)))
|
||||
}
|
||||
|
||||
pub async fn delete_workspace(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||
enforce_workspace_policy(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
PolicyAction::WriteWorkspace,
|
||||
)?;
|
||||
state
|
||||
.service
|
||||
.delete_workspace(&workspace_id, &session.user.id)
|
||||
.await?;
|
||||
record_access_audit(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
"workspace.deleted",
|
||||
AuditTargetKind::Workspace,
|
||||
workspace_id.as_str().to_owned(),
|
||||
json!({ "workspace_id": workspace_id.as_str() }),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn enforce_workspace_policy(
|
||||
state: &AppState,
|
||||
session: &AuthenticatedSession,
|
||||
workspace_id: &crank_core::WorkspaceId,
|
||||
action: PolicyAction,
|
||||
) -> Result<(), ApiError> {
|
||||
let membership = session
|
||||
.memberships
|
||||
.iter()
|
||||
.find(|membership| membership.workspace.id == *workspace_id)
|
||||
.ok_or_else(|| ApiError::forbidden("workspace access denied"))?;
|
||||
let actor = SessionActor {
|
||||
user_id: session.user.id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
role: membership.role,
|
||||
};
|
||||
|
||||
match state.service.policy_engine().check(
|
||||
&actor,
|
||||
action,
|
||||
PolicyScope::Workspace(workspace_id.clone()),
|
||||
) {
|
||||
PolicyDecision::Allow => Ok(()),
|
||||
PolicyDecision::Deny { reason } => Err(ApiError::forbidden(reason)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_access_audit(
|
||||
state: &AppState,
|
||||
session: &AuthenticatedSession,
|
||||
workspace_id: &crank_core::WorkspaceId,
|
||||
action: &str,
|
||||
target_kind: AuditTargetKind,
|
||||
target_id: String,
|
||||
payload: Value,
|
||||
) -> Result<(), ApiError> {
|
||||
state
|
||||
.service
|
||||
.audit_sink()
|
||||
.record(AuditEvent {
|
||||
id: AuditEventId::new(format!("audit_{}", Uuid::now_v7().simple())),
|
||||
occurred_at: OffsetDateTime::now_utc(),
|
||||
actor: AuditActor {
|
||||
user_id: session.user.id.clone(),
|
||||
email: session.user.email.clone(),
|
||||
session_id: Some(session.session_id.clone()),
|
||||
},
|
||||
action: action.to_owned(),
|
||||
target: AuditTarget {
|
||||
workspace_id: workspace_id.clone(),
|
||||
kind: target_kind,
|
||||
id: target_id,
|
||||
},
|
||||
payload,
|
||||
source_ip: None,
|
||||
user_agent: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ApiError::internal(format!("failed to record audit event: {error}")))
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||
ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||
UpdateAgentPayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -50,18 +50,6 @@ pub async fn list_agents(
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn preview_tool_search(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ToolSearchPreviewPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.preview_tool_search(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!({"items": items})))
|
||||
}
|
||||
|
||||
pub async fn create_agent(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
@@ -136,7 +124,7 @@ pub async fn get_agent_version(
|
||||
pub async fn save_agent_bindings(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AgentCatalogPayload>,
|
||||
Json(payload): Json<Vec<AgentBindingPayload>>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let record = state
|
||||
.service
|
||||
|
||||
@@ -103,7 +103,7 @@ pub async fn change_password(
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.change_password(&session.user.id, &session.session_id, payload)
|
||||
.change_password(&session.user.id, payload)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceImportPath {
|
||||
pub workspace_id: String,
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
pub async fn preview_openapi_import(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<OpenApiImportPreviewPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let preview = state
|
||||
.service
|
||||
.preview_openapi_import(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(preview)))
|
||||
}
|
||||
|
||||
pub async fn create_openapi_import(
|
||||
Path(path): Path<WorkspaceImportPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<OpenApiImportCreatePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let imported = state
|
||||
.service
|
||||
.create_openapi_import(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.job_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(imported)))
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use axum::{Json, extract::State};
|
||||
use crank_core::{
|
||||
IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MachineAccessMode, MembershipRole,
|
||||
TokenIssuerActor, TokenIssuerError, UserId, WorkspaceId,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
pub async fn issue_agent_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<IssueAgentTokenRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let edition = state.service.capability_profile().capabilities().edition;
|
||||
let grant_type = payload.grant_type.clone();
|
||||
let response = state
|
||||
.service
|
||||
.token_issuer()
|
||||
.issue_short_lived(payload, &community_token_issuer_actor())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_token_issuer_error(
|
||||
error,
|
||||
edition,
|
||||
MachineAccessMode::ShortLivedToken,
|
||||
json!({
|
||||
"grant_type": grant_type,
|
||||
"upgrade_required": true,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(serde_json::json!(response)))
|
||||
}
|
||||
|
||||
pub async fn issue_one_time_agent_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<IssueOneTimeAgentTokenRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let edition = state.service.capability_profile().capabilities().edition;
|
||||
let operation_id = payload.operation_id.as_str().to_owned();
|
||||
let response = state
|
||||
.service
|
||||
.token_issuer()
|
||||
.issue_one_time(payload, &community_token_issuer_actor())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_token_issuer_error(
|
||||
error,
|
||||
edition,
|
||||
MachineAccessMode::OneTimeToken,
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"upgrade_required": true,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(serde_json::json!(response)))
|
||||
}
|
||||
|
||||
fn community_token_issuer_actor() -> TokenIssuerActor {
|
||||
TokenIssuerActor {
|
||||
user_id: UserId::new("user_community_public"),
|
||||
workspace_id: WorkspaceId::new("ws_community_public"),
|
||||
role: MembershipRole::Viewer,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_token_issuer_error(
|
||||
error: TokenIssuerError,
|
||||
edition: crank_core::ProductEdition,
|
||||
machine_access_mode: MachineAccessMode,
|
||||
extra_context: serde_json::Value,
|
||||
) -> ApiError {
|
||||
match error {
|
||||
TokenIssuerError::NotSupportedInEdition => ApiError::forbidden_with_context(
|
||||
match machine_access_mode {
|
||||
MachineAccessMode::ShortLivedToken => {
|
||||
"short-lived machine access is not available in Community"
|
||||
}
|
||||
MachineAccessMode::OneTimeToken => {
|
||||
"one-time machine access is not available in Community"
|
||||
}
|
||||
MachineAccessMode::StaticAgentKey => {
|
||||
"static agent key machine access is not available for token issue"
|
||||
}
|
||||
},
|
||||
merge_machine_auth_context(edition, machine_access_mode, extra_context),
|
||||
),
|
||||
TokenIssuerError::InvalidGrant(reason) => ApiError::validation_with_context(
|
||||
"invalid machine token grant",
|
||||
json!({ "reason": reason }),
|
||||
),
|
||||
TokenIssuerError::AgentKeyUnknown => ApiError::validation("agent key is unknown"),
|
||||
TokenIssuerError::OperationNotStrict => ApiError::validation("operation is not strict"),
|
||||
TokenIssuerError::OperationNotPublishedForAgent => {
|
||||
ApiError::validation("operation is not published for agent")
|
||||
}
|
||||
TokenIssuerError::RegistryFailure(details) => {
|
||||
ApiError::internal(format!("token issuer registry failure: {details}"))
|
||||
}
|
||||
TokenIssuerError::ReplayGuardFailure(details) => {
|
||||
ApiError::internal(format!("token issuer replay guard failure: {details}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_machine_auth_context(
|
||||
edition: crank_core::ProductEdition,
|
||||
machine_access_mode: MachineAccessMode,
|
||||
extra_context: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
let mut context = json!({
|
||||
"edition": edition,
|
||||
"machine_access_mode": machine_access_mode,
|
||||
});
|
||||
|
||||
if let (Some(base), Some(extra)) = (context.as_object_mut(), extra_context.as_object()) {
|
||||
for (key, value) in extra {
|
||||
base.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
context
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
routes::access::WorkspacePath,
|
||||
service::{ApprovalsQuery, LogsQuery, UsageRequestQuery},
|
||||
service::{LogsQuery, UsageRequestQuery},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -17,12 +17,6 @@ pub struct WorkspaceLogPath {
|
||||
pub log_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WorkspaceApprovalPath {
|
||||
pub workspace_id: String,
|
||||
pub approval_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WorkspaceOperationUsagePath {
|
||||
pub workspace_id: String,
|
||||
@@ -61,32 +55,6 @@ pub async fn get_log(
|
||||
Ok(Json(json!(item)))
|
||||
}
|
||||
|
||||
pub async fn list_approvals(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<ApprovalsQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_approvals(&path.workspace_id.as_str().into(), query)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn get_approval(
|
||||
Path(path): Path<WorkspaceApprovalPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let item = state
|
||||
.service
|
||||
.get_approval(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.approval_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(item)))
|
||||
}
|
||||
|
||||
pub async fn get_usage(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<UsageRequestQuery>,
|
||||
|
||||
@@ -64,18 +64,6 @@ pub async fn create_operation(
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn analyze_operation_quality(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<OperationPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let report = state
|
||||
.service
|
||||
.analyze_operation_quality(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(report)))
|
||||
}
|
||||
|
||||
pub async fn get_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{error::ApiError, routes::access::WorkspacePath, state::AppState};
|
||||
|
||||
pub async fn list_protocol_capabilities(
|
||||
Path(_path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
Ok(Json(json!({
|
||||
"items": state.service.list_protocol_capabilities().await
|
||||
})))
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{error::ApiError, service::UpstreamPayload, state::AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceUpstreamPath {
|
||||
pub workspace_id: String,
|
||||
pub upstream_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_upstreams(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_workspace_upstreams(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_upstream(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpstreamPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let upstream = state
|
||||
.service
|
||||
.save_workspace_upstream(&path.workspace_id.as_str().into(), None, payload)
|
||||
.await?;
|
||||
Ok(Json(json!(upstream)))
|
||||
}
|
||||
|
||||
pub async fn update_upstream(
|
||||
Path(path): Path<WorkspaceUpstreamPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpstreamPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let upstream = state
|
||||
.service
|
||||
.save_workspace_upstream(
|
||||
&path.workspace_id.as_str().into(),
|
||||
Some(&path.upstream_id.as_str().into()),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(upstream)))
|
||||
}
|
||||
+3901
-412
File diff suppressed because it is too large
Load Diff
@@ -1,658 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, SearchableTool,
|
||||
ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
|
||||
};
|
||||
use crank_registry::{
|
||||
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
|
||||
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery,
|
||||
};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
|
||||
CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id,
|
||||
today_start_utc,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
|
||||
pub async fn preview_tool_search(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: ToolSearchPreviewPayload,
|
||||
) -> Result<Vec<crank_core::ToolSearchMatch>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
if payload.tool_selection_policy.mode != ToolAccessMode::Search {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"tool search preview requires search mode",
|
||||
json!({"field": "tool_selection_policy.mode"}),
|
||||
));
|
||||
}
|
||||
let bindings = payload
|
||||
.bindings
|
||||
.iter()
|
||||
.map(|binding| AgentOperationBinding {
|
||||
operation_id: OperationId::new(binding.operation_id.clone()),
|
||||
operation_version: binding.operation_version,
|
||||
tool_name: binding.tool_name.clone(),
|
||||
tool_title: binding.tool_title.clone(),
|
||||
tool_description_override: binding.tool_description_override.clone(),
|
||||
enabled: binding.enabled,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
validate_tool_selection_policy(&payload.tool_selection_policy, &bindings)?;
|
||||
|
||||
let mut tools = Vec::new();
|
||||
for binding in bindings.iter().filter(|binding| binding.enabled) {
|
||||
let version = self
|
||||
.registry
|
||||
.get_operation_version(
|
||||
workspace_id,
|
||||
&binding.operation_id,
|
||||
binding.operation_version,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("operation {} was not found", binding.operation_id.as_str()),
|
||||
json!({"operation_id": binding.operation_id.as_str()}),
|
||||
)
|
||||
})?;
|
||||
let groups = payload
|
||||
.tool_selection_policy
|
||||
.groups
|
||||
.iter()
|
||||
.filter(|group| {
|
||||
group
|
||||
.tool_names
|
||||
.iter()
|
||||
.any(|name| name == &binding.tool_name)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
tools.push(SearchableTool {
|
||||
name: binding.tool_name.clone(),
|
||||
title: binding.tool_title.clone(),
|
||||
description: binding
|
||||
.tool_description_override
|
||||
.clone()
|
||||
.unwrap_or(version.snapshot.tool_description.description),
|
||||
input_schema: serde_json::Value::Null,
|
||||
group_ids: groups.iter().map(|group| group.id.clone()).collect(),
|
||||
group_context: groups
|
||||
.iter()
|
||||
.map(|group| format!("{} {}", group.name, group.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
});
|
||||
}
|
||||
let max_results = payload.tool_selection_policy.search.max_results;
|
||||
Ok(search_tool_catalog(
|
||||
&tools,
|
||||
&payload.query,
|
||||
&payload.group_ids,
|
||||
max_results,
|
||||
))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_agents(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<Vec<AgentSummaryView>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let workspace = self.get_workspace(workspace_id).await?;
|
||||
let summaries = self.registry.list_agents(workspace_id).await?;
|
||||
let usage = self
|
||||
.registry
|
||||
.list_usage_by_agent(UsageQuery {
|
||||
workspace_id,
|
||||
period: UsagePeriod::Last24Hours,
|
||||
source: None,
|
||||
created_after: &today_start_utc()?,
|
||||
bucket: UsageBucket::Hour,
|
||||
})
|
||||
.await?;
|
||||
let calls_today = usage
|
||||
.into_iter()
|
||||
.map(|item| (item.agent_id.as_str().to_owned(), item.calls_total))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let key_counts = self
|
||||
.registry
|
||||
.list_platform_api_keys(workspace_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.fold(BTreeMap::new(), |mut counts, record| {
|
||||
if let Some(agent_id) = record.api_key.agent_id {
|
||||
*counts.entry(agent_id.as_str().to_owned()).or_insert(0usize) += 1;
|
||||
}
|
||||
counts
|
||||
});
|
||||
|
||||
let mut items = Vec::with_capacity(summaries.len());
|
||||
for summary in summaries {
|
||||
let version = self
|
||||
.get_agent_version(workspace_id, &summary.id, summary.current_draft_version)
|
||||
.await?;
|
||||
let operation_ids = version
|
||||
.bindings
|
||||
.iter()
|
||||
.map(|binding| binding.operation_id.as_str().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
items.push(AgentSummaryView {
|
||||
operation_count: operation_ids.len(),
|
||||
operation_ids,
|
||||
tool_selection_policy: version.snapshot.tool_selection_policy,
|
||||
key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0),
|
||||
calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0),
|
||||
mcp_endpoint: agent_mcp_endpoint(
|
||||
workspace.workspace.slug.as_str(),
|
||||
summary.slug.as_str(),
|
||||
),
|
||||
..map_agent_summary_view(summary)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<AgentSummaryView, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let workspace = self.get_workspace(workspace_id).await?;
|
||||
let summary = self
|
||||
.registry
|
||||
.get_agent_summary(workspace_id, agent_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("agent {} was not found", agent_id.as_str()),
|
||||
json!({ "agent_id": agent_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
let version = self
|
||||
.get_agent_version(workspace_id, agent_id, summary.current_draft_version)
|
||||
.await?;
|
||||
let operation_ids = version
|
||||
.bindings
|
||||
.iter()
|
||||
.map(|binding| binding.operation_id.as_str().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let usage = self
|
||||
.registry
|
||||
.get_usage_for_agent(
|
||||
UsageQuery {
|
||||
workspace_id,
|
||||
period: UsagePeriod::Last24Hours,
|
||||
source: None,
|
||||
created_after: &today_start_utc()?,
|
||||
bucket: UsageBucket::Hour,
|
||||
},
|
||||
agent_id,
|
||||
)
|
||||
.await?;
|
||||
let key_count = self
|
||||
.registry
|
||||
.list_platform_api_keys_for_agent(workspace_id, agent_id)
|
||||
.await?
|
||||
.len();
|
||||
|
||||
Ok(AgentSummaryView {
|
||||
operation_count: operation_ids.len(),
|
||||
operation_ids,
|
||||
tool_selection_policy: version.snapshot.tool_selection_policy,
|
||||
key_count,
|
||||
calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0),
|
||||
mcp_endpoint: agent_mcp_endpoint(
|
||||
workspace.workspace.slug.as_str(),
|
||||
summary.slug.as_str(),
|
||||
),
|
||||
..map_agent_summary_view(summary)
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_agent_version(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
version: u32,
|
||||
) -> Result<AgentVersionRecord, ApiError> {
|
||||
self.registry
|
||||
.get_agent_version(workspace_id, agent_id, version)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!(
|
||||
"agent version {version} for {} was not found",
|
||||
agent_id.as_str()
|
||||
),
|
||||
json!({
|
||||
"agent_id": agent_id.as_str(),
|
||||
"version": version,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_slug = %payload.slug))]
|
||||
pub async fn create_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: AgentPayload,
|
||||
) -> Result<CreatedAgentResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
|
||||
if self
|
||||
.find_agent_by_slug(workspace_id, &payload.slug)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::conflict_with_context(
|
||||
format!("agent with slug {} already exists", payload.slug),
|
||||
json!({ "slug": payload.slug }),
|
||||
));
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let agent_id = AgentId::new(new_prefixed_id("agent"));
|
||||
let agent = Agent {
|
||||
id: agent_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
slug: payload.slug,
|
||||
display_name: payload.display_name,
|
||||
description: payload.description,
|
||||
status: AgentStatus::Draft,
|
||||
current_draft_version: 1,
|
||||
latest_published_version: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
published_at: None,
|
||||
};
|
||||
let version = AgentVersion {
|
||||
agent_id: agent_id.clone(),
|
||||
version: 1,
|
||||
status: AgentStatus::Draft,
|
||||
instructions: payload.instructions,
|
||||
tool_selection_policy: payload.tool_selection_policy,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &version,
|
||||
bindings: &[],
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.agent.created",
|
||||
agent_id = %agent_id.as_str(),
|
||||
version = 1,
|
||||
"agent created"
|
||||
);
|
||||
|
||||
Ok(CreatedAgentResponse {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version: 1,
|
||||
status: AgentStatus::Draft,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
||||
pub async fn update_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
payload: UpdateAgentPayload,
|
||||
) -> Result<AgentMutationResult, ApiError> {
|
||||
let existing = self
|
||||
.registry
|
||||
.get_agent_summary(workspace_id, agent_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("agent {} was not found", agent_id.as_str()),
|
||||
json!({ "agent_id": agent_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
|
||||
if payload.slug != existing.slug
|
||||
&& self
|
||||
.find_agent_by_slug(workspace_id, &payload.slug)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::conflict_with_context(
|
||||
format!("agent with slug {} already exists", payload.slug),
|
||||
json!({ "slug": payload.slug }),
|
||||
));
|
||||
}
|
||||
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.update_agent_summary(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
&payload.slug,
|
||||
&payload.display_name,
|
||||
&payload.description,
|
||||
&updated_at,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(AgentMutationResult {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
updated_at: format_timestamp(updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
||||
pub async fn delete_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<AgentMutationResult, ApiError> {
|
||||
let existing = self
|
||||
.registry
|
||||
.get_agent_summary(workspace_id, agent_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("agent {} was not found", agent_id.as_str()),
|
||||
json!({ "agent_id": agent_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
|
||||
self.registry.delete_agent(workspace_id, agent_id).await?;
|
||||
|
||||
Ok(AgentMutationResult {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
updated_at: format_timestamp(existing.updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
||||
pub async fn save_agent_bindings(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
payload: AgentCatalogPayload,
|
||||
) -> Result<AgentVersionRecord, ApiError> {
|
||||
let current_version = self
|
||||
.ensure_editable_agent_version(workspace_id, agent_id)
|
||||
.await?;
|
||||
let (payload, requested_policy) = payload.into_parts();
|
||||
let bindings = payload
|
||||
.into_iter()
|
||||
.map(|binding| AgentOperationBinding {
|
||||
operation_id: OperationId::new(binding.operation_id),
|
||||
operation_version: binding.operation_version,
|
||||
tool_name: binding.tool_name,
|
||||
tool_title: binding.tool_title,
|
||||
tool_description_override: binding.tool_description_override,
|
||||
enabled: binding.enabled,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let tool_selection_policy = requested_policy
|
||||
.unwrap_or_else(|| current_version.snapshot.tool_selection_policy.clone());
|
||||
validate_tool_selection_policy(&tool_selection_policy, &bindings)?;
|
||||
|
||||
self.registry
|
||||
.save_agent_catalog_config(SaveAgentCatalogConfigRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
agent_version: current_version.version,
|
||||
bindings: &bindings,
|
||||
tool_selection_policy: &tool_selection_policy,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.agent.bindings_saved",
|
||||
agent_id = %agent_id.as_str(),
|
||||
version = current_version.version,
|
||||
binding_count = bindings.len(),
|
||||
"agent bindings saved"
|
||||
);
|
||||
|
||||
self.get_agent_version(workspace_id, agent_id, current_version.version)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_editable_agent_version(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<AgentVersionRecord, ApiError> {
|
||||
let agent = self.get_agent(workspace_id, agent_id).await?;
|
||||
let current = self
|
||||
.get_agent_version(workspace_id, agent_id, agent.current_draft_version)
|
||||
.await?;
|
||||
if agent.latest_published_version != Some(agent.current_draft_version) {
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let draft = AgentVersion {
|
||||
agent_id: agent_id.clone(),
|
||||
version: current.version + 1,
|
||||
status: AgentStatus::Draft,
|
||||
instructions: current.snapshot.instructions.clone(),
|
||||
tool_selection_policy: current.snapshot.tool_selection_policy.clone(),
|
||||
created_at: now,
|
||||
};
|
||||
self.registry
|
||||
.create_agent_draft_version(CreateAgentDraftVersionRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
version: &draft,
|
||||
bindings: ¤t.bindings,
|
||||
updated_at: &now,
|
||||
})
|
||||
.await?;
|
||||
self.get_agent_version(workspace_id, agent_id, draft.version)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), version))]
|
||||
pub async fn publish_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
version: u32,
|
||||
) -> Result<PublishAgentResponse, ApiError> {
|
||||
let agent_version = self
|
||||
.get_agent_version(workspace_id, agent_id, version)
|
||||
.await?;
|
||||
let published_bindings = self
|
||||
.published_agent_bindings(workspace_id, &agent_version.bindings)
|
||||
.await?;
|
||||
validate_tool_selection_policy(
|
||||
&agent_version.snapshot.tool_selection_policy,
|
||||
&published_bindings,
|
||||
)?;
|
||||
|
||||
if published_bindings.is_empty() {
|
||||
return Err(ApiError::conflict_with_context(
|
||||
"agent cannot be published without published enabled tools",
|
||||
json!({
|
||||
"agent_id": agent_id.as_str(),
|
||||
"version": version,
|
||||
"binding_count": agent_version.bindings.len()
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let published_at = OffsetDateTime::now_utc();
|
||||
if published_bindings != agent_version.bindings {
|
||||
let draft_version = AgentVersion {
|
||||
agent_id: agent_id.clone(),
|
||||
version: agent_version.version + 1,
|
||||
status: AgentStatus::Draft,
|
||||
instructions: agent_version.snapshot.instructions.clone(),
|
||||
tool_selection_policy: agent_version.snapshot.tool_selection_policy.clone(),
|
||||
created_at: published_at,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.create_agent_draft_version(CreateAgentDraftVersionRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
version: &draft_version,
|
||||
bindings: &agent_version.bindings,
|
||||
updated_at: &published_at,
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.registry
|
||||
.save_agent_bindings(SaveAgentBindingsRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
agent_version: agent_version.version,
|
||||
bindings: &published_bindings,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
self.registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
version,
|
||||
published_at: &published_at,
|
||||
published_by: None,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.agent.published",
|
||||
agent_id = %agent_id.as_str(),
|
||||
version,
|
||||
"agent published"
|
||||
);
|
||||
|
||||
Ok(PublishAgentResponse {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
published_version: version,
|
||||
published_at: format_timestamp(published_at),
|
||||
})
|
||||
}
|
||||
|
||||
async fn published_agent_bindings(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
bindings: &[AgentOperationBinding],
|
||||
) -> Result<Vec<AgentOperationBinding>, ApiError> {
|
||||
let mut published = Vec::new();
|
||||
|
||||
for binding in bindings {
|
||||
if !binding.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(summary) = self
|
||||
.registry
|
||||
.get_operation_summary(workspace_id, &binding.operation_id)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(operation_version) = summary.latest_published_version else {
|
||||
continue;
|
||||
};
|
||||
|
||||
published.push(AgentOperationBinding {
|
||||
operation_id: summary.id,
|
||||
operation_version,
|
||||
tool_name: binding.tool_name.clone(),
|
||||
tool_title: binding.tool_title.clone(),
|
||||
tool_description_override: binding.tool_description_override.clone(),
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(published)
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
||||
pub async fn unpublish_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<AgentMutationResult, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.unpublish_agent(workspace_id, agent_id, &updated_at)
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.agent.unpublished",
|
||||
agent_id = %agent_id.as_str(),
|
||||
"agent moved to draft"
|
||||
);
|
||||
|
||||
Ok(AgentMutationResult {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
updated_at: format_timestamp(updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
||||
pub async fn archive_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<AgentMutationResult, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.archive_agent(workspace_id, agent_id, &updated_at)
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.agent.archived",
|
||||
agent_id = %agent_id.as_str(),
|
||||
"agent archived"
|
||||
);
|
||||
|
||||
Ok(AgentMutationResult {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
updated_at: format_timestamp(updated_at),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_tool_selection_policy(
|
||||
policy: &ToolSelectionPolicy,
|
||||
bindings: &[AgentOperationBinding],
|
||||
) -> Result<(), ApiError> {
|
||||
policy
|
||||
.validate_for_tools(
|
||||
bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.enabled)
|
||||
.map(|binding| binding.tool_name.as_str()),
|
||||
)
|
||||
.map_err(|error| {
|
||||
ApiError::validation_with_context(
|
||||
error.to_string(),
|
||||
json!({"field": "tool_selection_policy"}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
use crank_core::{
|
||||
AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
|
||||
PlatformApiKeyStatus, WorkspaceId,
|
||||
};
|
||||
use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord};
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, CreatedPlatformApiKeyResponse, PlatformApiKeyPayload, generate_access_secret,
|
||||
hash_access_secret, new_prefixed_id,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_agent_platform_api_keys(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<Vec<PlatformApiKeyRecord>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
self.registry
|
||||
.get_agent_summary(workspace_id, agent_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("agent {} was not found", agent_id.as_str()),
|
||||
json!({ "agent_id": agent_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
Ok(self
|
||||
.registry
|
||||
.list_platform_api_keys_for_agent(workspace_id, agent_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
|
||||
pub async fn create_agent_platform_api_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
payload: PlatformApiKeyPayload,
|
||||
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
self.registry
|
||||
.get_agent_summary(workspace_id, agent_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("agent {} was not found", agent_id.as_str()),
|
||||
json!({ "agent_id": agent_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
|
||||
validate_platform_api_key_payload(&payload)?;
|
||||
|
||||
let expires_at = match payload.expires_at.as_deref() {
|
||||
Some(value) => Some(
|
||||
OffsetDateTime::parse(value, &Rfc3339)
|
||||
.map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let secret = generate_access_secret(payload.key_kind.secret_marker());
|
||||
let api_key = PlatformApiKeyRecord {
|
||||
api_key: PlatformApiKey {
|
||||
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
|
||||
workspace_id: workspace_id.clone(),
|
||||
agent_id: Some(agent_id.clone()),
|
||||
name: payload.name,
|
||||
prefix: secret.chars().take(16).collect(),
|
||||
key_kind: payload.key_kind,
|
||||
scopes: payload.scopes,
|
||||
status: PlatformApiKeyStatus::Active,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
last_used_at: None,
|
||||
expires_at,
|
||||
allowed_origins: payload.allowed_origins,
|
||||
},
|
||||
};
|
||||
|
||||
self.registry
|
||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||
api_key: &api_key.api_key,
|
||||
secret_hash: &hash_access_secret(&secret),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
|
||||
pub async fn revoke_agent_platform_api_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
) -> Result<(), ApiError> {
|
||||
self.registry
|
||||
.revoke_platform_api_key_for_agent(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
key_id,
|
||||
&OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
|
||||
pub async fn delete_agent_platform_api_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
) -> Result<(), ApiError> {
|
||||
self.registry
|
||||
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> {
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(ApiError::validation("key name is required"));
|
||||
}
|
||||
if payload.scopes.is_empty() {
|
||||
return Err(ApiError::validation("at least one key scope is required"));
|
||||
}
|
||||
|
||||
let valid = payload.scopes.iter().all(|scope| match payload.key_kind {
|
||||
PlatformApiKeyKind::McpClient => matches!(
|
||||
scope,
|
||||
PlatformApiKeyScope::Read | PlatformApiKeyScope::Write | PlatformApiKeyScope::Deploy
|
||||
),
|
||||
PlatformApiKeyKind::Approval => matches!(
|
||||
scope,
|
||||
PlatformApiKeyScope::Approve
|
||||
| PlatformApiKeyScope::Deny
|
||||
| PlatformApiKeyScope::ReadPending
|
||||
),
|
||||
});
|
||||
if !valid {
|
||||
return Err(ApiError::validation(
|
||||
"key scopes do not match selected key kind",
|
||||
));
|
||||
}
|
||||
|
||||
if payload.key_kind == PlatformApiKeyKind::Approval && payload.allowed_origins.len() > 20 {
|
||||
return Err(ApiError::validation(
|
||||
"approval key can contain at most 20 allowed origins",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
use crank_core::{LoginOutcome, MembershipRole, UserSessionId, WorkspaceId};
|
||||
use serde_json::json;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
AuthenticatedSession, SessionCookie, create_session_cookie, hash_password,
|
||||
hash_session_secret, verify_password,
|
||||
},
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, ChangePasswordPayload, LoginPayload, SessionResponse, UpdateProfilePayload,
|
||||
map_identity_error, validate_profile_display_name, validate_profile_email,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> {
|
||||
let password_hash = hash_password(
|
||||
&self.auth_settings.bootstrap_admin.password,
|
||||
&self.auth_settings.password_pepper,
|
||||
)?;
|
||||
let user_id = self
|
||||
.registry
|
||||
.ensure_bootstrap_user(
|
||||
&self.auth_settings.bootstrap_admin.email,
|
||||
&self.auth_settings.bootstrap_admin.display_name,
|
||||
&password_hash,
|
||||
)
|
||||
.await?;
|
||||
self.registry
|
||||
.ensure_membership(
|
||||
&WorkspaceId::new("ws_default"),
|
||||
&user_id,
|
||||
MembershipRole::Owner,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn seed_demo_assets(&self) -> Result<(), ApiError> {
|
||||
let admin_user = self
|
||||
.registry
|
||||
.get_auth_user_by_email(&self.auth_settings.bootstrap_admin.email)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::internal("bootstrap admin user was not found"))?;
|
||||
let admin_user_id = admin_user.user.id.clone();
|
||||
let default_workspace_id = WorkspaceId::new("ws_default");
|
||||
|
||||
self.seed_default_workspace_demo(&admin_user_id, &default_workspace_id)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
session_value: &str,
|
||||
) -> Result<Option<AuthenticatedSession>, ApiError> {
|
||||
let secret_hash = hash_session_secret(
|
||||
session_id,
|
||||
session_value,
|
||||
&self.auth_settings.session_secret,
|
||||
);
|
||||
let session = self
|
||||
.registry
|
||||
.get_user_session(session_id, &secret_hash)
|
||||
.await?
|
||||
.map(|record| AuthenticatedSession {
|
||||
session_id: record.session_id,
|
||||
user: record.user,
|
||||
memberships: record.memberships,
|
||||
current_workspace_id: record.current_workspace_id,
|
||||
});
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn touch_session(&self, session_id: &UserSessionId) -> Result<(), ApiError> {
|
||||
self.registry.touch_user_session(session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
&self,
|
||||
payload: LoginPayload,
|
||||
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
||||
let authenticated = self.authenticate_login(&payload).await?;
|
||||
|
||||
let session_cookie = create_session_cookie(&self.auth_settings)?;
|
||||
let secret_hash = hash_session_secret(
|
||||
&session_cookie.session_id,
|
||||
&session_cookie.value,
|
||||
&self.auth_settings.session_secret,
|
||||
);
|
||||
let memberships = self
|
||||
.registry
|
||||
.list_workspaces_for_user(&authenticated.user.id)
|
||||
.await?;
|
||||
let default_workspace_id = memberships
|
||||
.iter()
|
||||
.find(|membership| membership.workspace.id.as_str() == "ws_default")
|
||||
.map(|membership| membership.workspace.id.as_str().to_owned());
|
||||
let current_workspace_id = default_workspace_id.or_else(|| {
|
||||
authenticated
|
||||
.current_workspace_id
|
||||
.as_ref()
|
||||
.map(|workspace_id| workspace_id.as_str().to_owned())
|
||||
.or_else(|| {
|
||||
memberships
|
||||
.first()
|
||||
.map(|membership| membership.workspace.id.as_str().to_owned())
|
||||
})
|
||||
});
|
||||
let current_workspace_ref = current_workspace_id
|
||||
.as_ref()
|
||||
.map(|workspace_id| WorkspaceId::new(workspace_id.clone()));
|
||||
self.registry
|
||||
.create_user_session(
|
||||
&session_cookie.session_id,
|
||||
&authenticated.user.id,
|
||||
current_workspace_ref.as_ref(),
|
||||
&secret_hash,
|
||||
&session_cookie.expires_at,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
session_cookie,
|
||||
SessionResponse {
|
||||
user: authenticated.user,
|
||||
memberships,
|
||||
current_workspace_id,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn authenticate_login(
|
||||
&self,
|
||||
payload: &LoginPayload,
|
||||
) -> Result<crank_core::AuthenticatedIdentity, ApiError> {
|
||||
if let Some(identity_provider) = &self.identity_provider {
|
||||
return match identity_provider
|
||||
.login_password(crank_core::LoginPayload {
|
||||
email: payload.email.clone(),
|
||||
password: payload.password.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(LoginOutcome::Authenticated(identity)) => Ok(identity),
|
||||
Err(error) => Err(map_identity_error(error)),
|
||||
};
|
||||
}
|
||||
|
||||
let user = self
|
||||
.registry
|
||||
.get_auth_user_by_email(&payload.email)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("invalid email or password"))?;
|
||||
|
||||
if !verify_password(
|
||||
&payload.password,
|
||||
&self.auth_settings.password_pepper,
|
||||
&user.password_hash,
|
||||
) {
|
||||
return Err(ApiError::unauthorized("invalid email or password"));
|
||||
}
|
||||
|
||||
Ok(crank_core::AuthenticatedIdentity {
|
||||
user: user.user,
|
||||
memberships: vec![],
|
||||
current_workspace_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
_session_value: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
self.registry.revoke_user_session(session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn session_response(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
session_value: &str,
|
||||
) -> Result<Option<SessionResponse>, ApiError> {
|
||||
Ok(self
|
||||
.get_session(session_id, session_value)
|
||||
.await?
|
||||
.map(|session| SessionResponse {
|
||||
user: session.user,
|
||||
memberships: session.memberships,
|
||||
current_workspace_id: session
|
||||
.current_workspace_id
|
||||
.map(|id| id.as_str().to_owned()),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
&self,
|
||||
user_id: &crank_core::UserId,
|
||||
current_workspace_id: Option<&WorkspaceId>,
|
||||
payload: UpdateProfilePayload,
|
||||
) -> Result<SessionResponse, ApiError> {
|
||||
let display_name = validate_profile_display_name(&payload.display_name)?;
|
||||
let email = validate_profile_email(&payload.email)?;
|
||||
|
||||
let user = self
|
||||
.registry
|
||||
.update_user_profile(user_id, &email, &display_name)
|
||||
.await?;
|
||||
let memberships = self.registry.list_workspaces_for_user(user_id).await?;
|
||||
|
||||
Ok(SessionResponse {
|
||||
user,
|
||||
memberships,
|
||||
current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
user_id: &crank_core::UserId,
|
||||
current_session_id: &UserSessionId,
|
||||
payload: ChangePasswordPayload,
|
||||
) -> Result<(), ApiError> {
|
||||
if payload.new_password.len() < 12 {
|
||||
return Err(ApiError::validation(
|
||||
"new password must be at least 12 characters long",
|
||||
));
|
||||
}
|
||||
|
||||
let user = self
|
||||
.registry
|
||||
.get_auth_user_by_id(user_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("user {} was not found", user_id.as_str()),
|
||||
json!({ "user_id": user_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !verify_password(
|
||||
&payload.current_password,
|
||||
&self.auth_settings.password_pepper,
|
||||
&user.password_hash,
|
||||
) {
|
||||
return Err(ApiError::unauthorized("current password is invalid"));
|
||||
}
|
||||
|
||||
let password_hash =
|
||||
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
|
||||
self.registry
|
||||
.update_user_password_and_revoke_other_sessions(
|
||||
user_id,
|
||||
current_session_id,
|
||||
&password_hash,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_core::{
|
||||
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId,
|
||||
OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus,
|
||||
Protocol, Target, WizardState, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::{ListInvocationLogsQuery, OperationSummary, RegistryError, SampleKind};
|
||||
use crank_schema::Schema;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, AgentBindingPayload, AgentPayload, AgentSummaryView, InvocationRecordRequest,
|
||||
OperationPayload, PlatformApiKeyPayload,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
pub(super) async fn seed_default_workspace_demo(
|
||||
&self,
|
||||
owner_user_id: &crank_core::UserId,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<(), ApiError> {
|
||||
self.registry
|
||||
.ensure_membership(workspace_id, owner_user_id, MembershipRole::Owner)
|
||||
.await?;
|
||||
|
||||
self.cleanup_legacy_demo_assets(workspace_id).await?;
|
||||
|
||||
let rest_operation = self
|
||||
.ensure_demo_operation(workspace_id, demo_rest_operation_payload())
|
||||
.await?;
|
||||
self.ensure_operation_published(workspace_id, &rest_operation)
|
||||
.await?;
|
||||
self.ensure_demo_json_samples(
|
||||
workspace_id,
|
||||
&rest_operation.id,
|
||||
&demo_rest_input_sample(),
|
||||
&demo_rest_output_sample(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let currency_agent = self
|
||||
.ensure_demo_agent(workspace_id, demo_currency_agent_payload())
|
||||
.await?;
|
||||
self.ensure_demo_agent_bindings(
|
||||
workspace_id,
|
||||
&AgentId::new(currency_agent.id.clone()),
|
||||
vec![AgentBindingPayload {
|
||||
operation_id: rest_operation.id.as_str().to_owned(),
|
||||
operation_version: rest_operation.current_draft_version,
|
||||
tool_name: "frankfurter_latest_rate".to_owned(),
|
||||
tool_title: "Последний курс валюты".to_owned(),
|
||||
tool_description_override: Some(
|
||||
"Возвращает последний доступный курс одной валюты к другой через Frankfurter."
|
||||
.to_owned(),
|
||||
),
|
||||
enabled: true,
|
||||
}],
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.ensure_demo_platform_api_key(
|
||||
workspace_id,
|
||||
&AgentId::new(currency_agent.id.clone()),
|
||||
"Frankfurter Demo Key",
|
||||
vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.seed_demo_invocation_logs(
|
||||
workspace_id,
|
||||
&AgentId::new(currency_agent.id),
|
||||
&rest_operation.id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cleanup_legacy_demo_assets(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> {
|
||||
for slug in ["revops-copilot", "support-triage"] {
|
||||
if let Some(agent) = self.find_agent_by_slug(workspace_id, slug).await? {
|
||||
self.delete_agent(workspace_id, &AgentId::new(agent.id.as_str().to_owned()))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let operations = self.registry.list_operations(workspace_id).await?;
|
||||
for operation in operations {
|
||||
if operation.name.starts_with("internal_health_smoke_")
|
||||
|| operation
|
||||
.name
|
||||
.starts_with("weather_current_open_meteo_smoke_")
|
||||
{
|
||||
self.delete_legacy_demo_operation_if_safe(workspace_id, &operation.id)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
for name in [
|
||||
"crm_create_lead",
|
||||
"marketing_archive_contact",
|
||||
"weather_current_open_meteo",
|
||||
] {
|
||||
if let Some(operation) = self.find_operation_by_name(workspace_id, name).await? {
|
||||
self.delete_legacy_demo_operation_if_safe(workspace_id, &operation.id)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_legacy_demo_operation_if_safe(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
) -> Result<(), ApiError> {
|
||||
match self
|
||||
.registry
|
||||
.delete_operation(workspace_id, operation_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(RegistryError::OperationHasPublishedAgentBindings { .. }) => {
|
||||
tracing::warn!(
|
||||
name: "admin.demo_operation.cleanup_skipped",
|
||||
operation_id = %operation_id.as_str(),
|
||||
"legacy demo operation is still bound to a published agent; leaving it in place"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(ApiError::from(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_demo_platform_api_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
name: &str,
|
||||
scopes: Vec<PlatformApiKeyScope>,
|
||||
revoke: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let existing = self
|
||||
.registry
|
||||
.list_platform_api_keys(workspace_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|record| record.api_key.name == name);
|
||||
let key = match existing {
|
||||
Some(record) => record,
|
||||
None => {
|
||||
self.create_agent_platform_api_key(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
PlatformApiKeyPayload {
|
||||
name: name.to_owned(),
|
||||
key_kind: PlatformApiKeyKind::McpClient,
|
||||
scopes,
|
||||
expires_at: None,
|
||||
allowed_origins: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
.api_key
|
||||
}
|
||||
};
|
||||
|
||||
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked {
|
||||
self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_demo_operation(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: OperationPayload,
|
||||
) -> Result<OperationSummary, ApiError> {
|
||||
if let Some(existing) = self
|
||||
.find_operation_by_name(workspace_id, &payload.name)
|
||||
.await?
|
||||
{
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let operation_name = payload.name.clone();
|
||||
self.create_operation(workspace_id, payload).await?;
|
||||
self.find_operation_by_name(workspace_id, &operation_name)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::internal("demo operation was created but not found"))
|
||||
}
|
||||
|
||||
async fn ensure_operation_published(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
summary: &OperationSummary,
|
||||
) -> Result<(), ApiError> {
|
||||
if summary.latest_published_version.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.publish_operation(workspace_id, &summary.id, summary.current_draft_version)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_demo_json_samples(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
input: &Value,
|
||||
output: &Value,
|
||||
) -> Result<(), ApiError> {
|
||||
let summary = self.get_operation(workspace_id, operation_id).await?;
|
||||
let samples = self
|
||||
.registry
|
||||
.list_sample_metadata(operation_id, summary.current_draft_version)
|
||||
.await?;
|
||||
|
||||
if !samples
|
||||
.iter()
|
||||
.any(|sample| sample.sample_kind == SampleKind::InputJson)
|
||||
{
|
||||
self.save_json_sample(workspace_id, operation_id, SampleKind::InputJson, input)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !samples
|
||||
.iter()
|
||||
.any(|sample| sample.sample_kind == SampleKind::OutputJson)
|
||||
{
|
||||
self.save_json_sample(workspace_id, operation_id, SampleKind::OutputJson, output)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_demo_agent(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: AgentPayload,
|
||||
) -> Result<AgentSummaryView, ApiError> {
|
||||
let summary =
|
||||
if let Some(existing) = self.find_agent_by_slug(workspace_id, &payload.slug).await? {
|
||||
existing
|
||||
} else {
|
||||
self.create_agent(workspace_id, payload.clone()).await?;
|
||||
self.find_agent_by_slug(workspace_id, &payload.slug)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::internal("demo agent was created but not found"))?
|
||||
};
|
||||
|
||||
self.get_agent(workspace_id, &summary.id).await
|
||||
}
|
||||
|
||||
async fn ensure_demo_agent_bindings(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
bindings: Vec<AgentBindingPayload>,
|
||||
publish: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let summary = self.get_agent(workspace_id, agent_id).await?;
|
||||
self.save_agent_bindings(workspace_id, agent_id, bindings.into())
|
||||
.await?;
|
||||
if publish && summary.latest_published_version.is_none() {
|
||||
self.publish_agent(workspace_id, agent_id, summary.current_draft_version)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_demo_invocation_logs(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
currency_agent_id: &AgentId,
|
||||
rest_operation_id: &OperationId,
|
||||
) -> Result<(), ApiError> {
|
||||
if !self
|
||||
.registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id,
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: None,
|
||||
operation_id: None,
|
||||
agent_id: None,
|
||||
created_after: None,
|
||||
limit: 1,
|
||||
})
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let rest_operation = self
|
||||
.get_operation_version(
|
||||
workspace_id,
|
||||
rest_operation_id,
|
||||
self.get_operation(workspace_id, rest_operation_id)
|
||||
.await?
|
||||
.current_draft_version,
|
||||
)
|
||||
.await?;
|
||||
self.record_invocation(InvocationRecordRequest {
|
||||
workspace_id,
|
||||
agent_id: Some(currency_agent_id),
|
||||
operation: &rest_operation.snapshot,
|
||||
request_id: None,
|
||||
source: InvocationSource::AgentToolCall,
|
||||
level: InvocationLevel::Info,
|
||||
status: InvocationStatus::Ok,
|
||||
message: "Frankfurter returned latest exchange rate".to_owned(),
|
||||
status_code: Some(200),
|
||||
error_kind: None,
|
||||
duration_ms: 124,
|
||||
request_preview: json!({
|
||||
"path": {},
|
||||
"query": demo_rest_request_sample(),
|
||||
"headers": { "Accept": "application/json" },
|
||||
"body": null
|
||||
}),
|
||||
response_preview: demo_rest_response_sample(),
|
||||
})
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn demo_currency_agent_payload() -> AgentPayload {
|
||||
AgentPayload {
|
||||
slug: "currency-rates".to_owned(),
|
||||
display_name: "Курсы валют".to_owned(),
|
||||
description: "Агент с инструментами для получения курсов валют.".to_owned(),
|
||||
instructions: json!({
|
||||
"system": "Используй инструменты Frankfurter только для запросов о курсах валют."
|
||||
}),
|
||||
tool_selection_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn demo_rest_operation_payload() -> OperationPayload {
|
||||
let input = demo_rest_input_sample();
|
||||
let response = demo_rest_response_sample();
|
||||
let output = demo_rest_output_sample();
|
||||
|
||||
OperationPayload {
|
||||
name: "frankfurter_latest_rate".to_owned(),
|
||||
display_name: "Последний курс валюты".to_owned(),
|
||||
category: "frankfurter_rates".to_owned(),
|
||||
protocol: Protocol::Rest,
|
||||
security_level: OperationSecurityLevel::Standard,
|
||||
target: Target::Rest(crank_core::RestTarget {
|
||||
base_url: "https://api.frankfurter.dev".to_owned(),
|
||||
method: crank_core::HttpMethod::Get,
|
||||
path_template: "/v1/latest".to_owned(),
|
||||
static_headers: BTreeMap::from([("Accept".to_owned(), "application/json".to_owned())]),
|
||||
}),
|
||||
input_schema: Schema::from_json_sample(&input),
|
||||
output_schema: Schema::from_json_sample(&output),
|
||||
input_mapping: frankfurter_input_mapping(),
|
||||
output_mapping: infer_mapping_from_samples(
|
||||
&response,
|
||||
JsonPathRoot::ResponseBody,
|
||||
&output,
|
||||
JsonPathRoot::Output,
|
||||
),
|
||||
execution_config: crank_core::ExecutionConfig {
|
||||
timeout_ms: 10_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
tool_description: crank_core::ToolDescription {
|
||||
title: "Последний курс валюты".to_owned(),
|
||||
description:
|
||||
"Возвращает последний доступный курс одной валюты к другой через Frankfurter."
|
||||
.to_owned(),
|
||||
tags: vec![
|
||||
"frankfurter".to_owned(),
|
||||
"currency".to_owned(),
|
||||
"exchange-rate".to_owned(),
|
||||
],
|
||||
examples: vec![crank_core::ToolExample {
|
||||
input: json!({
|
||||
"base": "USD",
|
||||
"quote": "EUR"
|
||||
}),
|
||||
}],
|
||||
},
|
||||
wizard_state: Some(WizardState {
|
||||
input_sample: Some(input),
|
||||
output_sample: Some(output),
|
||||
test_input: Some(demo_rest_input_sample()),
|
||||
import_findings: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn demo_rest_input_sample() -> Value {
|
||||
json!({
|
||||
"base": "USD",
|
||||
"quote": "EUR"
|
||||
})
|
||||
}
|
||||
|
||||
fn demo_rest_request_sample() -> Value {
|
||||
json!({
|
||||
"base": "USD",
|
||||
"symbols": "EUR"
|
||||
})
|
||||
}
|
||||
|
||||
fn demo_rest_response_sample() -> Value {
|
||||
json!({
|
||||
"amount": 1.0,
|
||||
"base": "USD",
|
||||
"date": "2026-06-19",
|
||||
"rates": {
|
||||
"EUR": 0.87207
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn demo_rest_output_sample() -> Value {
|
||||
demo_rest_response_sample()
|
||||
}
|
||||
|
||||
fn frankfurter_input_mapping() -> MappingSet {
|
||||
MappingSet {
|
||||
rules: vec![
|
||||
MappingRule {
|
||||
source: "$.mcp.base".to_owned(),
|
||||
target: "$.request.query.base".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
},
|
||||
MappingRule {
|
||||
source: "$.mcp.quote".to_owned(),
|
||||
target: "$.request.query.symbols".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
use crank_core::{ConfigExport, WorkspaceId};
|
||||
use crank_registry::RegistryOperation;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
import_guidance::import_guidance_warnings,
|
||||
service::{
|
||||
AdminService, ExportQuery, ImportMode, ImportQuery, ImportResponse, NewVersionPayload,
|
||||
OperationPayload, YamlOperationDocument,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
#[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))]
|
||||
pub async fn export_operation(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &crank_core::OperationId,
|
||||
query: ExportQuery,
|
||||
) -> Result<String, ApiError> {
|
||||
let version = match query.version {
|
||||
Some(version) => version,
|
||||
None => {
|
||||
self.get_operation(workspace_id, operation_id)
|
||||
.await?
|
||||
.current_draft_version
|
||||
}
|
||||
};
|
||||
let record = self
|
||||
.get_operation_version(workspace_id, operation_id, version)
|
||||
.await?;
|
||||
let document = YamlOperationDocument {
|
||||
format_version: "1".to_owned(),
|
||||
kind: "operation".to_owned(),
|
||||
operation: RegistryOperation {
|
||||
config_export: Some(ConfigExport {
|
||||
format_version: "1".to_owned(),
|
||||
export_mode: query.mode,
|
||||
}),
|
||||
..record.snapshot
|
||||
},
|
||||
};
|
||||
|
||||
serde_yaml::to_string(&document).map_err(|error| ApiError::internal(error.to_string()))
|
||||
}
|
||||
|
||||
#[instrument(skip(self, yaml_document), fields(mode = ?query.mode))]
|
||||
pub async fn import_operation(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
query: ImportQuery,
|
||||
yaml_document: &str,
|
||||
) -> Result<ImportResponse, ApiError> {
|
||||
let document: YamlOperationDocument = serde_yaml::from_str(yaml_document)
|
||||
.map_err(|error| ApiError::validation(error.to_string()))?;
|
||||
if document.kind != "operation" {
|
||||
return Err(ApiError::validation("yaml kind must be operation"));
|
||||
}
|
||||
|
||||
let payload = OperationPayload {
|
||||
name: document.operation.name.clone(),
|
||||
display_name: document.operation.display_name.clone(),
|
||||
category: document.operation.category.clone(),
|
||||
protocol: document.operation.protocol,
|
||||
security_level: document.operation.security_level,
|
||||
target: document.operation.target.clone(),
|
||||
input_schema: document.operation.input_schema.clone(),
|
||||
output_schema: document.operation.output_schema.clone(),
|
||||
input_mapping: document.operation.input_mapping.clone(),
|
||||
output_mapping: document.operation.output_mapping.clone(),
|
||||
execution_config: document.operation.execution_config.clone(),
|
||||
tool_description: document.operation.tool_description.clone(),
|
||||
wizard_state: document.operation.wizard_state.clone(),
|
||||
};
|
||||
let warnings = import_guidance_warnings(&document.operation);
|
||||
|
||||
match query.mode {
|
||||
ImportMode::Create => {
|
||||
let created = self.create_operation(workspace_id, payload).await?;
|
||||
Ok(ImportResponse {
|
||||
operation_id: created.operation_id,
|
||||
workspace_id: created.workspace_id,
|
||||
version: created.version,
|
||||
import_mode: ImportMode::Create,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
ImportMode::Upsert => {
|
||||
if let Some(existing) = self
|
||||
.find_operation_by_name(workspace_id, &document.operation.name)
|
||||
.await?
|
||||
{
|
||||
let created = self
|
||||
.create_version(
|
||||
workspace_id,
|
||||
&existing.id,
|
||||
NewVersionPayload {
|
||||
operation: payload,
|
||||
change_note: Some("yaml upsert".to_owned()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response = ImportResponse {
|
||||
operation_id: created.operation_id,
|
||||
workspace_id: created.workspace_id,
|
||||
version: created.version,
|
||||
import_mode: ImportMode::Upsert,
|
||||
warnings,
|
||||
};
|
||||
info!(
|
||||
name: "admin.operation.imported",
|
||||
operation_id = %response.operation_id,
|
||||
version = response.version,
|
||||
"operation imported by upsert"
|
||||
);
|
||||
Ok(response)
|
||||
} else {
|
||||
let created = self.create_operation(workspace_id, payload).await?;
|
||||
let response = ImportResponse {
|
||||
operation_id: created.operation_id,
|
||||
workspace_id: created.workspace_id,
|
||||
version: created.version,
|
||||
import_mode: ImportMode::Upsert,
|
||||
warnings,
|
||||
};
|
||||
info!(
|
||||
name: "admin.operation.imported",
|
||||
operation_id = %response.operation_id,
|
||||
version = response.version,
|
||||
"operation imported by upsert"
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crank_core::{
|
||||
ExecutionConfig, OperationSecurityLevel, Protocol, ToolQualityFinding, ToolQualitySeverity,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crank_import::rest::{
|
||||
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, operation_draft_from_candidate,
|
||||
};
|
||||
use crank_registry::{
|
||||
ApplyImportJobRequest, CreateImportJobRequest, ImportConflictMode, ImportJobId, ImportJobKind,
|
||||
ImportJobStatus, ImportOperationDraft,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, OpenApiImportCreatePayload, OpenApiImportCreateResponse,
|
||||
OpenApiImportCreatedOperation, OpenApiImportPreviewPayload, OpenApiImportPreviewResponse,
|
||||
OpenApiImportSkippedOperation, OperationPayload, new_prefixed_id,
|
||||
},
|
||||
};
|
||||
|
||||
const IMPORT_JOB_TTL_HOURS: i64 = 24;
|
||||
|
||||
impl AdminService {
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
|
||||
pub async fn preview_openapi_import(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: OpenApiImportPreviewPayload,
|
||||
) -> Result<OpenApiImportPreviewResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let _ = self.registry.delete_expired_import_jobs().await;
|
||||
|
||||
let preview = crank_import::rest::preview_document(&payload.document)
|
||||
.map_err(|error| ApiError::validation(error.to_string()))?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let expires_at = now + Duration::hours(IMPORT_JOB_TTL_HOURS);
|
||||
let job_id = ImportJobId::new(new_prefixed_id("imp"));
|
||||
let preview_payload = serde_json::to_value(&preview)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
|
||||
self.registry
|
||||
.create_import_job(CreateImportJobRequest {
|
||||
id: &job_id,
|
||||
workspace_id,
|
||||
kind: ImportJobKind::OpenApi,
|
||||
source_format: &preview.source.format,
|
||||
source_version: preview.source.version.as_deref(),
|
||||
status: ImportJobStatus::Pending,
|
||||
preview_payload: &preview_payload,
|
||||
created_at: &now,
|
||||
expires_at: &expires_at,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(OpenApiImportPreviewResponse {
|
||||
job_id: job_id.as_str().to_owned(),
|
||||
expires_at: expires_at
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
||||
preview,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), job_id = %job_id.as_str()))]
|
||||
pub async fn create_openapi_import(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
job_id: &ImportJobId,
|
||||
payload: OpenApiImportCreatePayload,
|
||||
) -> Result<OpenApiImportCreateResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let _ = self.registry.delete_expired_import_jobs().await;
|
||||
|
||||
if !matches!(payload.conflict_mode.as_str(), "skip" | "rename") {
|
||||
return Err(ApiError::validation(
|
||||
"unsupported conflict_mode; supported values are skip and rename",
|
||||
));
|
||||
}
|
||||
|
||||
let job = self
|
||||
.registry
|
||||
.get_import_job(workspace_id, job_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
"import job was not found",
|
||||
json!({ "job_id": job_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
if job.expires_at <= OffsetDateTime::now_utc() {
|
||||
return Err(ApiError::validation("import preview has expired"));
|
||||
}
|
||||
if job.kind != ImportJobKind::OpenApi {
|
||||
return Err(ApiError::validation("import job kind is not openapi"));
|
||||
}
|
||||
|
||||
let stored_preview = job
|
||||
.preview_payload
|
||||
.get("preview")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| job.preview_payload.clone());
|
||||
let preview: crank_import::rest::ImportPreview = serde_json::from_value(stored_preview)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
let selected = payload
|
||||
.selected_operation_keys
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if selected.is_empty() {
|
||||
return Err(ApiError::validation(
|
||||
"selected_operation_keys must contain at least one operation",
|
||||
));
|
||||
}
|
||||
|
||||
let mut candidates = BTreeMap::new();
|
||||
for group in &preview.groups {
|
||||
for operation in &group.operations {
|
||||
candidates.insert(operation.key.clone(), operation);
|
||||
}
|
||||
}
|
||||
|
||||
let mut skipped = Vec::new();
|
||||
let mut operations = Vec::new();
|
||||
|
||||
for operation_key in selected {
|
||||
let Some(candidate) = candidates.get(&operation_key) else {
|
||||
skipped.push(OpenApiImportSkippedOperation {
|
||||
operation_key,
|
||||
name: String::new(),
|
||||
reason: "operation was not found in import preview".to_owned(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let mut draft =
|
||||
operation_draft_from_candidate(candidate, payload.server_url.as_deref());
|
||||
attach_import_findings(&mut draft, candidate);
|
||||
let operation = self.new_operation_snapshot(OperationPayload {
|
||||
name: draft.name.clone(),
|
||||
display_name: draft.display_name.clone(),
|
||||
category: draft.category,
|
||||
protocol: Protocol::Rest,
|
||||
security_level: OperationSecurityLevel::Standard,
|
||||
target: crank_core::Target::Rest(draft.target),
|
||||
input_schema: draft.input_schema,
|
||||
output_schema: draft.output_schema,
|
||||
input_mapping: draft.input_mapping,
|
||||
output_mapping: draft.output_mapping,
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 10_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
tool_description: draft.tool_description,
|
||||
wizard_state: draft.wizard_state,
|
||||
})?;
|
||||
operations.push(ImportOperationDraft {
|
||||
operation_key: candidate.key.clone(),
|
||||
operation,
|
||||
});
|
||||
}
|
||||
|
||||
let finished_at = OffsetDateTime::now_utc();
|
||||
let application_key = openapi_application_key(&payload)?;
|
||||
let conflict_mode = if payload.conflict_mode == "skip" {
|
||||
ImportConflictMode::Skip
|
||||
} else {
|
||||
ImportConflictMode::Rename
|
||||
};
|
||||
let applied = self
|
||||
.registry
|
||||
.apply_import_job(ApplyImportJobRequest {
|
||||
id: job_id,
|
||||
workspace_id,
|
||||
application_key: &application_key,
|
||||
conflict_mode,
|
||||
operations: &operations,
|
||||
finished_at: &finished_at,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let created = applied
|
||||
.created
|
||||
.iter()
|
||||
.map(|operation| OpenApiImportCreatedOperation {
|
||||
operation_id: operation.operation_id.as_str().to_owned(),
|
||||
name: operation.name.clone(),
|
||||
version: operation.version,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut findings = applied
|
||||
.created
|
||||
.iter()
|
||||
.filter_map(|operation| {
|
||||
operation.renamed_from.as_ref().map(|previous_name| ImportFinding {
|
||||
code: "operation_name_renamed".to_owned(),
|
||||
severity: ImportFindingSeverity::Info,
|
||||
message: format!(
|
||||
"Операция {previous_name} уже существует, новый черновик создан как {}.",
|
||||
operation.name
|
||||
),
|
||||
operation_key: Some(operation.operation_key.clone()),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for operation in applied.skipped {
|
||||
skipped.push(OpenApiImportSkippedOperation {
|
||||
operation_key: operation.operation_key.clone(),
|
||||
name: operation.name.clone(),
|
||||
reason: "operation with this name already exists".to_owned(),
|
||||
});
|
||||
findings.push(ImportFinding {
|
||||
code: operation.reason,
|
||||
severity: ImportFindingSeverity::Warning,
|
||||
message: format!(
|
||||
"Операция {} уже существует и была пропущена.",
|
||||
operation.name
|
||||
),
|
||||
operation_key: Some(operation.operation_key),
|
||||
});
|
||||
}
|
||||
info!(
|
||||
name: "admin.openapi_import.completed",
|
||||
created = created.len(),
|
||||
skipped = skipped.len(),
|
||||
"openapi import created drafts"
|
||||
);
|
||||
|
||||
Ok(OpenApiImportCreateResponse {
|
||||
created,
|
||||
skipped,
|
||||
findings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn openapi_application_key(payload: &OpenApiImportCreatePayload) -> Result<String, ApiError> {
|
||||
let selected_operation_keys = payload
|
||||
.selected_operation_keys
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let canonical = serde_json::to_vec(&json!({
|
||||
"selected_operation_keys": selected_operation_keys,
|
||||
"server_url": payload.server_url.as_deref(),
|
||||
"conflict_mode": payload.conflict_mode.as_str(),
|
||||
}))
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
Ok(format!("{:x}", Sha256::digest(canonical)))
|
||||
}
|
||||
|
||||
fn attach_import_findings(
|
||||
draft: &mut crank_import::rest::RestImportCandidate,
|
||||
candidate: &ImportOperationCandidate,
|
||||
) {
|
||||
let findings = candidate
|
||||
.findings
|
||||
.iter()
|
||||
.map(tool_quality_finding_from_import)
|
||||
.collect::<Vec<_>>();
|
||||
if findings.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut wizard_state = draft.wizard_state.take().unwrap_or_default();
|
||||
wizard_state.import_findings = findings;
|
||||
draft.wizard_state = Some(wizard_state);
|
||||
}
|
||||
|
||||
fn tool_quality_finding_from_import(finding: &ImportFinding) -> ToolQualityFinding {
|
||||
ToolQualityFinding {
|
||||
severity: match finding.severity {
|
||||
ImportFindingSeverity::Info => ToolQualitySeverity::Info,
|
||||
ImportFindingSeverity::Warning => ToolQualitySeverity::Warning,
|
||||
ImportFindingSeverity::Error => ToolQualitySeverity::Error,
|
||||
},
|
||||
code: format!("openapi_import.{}", finding.code),
|
||||
message: finding.message.clone(),
|
||||
suggested_action: Some(
|
||||
"Откройте черновик в мастере и уточните описание, схемы или маппинг перед публикацией."
|
||||
.to_owned(),
|
||||
),
|
||||
field_path: finding.operation_key.clone(),
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
use crank_core::{
|
||||
AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crank_registry::{
|
||||
ApprovalRequestRecord, ExpireApprovalRequest, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, UsageQuery, UsageRollupRecord,
|
||||
};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, ApprovalsQuery, LogsQuery, UsageOverviewResponse, UsageRequestQuery,
|
||||
usage_window,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
pub async fn cleanup_invocation_logs_before(
|
||||
&self,
|
||||
cutoff: OffsetDateTime,
|
||||
) -> Result<u64, ApiError> {
|
||||
self.registry
|
||||
.delete_invocation_logs_before(cutoff)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_logs(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
query: LogsQuery,
|
||||
) -> Result<Vec<InvocationLogRecord>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let operation_id = query.operation_id.as_deref().map(OperationId::new);
|
||||
let agent_id = query.agent_id.as_deref().map(AgentId::new);
|
||||
let (_, created_after, _) = usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
|
||||
|
||||
self.registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id,
|
||||
level: query.level,
|
||||
search_text: query.search.as_deref(),
|
||||
source: query.source,
|
||||
operation_id: operation_id.as_ref(),
|
||||
agent_id: agent_id.as_ref(),
|
||||
created_after: Some(&created_after),
|
||||
limit: query.limit.unwrap_or(100),
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_log(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
log_id: &InvocationLogId,
|
||||
) -> Result<InvocationLogRecord, ApiError> {
|
||||
self.registry
|
||||
.get_invocation_log(workspace_id, log_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("invocation log {} was not found", log_id.as_str()),
|
||||
json!({ "log_id": log_id.as_str() }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_approvals(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
query: ApprovalsQuery,
|
||||
) -> Result<Vec<ApprovalRequestRecord>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let records = self
|
||||
.registry
|
||||
.list_approval_requests(ListApprovalRequestsQuery {
|
||||
workspace_id,
|
||||
status: query.status,
|
||||
limit: query.limit.unwrap_or(50).clamp(1, 200),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut normalized = Vec::with_capacity(records.len());
|
||||
for record in records {
|
||||
let record = self.normalize_approval_record(record).await?;
|
||||
if query.status.is_none() || record.approval.status == query.status.unwrap() {
|
||||
normalized.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_approval(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
approval_id: &ApprovalRequestId,
|
||||
) -> Result<ApprovalRequestRecord, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let record = self
|
||||
.registry
|
||||
.get_approval_request(workspace_id, approval_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("approval request {} was not found", approval_id.as_str()),
|
||||
json!({ "approval_id": approval_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
|
||||
self.normalize_approval_record(record).await
|
||||
}
|
||||
|
||||
async fn normalize_approval_record(
|
||||
&self,
|
||||
record: ApprovalRequestRecord,
|
||||
) -> Result<ApprovalRequestRecord, ApiError> {
|
||||
if record.approval.status != ApprovalRequestStatus::Pending
|
||||
|| record.approval.expires_at > OffsetDateTime::now_utc()
|
||||
{
|
||||
return Ok(record);
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.registry
|
||||
.expire_approval_request(ExpireApprovalRequest {
|
||||
workspace_id: &record.approval.workspace_id,
|
||||
agent_id: &record.approval.agent_id,
|
||||
approval_id: &record.approval.id,
|
||||
expired_at: OffsetDateTime::now_utc(),
|
||||
})
|
||||
.await?
|
||||
.unwrap_or(record))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_usage_overview(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
query: UsageRequestQuery,
|
||||
) -> Result<UsageOverviewResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let (period, created_after, bucket) =
|
||||
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
|
||||
let usage_query = UsageQuery {
|
||||
workspace_id,
|
||||
period,
|
||||
source: query.source,
|
||||
created_after: &created_after,
|
||||
bucket,
|
||||
};
|
||||
|
||||
let summary = self.registry.summarize_usage(usage_query.clone()).await?;
|
||||
let timeline = self
|
||||
.registry
|
||||
.list_usage_timeline(usage_query.clone())
|
||||
.await?;
|
||||
let operations = self
|
||||
.registry
|
||||
.list_usage_by_operation(usage_query.clone())
|
||||
.await?;
|
||||
let agents = self.registry.list_usage_by_agent(usage_query).await?;
|
||||
|
||||
Ok(UsageOverviewResponse {
|
||||
summary,
|
||||
timeline,
|
||||
operations,
|
||||
agents,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_operation_usage(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
query: UsageRequestQuery,
|
||||
) -> Result<UsageRollupRecord, ApiError> {
|
||||
self.get_operation(workspace_id, operation_id).await?;
|
||||
let (period, created_after, bucket) =
|
||||
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
|
||||
|
||||
self.registry
|
||||
.get_usage_for_operation(
|
||||
UsageQuery {
|
||||
workspace_id,
|
||||
period,
|
||||
source: query.source,
|
||||
created_after: &created_after,
|
||||
bucket,
|
||||
},
|
||||
operation_id,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!(
|
||||
"usage for operation {} was not found",
|
||||
operation_id.as_str()
|
||||
),
|
||||
json!({ "operation_id": operation_id.as_str() }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_agent_usage(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
query: UsageRequestQuery,
|
||||
) -> Result<UsageRollupRecord, ApiError> {
|
||||
self.get_agent(workspace_id, agent_id).await?;
|
||||
let (period, created_after, bucket) =
|
||||
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
|
||||
|
||||
self.registry
|
||||
.get_usage_for_agent(
|
||||
UsageQuery {
|
||||
workspace_id,
|
||||
period,
|
||||
source: query.source,
|
||||
created_after: &created_after,
|
||||
bucket,
|
||||
},
|
||||
agent_id,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("usage for agent {} was not found", agent_id.as_str()),
|
||||
json!({ "agent_id": agent_id.as_str() }),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
use crank_core::{Protocol, ResponseCachePolicy, Target};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
const MAX_OPERATION_TIMEOUT_MS: u64 = 300_000;
|
||||
|
||||
pub(super) fn validate_protocol_target(
|
||||
protocol: Protocol,
|
||||
target: &Target,
|
||||
) -> Result<(), ApiError> {
|
||||
let is_match = matches!((protocol, target), (Protocol::Rest, Target::Rest(_)));
|
||||
|
||||
if is_match {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ApiError::validation("protocol and target kind must match"))
|
||||
}
|
||||
|
||||
pub(super) fn validate_execution_timeout(
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
) -> Result<(), ApiError> {
|
||||
if !(1..=MAX_OPERATION_TIMEOUT_MS).contains(&execution_config.timeout_ms) {
|
||||
return Err(ApiError::validation_with_context(
|
||||
format!("operation timeout must be between 1 and {MAX_OPERATION_TIMEOUT_MS} ms"),
|
||||
json!({ "field": "execution_config.timeout_ms" }),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_response_cache_policy(
|
||||
target: &Target,
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
) -> Result<(), ApiError> {
|
||||
let Some(ResponseCachePolicy { ttl_ms }) = execution_config.response_cache.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if *ttl_ms == 0 {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"response cache ttl must be greater than zero".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.response_cache.ttl_ms",
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
match target {
|
||||
Target::Rest(rest_target) if rest_target.method == crank_core::HttpMethod::Get => {}
|
||||
_ => {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"response cache is supported only for REST GET operations".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.response_cache",
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if execution_config.auth_profile_ref.is_some() {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"response cache is not supported for operations with auth_profile_ref".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.auth_profile_ref",
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_idempotency_policy(
|
||||
target: &Target,
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
) -> Result<(), ApiError> {
|
||||
let Some(policy) = execution_config.idempotency.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if policy.mode == crank_core::IdempotencyMode::Disabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if policy.ttl_ms == 0 {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"idempotency ttl must be greater than zero".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.idempotency.ttl_ms",
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
match target {
|
||||
Target::Rest(rest_target) if rest_target.method != crank_core::HttpMethod::Get => {}
|
||||
_ => {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"idempotency is supported only for mutating REST operations".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.idempotency",
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if policy.mode == crank_core::IdempotencyMode::Required
|
||||
&& policy.input_field.as_deref().is_none_or(str::is_empty)
|
||||
&& policy.header_name.as_deref().is_none_or(str::is_empty)
|
||||
{
|
||||
return Err(ApiError::validation_with_context(
|
||||
"required idempotency needs input_field or header_name".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.idempotency",
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_approval_policy(
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
) -> Result<(), ApiError> {
|
||||
let Some(policy) = execution_config.approval_policy.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if !policy.required {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if policy.ttl_seconds == 0 || policy.ttl_seconds > 300 {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"approval ttl must be between 1 and 300 seconds".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.approval_policy.ttl_seconds",
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(message) = policy.elicitation_message.as_ref()
|
||||
&& message.chars().count() > 240
|
||||
{
|
||||
return Err(ApiError::validation_with_context(
|
||||
"approval elicitation message must be at most 240 characters".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.approval_policy.elicitation_message",
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_core::{
|
||||
ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, OperationApprovalMode,
|
||||
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
|
||||
ResponseCachePolicy, RestTarget, Target,
|
||||
};
|
||||
|
||||
use super::{
|
||||
validate_approval_policy, validate_execution_timeout, validate_idempotency_policy,
|
||||
validate_response_cache_policy,
|
||||
};
|
||||
|
||||
fn cacheable_execution_config() -> ExecutionConfig {
|
||||
ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: Some(ResponseCachePolicy { ttl_ms: 5_000 }),
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn idempotent_execution_config(mode: IdempotencyMode) -> ExecutionConfig {
|
||||
ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
idempotency: Some(IdempotencyPolicy {
|
||||
mode,
|
||||
ttl_ms: 5_000,
|
||||
input_field: Some("request_id".to_owned()),
|
||||
header_name: Some("Idempotency-Key".to_owned()),
|
||||
}),
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_response_cache_for_rest_get() {
|
||||
let target = Target::Rest(RestTarget {
|
||||
base_url: "http://example.invalid".to_owned(),
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/catalog".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
});
|
||||
|
||||
let result = validate_response_cache_policy(&target, &cacheable_execution_config());
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_and_excessive_execution_timeouts() {
|
||||
let mut config = cacheable_execution_config();
|
||||
config.timeout_ms = 0;
|
||||
assert!(validate_execution_timeout(&config).is_err());
|
||||
|
||||
config.timeout_ms = 300_001;
|
||||
assert!(validate_execution_timeout(&config).is_err());
|
||||
|
||||
config.timeout_ms = 300_000;
|
||||
assert!(validate_execution_timeout(&config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_response_cache_for_non_get_rest_operation() {
|
||||
let target = Target::Rest(RestTarget {
|
||||
base_url: "http://example.invalid".to_owned(),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/catalog".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
});
|
||||
|
||||
let error =
|
||||
validate_response_cache_policy(&target, &cacheable_execution_config()).unwrap_err();
|
||||
|
||||
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"response cache is supported only for REST GET operations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_idempotency_for_mutating_rest_operation() {
|
||||
let target = Target::Rest(RestTarget {
|
||||
base_url: "http://example.invalid".to_owned(),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/orders".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
});
|
||||
|
||||
let result = validate_idempotency_policy(
|
||||
&target,
|
||||
&idempotent_execution_config(IdempotencyMode::Required),
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_idempotency_for_rest_get() {
|
||||
let target = Target::Rest(RestTarget {
|
||||
base_url: "http://example.invalid".to_owned(),
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/orders".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
});
|
||||
|
||||
let error = validate_idempotency_policy(
|
||||
&target,
|
||||
&idempotent_execution_config(IdempotencyMode::Optional),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"idempotency is supported only for mutating REST operations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_required_idempotency_without_key_source() {
|
||||
let target = Target::Rest(RestTarget {
|
||||
base_url: "http://example.invalid".to_owned(),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/orders".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
});
|
||||
let mut config = idempotent_execution_config(IdempotencyMode::Required);
|
||||
let policy = config.idempotency.as_mut().unwrap();
|
||||
policy.input_field = None;
|
||||
policy.header_name = None;
|
||||
|
||||
let error = validate_idempotency_policy(&target, &config).unwrap_err();
|
||||
|
||||
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"required idempotency needs input_field or header_name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_approval_policy() {
|
||||
let mut config = cacheable_execution_config();
|
||||
config.approval_policy = Some(OperationApprovalPolicy {
|
||||
required: true,
|
||||
mode: OperationApprovalMode::Custom,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
ttl_seconds: 300,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||
elicitation_message: None,
|
||||
});
|
||||
|
||||
validate_approval_policy(&config).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_approval_policy() {
|
||||
let mut config = cacheable_execution_config();
|
||||
config.approval_policy = Some(OperationApprovalPolicy {
|
||||
required: true,
|
||||
mode: OperationApprovalMode::Custom,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
ttl_seconds: 0,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||
elicitation_message: None,
|
||||
});
|
||||
|
||||
let error = validate_approval_policy(&config).unwrap_err();
|
||||
|
||||
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"approval ttl must be between 1 and 300 seconds"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
use crank_core::{
|
||||
ConfigExport, ExecutionMode, ExportMode, InvocationLevel, InvocationSource, InvocationStatus,
|
||||
OperationId, OperationStatus, Samples, WorkspaceId,
|
||||
};
|
||||
use crank_registry::{
|
||||
CreateVersionRequest, OperationVersionRecord, PublishRequest, RegistryOperation,
|
||||
};
|
||||
use crank_runtime::{
|
||||
RuntimeError, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, CreatedOperationResponse, InvocationRecordRequest, NewVersionPayload,
|
||||
OperationDetailView, OperationMutationResult, OperationPayload, OperationSummaryView,
|
||||
PublishResponse, TestRunPayload, TestRunResult, UpdateOperationPayload, VersionRef,
|
||||
agent_ref_map, build_request_preview, default_usage_summary, enrich_operation_summary,
|
||||
format_timestamp, new_prefixed_id, now_string, runtime_error_code, today_start_utc,
|
||||
tool_quality_mapping_set, tool_quality_schema_node, usage_map,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
pub async fn list_operations(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<Vec<OperationSummaryView>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let summaries = self.registry.list_operations(workspace_id).await?;
|
||||
let usage = self
|
||||
.registry
|
||||
.list_operation_usage_summaries(workspace_id, &today_start_utc()?)
|
||||
.await?;
|
||||
let agent_refs = self
|
||||
.registry
|
||||
.list_operation_agent_refs(workspace_id)
|
||||
.await?;
|
||||
let usage_by_operation = usage_map(usage);
|
||||
let refs_by_operation = agent_ref_map(agent_refs);
|
||||
|
||||
Ok(summaries
|
||||
.into_iter()
|
||||
.map(|summary| {
|
||||
let operation_id = summary.id.as_str().to_owned();
|
||||
enrich_operation_summary(
|
||||
summary,
|
||||
usage_by_operation
|
||||
.get(&operation_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(default_usage_summary),
|
||||
refs_by_operation
|
||||
.get(&operation_id)
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_operation(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
) -> Result<OperationDetailView, ApiError> {
|
||||
let summary = self
|
||||
.registry
|
||||
.get_operation_summary(workspace_id, operation_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("operation {} was not found", operation_id.as_str()),
|
||||
json!({ "operation_id": operation_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
let agent_refs = self
|
||||
.registry
|
||||
.list_operation_agent_refs(workspace_id)
|
||||
.await?;
|
||||
let refs = agent_ref_map(agent_refs)
|
||||
.remove(operation_id.as_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(OperationDetailView {
|
||||
id: summary.id.as_str().to_owned(),
|
||||
workspace_id: summary.workspace_id.as_str().to_owned(),
|
||||
name: summary.name,
|
||||
display_name: summary.display_name,
|
||||
category: summary.category,
|
||||
protocol: summary.protocol,
|
||||
security_level: summary.security_level,
|
||||
status: summary.status,
|
||||
current_draft_version: summary.current_draft_version,
|
||||
latest_published_version: summary.latest_published_version,
|
||||
created_at: format_timestamp(summary.created_at),
|
||||
updated_at: format_timestamp(summary.updated_at),
|
||||
published_at: summary.published_at.map(format_timestamp),
|
||||
draft_version_ref: VersionRef {
|
||||
version: summary.current_draft_version,
|
||||
status: summary.status,
|
||||
},
|
||||
published_version_ref: summary.latest_published_version.map(|version| VersionRef {
|
||||
version,
|
||||
status: OperationStatus::Published,
|
||||
}),
|
||||
agent_refs: refs,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_operation_version(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
version: u32,
|
||||
) -> Result<OperationVersionRecord, ApiError> {
|
||||
self.registry
|
||||
.get_operation_version(workspace_id, operation_id, version)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!(
|
||||
"operation version {version} for {} was not found",
|
||||
operation_id.as_str()
|
||||
),
|
||||
json!({
|
||||
"operation_id": operation_id.as_str(),
|
||||
"version": version,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(protocol = ?payload.protocol, operation_name = %payload.name))]
|
||||
pub async fn create_operation(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: OperationPayload,
|
||||
) -> Result<CreatedOperationResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
|
||||
if self
|
||||
.find_operation_by_name(workspace_id, &payload.name)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::conflict_with_context(
|
||||
format!("operation with name {} already exists", payload.name),
|
||||
json!({ "name": payload.name }),
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = self.new_operation_snapshot(payload)?;
|
||||
let operation_id = snapshot.id.clone();
|
||||
|
||||
self.registry
|
||||
.create_operation(workspace_id, &snapshot, None)
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.operation.created",
|
||||
operation_id = %operation_id.as_str(),
|
||||
version = 1,
|
||||
"operation created"
|
||||
);
|
||||
|
||||
Ok(CreatedOperationResponse {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version: 1,
|
||||
status: OperationStatus::Draft,
|
||||
updated_at: format_timestamp(snapshot.updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn new_operation_snapshot(
|
||||
&self,
|
||||
payload: OperationPayload,
|
||||
) -> Result<RegistryOperation, ApiError> {
|
||||
self.validate_operation_payload(&payload)?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
Ok(RegistryOperation {
|
||||
id: OperationId::new(new_prefixed_id("op")),
|
||||
name: payload.name,
|
||||
display_name: payload.display_name,
|
||||
category: payload.category,
|
||||
protocol: payload.protocol,
|
||||
security_level: payload.security_level,
|
||||
status: OperationStatus::Draft,
|
||||
version: 1,
|
||||
target: payload.target,
|
||||
input_schema: payload.input_schema,
|
||||
output_schema: payload.output_schema,
|
||||
input_mapping: payload.input_mapping,
|
||||
output_mapping: payload.output_mapping,
|
||||
execution_config: payload.execution_config,
|
||||
tool_description: payload.tool_description,
|
||||
samples: Some(Samples::default()),
|
||||
generated_draft: None,
|
||||
config_export: Some(ConfigExport {
|
||||
format_version: "1".to_owned(),
|
||||
export_mode: ExportMode::Portable,
|
||||
}),
|
||||
wizard_state: payload.wizard_state,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
published_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(protocol = ?payload.protocol, operation_name = %payload.name))]
|
||||
pub async fn analyze_operation_quality(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: OperationPayload,
|
||||
) -> Result<crank_core::ToolQualityReport, ApiError> {
|
||||
self.validate_operation_payload(&payload)?;
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
|
||||
let mut findings =
|
||||
crank_core::analyze_tool_identity_quality(&payload.name, &payload.tool_description)
|
||||
.findings;
|
||||
let input_schema = tool_quality_schema_node(&payload.input_schema);
|
||||
findings.extend(
|
||||
crank_core::analyze_tool_schema_quality("input_schema", &input_schema).findings,
|
||||
);
|
||||
let output_mapping = tool_quality_mapping_set(&payload.output_mapping);
|
||||
findings
|
||||
.extend(crank_core::analyze_tool_response_projection_quality(&output_mapping).findings);
|
||||
|
||||
Ok(crank_core::ToolQualityReport::new(findings))
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), protocol = ?payload.operation.protocol))]
|
||||
pub async fn create_version(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
payload: NewVersionPayload,
|
||||
) -> Result<CreatedOperationResponse, ApiError> {
|
||||
self.validate_operation_payload(&payload.operation)?;
|
||||
|
||||
let summary = self
|
||||
.registry
|
||||
.get_operation_summary(workspace_id, operation_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("operation {} was not found", operation_id.as_str()),
|
||||
json!({ "operation_id": operation_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let version = summary.current_draft_version + 1;
|
||||
let snapshot = RegistryOperation {
|
||||
id: operation_id.clone(),
|
||||
name: payload.operation.name,
|
||||
display_name: payload.operation.display_name,
|
||||
category: payload.operation.category,
|
||||
protocol: payload.operation.protocol,
|
||||
security_level: payload.operation.security_level,
|
||||
status: OperationStatus::Draft,
|
||||
version,
|
||||
target: payload.operation.target,
|
||||
input_schema: payload.operation.input_schema,
|
||||
output_schema: payload.operation.output_schema,
|
||||
input_mapping: payload.operation.input_mapping,
|
||||
output_mapping: payload.operation.output_mapping,
|
||||
execution_config: payload.operation.execution_config,
|
||||
tool_description: payload.operation.tool_description,
|
||||
samples: Some(Samples::default()),
|
||||
generated_draft: None,
|
||||
config_export: Some(ConfigExport {
|
||||
format_version: "1".to_owned(),
|
||||
export_mode: ExportMode::Portable,
|
||||
}),
|
||||
wizard_state: payload.operation.wizard_state,
|
||||
created_at: summary.created_at,
|
||||
updated_at: now,
|
||||
published_at: None,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.create_version(CreateVersionRequest {
|
||||
workspace_id,
|
||||
snapshot: &snapshot,
|
||||
change_note: payload.change_note.as_deref(),
|
||||
created_by: None,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.operation.version_created",
|
||||
operation_id = %operation_id.as_str(),
|
||||
version,
|
||||
"operation version created"
|
||||
);
|
||||
|
||||
Ok(CreatedOperationResponse {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version,
|
||||
status: OperationStatus::Draft,
|
||||
updated_at: format_timestamp(snapshot.updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str()))]
|
||||
pub async fn update_operation(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
payload: UpdateOperationPayload,
|
||||
) -> Result<OperationMutationResult, ApiError> {
|
||||
let existing = self
|
||||
.get_operation_version(
|
||||
workspace_id,
|
||||
operation_id,
|
||||
self.get_operation(workspace_id, operation_id)
|
||||
.await?
|
||||
.current_draft_version,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
let snapshot = RegistryOperation {
|
||||
id: operation_id.clone(),
|
||||
name: existing.snapshot.name,
|
||||
display_name: payload.display_name,
|
||||
category: payload.category,
|
||||
protocol: existing.snapshot.protocol,
|
||||
security_level: payload.security_level,
|
||||
status: OperationStatus::Draft,
|
||||
version: existing.version,
|
||||
target: payload.target,
|
||||
input_schema: payload.input_schema,
|
||||
output_schema: payload.output_schema,
|
||||
input_mapping: payload.input_mapping,
|
||||
output_mapping: payload.output_mapping,
|
||||
execution_config: payload.execution_config,
|
||||
tool_description: payload.tool_description,
|
||||
samples: existing.snapshot.samples,
|
||||
generated_draft: existing.snapshot.generated_draft,
|
||||
config_export: existing.snapshot.config_export,
|
||||
wizard_state: payload.wizard_state,
|
||||
created_at: existing.snapshot.created_at,
|
||||
updated_at,
|
||||
published_at: existing.snapshot.published_at,
|
||||
};
|
||||
|
||||
self.validate_registry_operation(&snapshot)?;
|
||||
self.registry
|
||||
.update_operation_draft(workspace_id, &snapshot)
|
||||
.await?;
|
||||
|
||||
Ok(OperationMutationResult {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version: snapshot.version,
|
||||
status: snapshot.status,
|
||||
updated_at: format_timestamp(updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version))]
|
||||
pub async fn publish_operation(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
version: u32,
|
||||
) -> Result<PublishResponse, ApiError> {
|
||||
let published_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id,
|
||||
operation_id,
|
||||
version,
|
||||
published_at: &published_at,
|
||||
published_by: None,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.operation.published",
|
||||
operation_id = %operation_id.as_str(),
|
||||
version,
|
||||
"operation published"
|
||||
);
|
||||
|
||||
Ok(PublishResponse {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
published_version: version,
|
||||
published_at: format_timestamp(published_at),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(operation_id = %operation_id.as_str()))]
|
||||
pub async fn archive_operation(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
) -> Result<OperationMutationResult, ApiError> {
|
||||
let summary = self.get_operation(workspace_id, operation_id).await?;
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.archive_operation(workspace_id, operation_id, &updated_at)
|
||||
.await?;
|
||||
|
||||
Ok(OperationMutationResult {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version: summary.current_draft_version,
|
||||
status: OperationStatus::Archived,
|
||||
updated_at: format_timestamp(updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(operation_id = %operation_id.as_str()))]
|
||||
pub async fn delete_operation(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
) -> Result<OperationMutationResult, ApiError> {
|
||||
let summary = self.get_operation(workspace_id, operation_id).await?;
|
||||
let updated_at = now_string()?;
|
||||
self.registry
|
||||
.delete_operation(workspace_id, operation_id)
|
||||
.await?;
|
||||
|
||||
Ok(OperationMutationResult {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version: summary.current_draft_version,
|
||||
status: summary.status,
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), version = payload.version))]
|
||||
pub async fn run_test(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
payload: TestRunPayload,
|
||||
request_id: &str,
|
||||
) -> Result<TestRunResult, ApiError> {
|
||||
let runtime_request_context = RuntimeRequestContext::from_request_id(request_id)
|
||||
.with_metering_context(workspace_id.clone(), None, InvocationSource::AdminTestRun);
|
||||
let record = self
|
||||
.get_operation_version(workspace_id, operation_id, payload.version)
|
||||
.await?;
|
||||
let runtime = RuntimeOperation::from(record.snapshot.clone());
|
||||
let mode = ExecutionMode::Unary;
|
||||
let preview_span = crank_trace::Stage::RuntimeArgumentsMap.span();
|
||||
let preview_result = preview_span
|
||||
.in_scope(|| build_request_preview(&record.snapshot.input_mapping, &payload.input));
|
||||
let request_preview = match preview_result {
|
||||
Ok(preview) => preview,
|
||||
Err(error) => {
|
||||
crank_trace::StageOutcome::Error.record(&preview_span);
|
||||
crank_trace::ErrorCategory::Mapping.record(&preview_span);
|
||||
drop(preview_span);
|
||||
self.record_invocation(InvocationRecordRequest {
|
||||
workspace_id,
|
||||
agent_id: None,
|
||||
operation: &record.snapshot,
|
||||
request_id: Some(request_id),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Error,
|
||||
status: InvocationStatus::Error,
|
||||
message: "mapping preview failed".to_owned(),
|
||||
status_code: None,
|
||||
error_kind: Some("mapping".to_owned()),
|
||||
duration_ms: 0,
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
})
|
||||
.await;
|
||||
return Ok(TestRunResult {
|
||||
ok: false,
|
||||
mode,
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping(
|
||||
error,
|
||||
))],
|
||||
});
|
||||
}
|
||||
};
|
||||
crank_trace::StageOutcome::Success.record(&preview_span);
|
||||
drop(preview_span);
|
||||
|
||||
let resolved_auth = self
|
||||
.resolve_operation_auth(workspace_id, &runtime.execution_config)
|
||||
.await;
|
||||
let started_at = std::time::Instant::now();
|
||||
match match resolved_auth {
|
||||
Ok(resolved_auth) => {
|
||||
self.runtime
|
||||
.execute_request(
|
||||
RuntimeExecutionRequest::new(&runtime, &payload.input)
|
||||
.with_optional_auth(resolved_auth.as_ref())
|
||||
.with_context(&runtime_request_context),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
} {
|
||||
Ok(response_preview) => {
|
||||
let duration_ms =
|
||||
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
self.record_invocation(InvocationRecordRequest {
|
||||
workspace_id,
|
||||
agent_id: None,
|
||||
operation: &record.snapshot,
|
||||
request_id: Some(request_id),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Info,
|
||||
status: InvocationStatus::Ok,
|
||||
message: "admin test run completed".to_owned(),
|
||||
status_code: None,
|
||||
error_kind: None,
|
||||
duration_ms,
|
||||
request_preview: request_preview.clone(),
|
||||
response_preview: response_preview.clone(),
|
||||
})
|
||||
.await;
|
||||
Ok(TestRunResult {
|
||||
ok: true,
|
||||
mode,
|
||||
request_preview,
|
||||
response_preview,
|
||||
errors: Vec::new(),
|
||||
})
|
||||
}
|
||||
Err(error) => {
|
||||
let duration_ms =
|
||||
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
self.record_invocation(InvocationRecordRequest {
|
||||
workspace_id,
|
||||
agent_id: None,
|
||||
operation: &record.snapshot,
|
||||
request_id: Some(request_id),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Error,
|
||||
status: InvocationStatus::Error,
|
||||
message: error.to_string(),
|
||||
status_code: None,
|
||||
error_kind: Some(runtime_error_code(&error).to_owned()),
|
||||
duration_ms,
|
||||
request_preview: request_preview.clone(),
|
||||
response_preview: Value::Null,
|
||||
})
|
||||
.await;
|
||||
Ok(TestRunResult {
|
||||
ok: false,
|
||||
mode,
|
||||
request_preview,
|
||||
response_preview: Value::Null,
|
||||
errors: vec![crate::error::runtime_test_failure(&error)],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
use crank_core::{GeneratedDraft, GeneratedDraftStatus, OperationId, SampleId, WorkspaceId};
|
||||
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
|
||||
use crank_registry::{OperationSampleMetadata, SampleKind, SaveSampleMetadataRequest};
|
||||
use crank_schema::Schema;
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, DraftGenerationResult, GenerateDraftPayload, new_prefixed_id, now_string,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), sample_kind = ?sample_kind))]
|
||||
pub async fn save_json_sample(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
sample_kind: SampleKind,
|
||||
payload: &Value,
|
||||
) -> Result<OperationSampleMetadata, ApiError> {
|
||||
let summary = self.get_operation(workspace_id, operation_id).await?;
|
||||
let version = summary.current_draft_version;
|
||||
let sample_id = SampleId::new(new_prefixed_id("sample"));
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let file_name = match sample_kind {
|
||||
SampleKind::InputJson => "input.json",
|
||||
SampleKind::OutputJson => "output.json",
|
||||
SampleKind::YamlImportSource => "source.yaml",
|
||||
};
|
||||
let storage_ref = self
|
||||
.storage
|
||||
.write_json_sample(operation_id, version, sample_kind, &sample_id, payload)
|
||||
.await?;
|
||||
let metadata = OperationSampleMetadata {
|
||||
id: sample_id,
|
||||
operation_id: operation_id.clone(),
|
||||
version,
|
||||
sample_kind,
|
||||
storage_ref,
|
||||
content_type: "application/json".to_owned(),
|
||||
file_name: Some(file_name.to_owned()),
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata })
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.sample.saved",
|
||||
operation_id = %operation_id.as_str(),
|
||||
sample_id = %metadata.id.as_str(),
|
||||
version,
|
||||
"json sample saved"
|
||||
);
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str()))]
|
||||
pub async fn generate_draft(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
payload: GenerateDraftPayload,
|
||||
) -> Result<DraftGenerationResult, ApiError> {
|
||||
let summary = self.get_operation(workspace_id, operation_id).await?;
|
||||
let samples = self
|
||||
.registry
|
||||
.list_sample_metadata(operation_id, summary.current_draft_version)
|
||||
.await?;
|
||||
|
||||
let input_sample = latest_sample_ref(&samples, SampleKind::InputJson)
|
||||
.ok_or_else(|| ApiError::validation("input_json sample was not found"))?;
|
||||
let output_sample = latest_sample_ref(&samples, SampleKind::OutputJson)
|
||||
.ok_or_else(|| ApiError::validation("output_json sample was not found"))?;
|
||||
let input_value = self.storage.read_json(&input_sample.storage_ref).await?;
|
||||
let output_value = self.storage.read_json(&output_sample.storage_ref).await?;
|
||||
|
||||
let input_schema = Schema::from_json_sample(&input_value);
|
||||
let output_schema = Schema::from_json_sample(&output_value);
|
||||
let input_mapping = infer_mapping_from_samples(
|
||||
&input_value,
|
||||
JsonPathRoot::Mcp,
|
||||
&input_value,
|
||||
JsonPathRoot::RequestBody,
|
||||
);
|
||||
let output_mapping = infer_mapping_from_samples(
|
||||
&output_value,
|
||||
JsonPathRoot::ResponseBody,
|
||||
&output_value,
|
||||
JsonPathRoot::Output,
|
||||
);
|
||||
let source_types = if payload.sources.is_empty() {
|
||||
vec![
|
||||
"input_json_sample".to_owned(),
|
||||
"output_json_sample".to_owned(),
|
||||
]
|
||||
} else {
|
||||
payload.sources
|
||||
};
|
||||
let generated_draft = GeneratedDraft {
|
||||
status: GeneratedDraftStatus::Available,
|
||||
source_types,
|
||||
generated_at: Some(now_string()?),
|
||||
input_schema_generated: true,
|
||||
output_schema_generated: true,
|
||||
input_mapping_generated: true,
|
||||
output_mapping_generated: true,
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
|
||||
let result = DraftGenerationResult {
|
||||
generated_draft,
|
||||
input_schema,
|
||||
output_schema,
|
||||
input_mapping,
|
||||
output_mapping,
|
||||
};
|
||||
info!(
|
||||
name: "admin.operation_draft.generated",
|
||||
operation_id = %operation_id.as_str(),
|
||||
"draft generated from samples"
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_sample_ref(
|
||||
samples: &[OperationSampleMetadata],
|
||||
sample_kind: SampleKind,
|
||||
) -> Option<OperationSampleMetadata> {
|
||||
samples
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|sample| sample.sample_kind == sample_kind)
|
||||
.cloned()
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
use crank_core::{
|
||||
AuthConfig, AuthKind, AuthProfile, AuthProfileId, Secret, SecretId, SecretStatus, UserId,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crank_registry::{
|
||||
CreateSecretRequest, RegistryError, RotateSecretRequest, SaveAuthProfileRequest,
|
||||
};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, AuthProfilePayload, RotateSecretPayload, SecretPayload, new_prefixed_id,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_auth_profiles(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<Vec<AuthProfile>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
Ok(self.registry.list_auth_profiles(workspace_id).await?)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_secrets(&self, workspace_id: &WorkspaceId) -> Result<Vec<Secret>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
Ok(self
|
||||
.registry
|
||||
.list_secrets(workspace_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|record| record.secret)
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<Secret, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
self.registry
|
||||
.get_secret(workspace_id, secret_id)
|
||||
.await?
|
||||
.map(|record| record.secret)
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("secret {} was not found", secret_id.as_str()),
|
||||
json!({ "secret_id": secret_id.as_str() }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))]
|
||||
pub async fn create_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
created_by: Option<&UserId>,
|
||||
payload: SecretPayload,
|
||||
) -> Result<Secret, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
validate_secret_payload(&payload)?;
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let secret = Secret {
|
||||
id: SecretId::new(new_prefixed_id("secret")),
|
||||
workspace_id: workspace_id.clone(),
|
||||
name: payload.name.trim().to_owned(),
|
||||
kind: payload.kind,
|
||||
status: SecretStatus::Active,
|
||||
current_version: 1,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_used_at: None,
|
||||
};
|
||||
let ciphertext = self
|
||||
.secret_crypto
|
||||
.encrypt(&payload.value)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
|
||||
self.registry
|
||||
.create_secret(CreateSecretRequest {
|
||||
secret: &secret,
|
||||
ciphertext: &ciphertext,
|
||||
key_version: self.secret_crypto.key_version(),
|
||||
created_by,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.secret.created",
|
||||
secret_id = %secret.id.as_str(),
|
||||
"secret created"
|
||||
);
|
||||
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
|
||||
pub async fn rotate_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
created_by: Option<&UserId>,
|
||||
payload: RotateSecretPayload,
|
||||
) -> Result<Secret, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
if payload.value.is_null() {
|
||||
return Err(ApiError::validation("secret value must not be null"));
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let ciphertext = self
|
||||
.secret_crypto
|
||||
.encrypt(&payload.value)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
self.registry
|
||||
.rotate_secret(RotateSecretRequest {
|
||||
workspace_id,
|
||||
secret_id,
|
||||
ciphertext: &ciphertext,
|
||||
key_version: self.secret_crypto.key_version(),
|
||||
created_at: &now,
|
||||
updated_at: &now,
|
||||
created_by,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.secret.rotated",
|
||||
secret_id = %secret_id.as_str(),
|
||||
"secret rotated"
|
||||
);
|
||||
|
||||
self.get_secret(workspace_id, secret_id).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
|
||||
pub async fn delete_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<(), ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
if let Some(profile) = self
|
||||
.registry
|
||||
.list_auth_profiles_referencing_secret(workspace_id, secret_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
return Err(RegistryError::SecretReferencedByAuthProfile {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
auth_profile_id: profile.id.as_str().to_owned(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
self.registry.delete_secret(workspace_id, secret_id).await?;
|
||||
info!(
|
||||
name: "admin.secret.deleted",
|
||||
secret_id = %secret_id.as_str(),
|
||||
"secret deleted"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_auth_profile(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
auth_profile_id: &AuthProfileId,
|
||||
) -> Result<AuthProfile, ApiError> {
|
||||
self.registry
|
||||
.get_auth_profile(workspace_id, auth_profile_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("auth profile {} was not found", auth_profile_id.as_str()),
|
||||
json!({ "auth_profile_id": auth_profile_id.as_str() }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))]
|
||||
pub async fn create_auth_profile(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: AuthProfilePayload,
|
||||
) -> Result<AuthProfile, ApiError> {
|
||||
validate_auth_profile_kind(payload.kind, &payload.config)?;
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
self.validate_auth_profile_secret_ids(workspace_id, &payload.config)
|
||||
.await?;
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let profile = AuthProfile {
|
||||
id: AuthProfileId::new(new_prefixed_id("auth")),
|
||||
workspace_id: workspace_id.clone(),
|
||||
name: payload.name,
|
||||
kind: payload.kind,
|
||||
config: payload.config,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.save_auth_profile(SaveAuthProfileRequest {
|
||||
workspace_id,
|
||||
profile: &profile,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.auth_profile.created",
|
||||
auth_profile_id = %profile.id.as_str(),
|
||||
"auth profile created"
|
||||
);
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
async fn validate_auth_profile_secret_ids(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
config: &AuthConfig,
|
||||
) -> Result<(), ApiError> {
|
||||
for secret_id in config.secret_ids() {
|
||||
self.get_secret(workspace_id, secret_id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> {
|
||||
let is_match = matches!(
|
||||
(kind, config),
|
||||
(AuthKind::Bearer, AuthConfig::Bearer(_))
|
||||
| (AuthKind::Basic, AuthConfig::Basic(_))
|
||||
| (AuthKind::ApiKeyHeader, AuthConfig::ApiKeyHeader(_))
|
||||
| (AuthKind::ApiKeyQuery, AuthConfig::ApiKeyQuery(_))
|
||||
);
|
||||
|
||||
if is_match {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ApiError::validation("auth kind and config must match"))
|
||||
}
|
||||
|
||||
fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> {
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(ApiError::validation("secret name must not be empty"));
|
||||
}
|
||||
|
||||
if payload.value.is_null() {
|
||||
return Err(ApiError::validation("secret value must not be null"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
use crank_core::{AuthProfileId, WorkspaceId};
|
||||
use crank_registry::{SaveWorkspaceUpstreamRequest, WorkspaceUpstream, WorkspaceUpstreamId};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{AdminService, UpstreamPayload, new_prefixed_id},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_workspace_upstreams(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<Vec<WorkspaceUpstream>, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
self.ensure_default_workspace_upstreams(workspace_id)
|
||||
.await?;
|
||||
Ok(self.registry.list_workspace_upstreams(workspace_id).await?)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), upstream_name = %payload.name))]
|
||||
pub async fn save_workspace_upstream(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
upstream_id: Option<&WorkspaceUpstreamId>,
|
||||
payload: UpstreamPayload,
|
||||
) -> Result<WorkspaceUpstream, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
validate_upstream_payload(&payload)?;
|
||||
|
||||
if let Some(auth_profile_id) = payload.auth_profile_id.as_deref() {
|
||||
self.get_auth_profile(
|
||||
workspace_id,
|
||||
&AuthProfileId::new(auth_profile_id.to_owned()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let existing = match upstream_id {
|
||||
Some(id) => {
|
||||
self.registry
|
||||
.get_workspace_upstream(workspace_id, id)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if upstream_id.is_some() && existing.is_none() {
|
||||
return Err(ApiError::not_found_with_context(
|
||||
"upstream was not found",
|
||||
json!({ "upstream_id": upstream_id.map(|id| id.as_str()).unwrap_or_default() }),
|
||||
));
|
||||
}
|
||||
let upstream = WorkspaceUpstream {
|
||||
id: existing
|
||||
.as_ref()
|
||||
.map(|item| item.id.clone())
|
||||
.unwrap_or_else(|| WorkspaceUpstreamId::new(new_prefixed_id("upstream"))),
|
||||
workspace_id: workspace_id.clone(),
|
||||
name: payload.name.trim().to_owned(),
|
||||
base_url: normalize_base_url(&payload.base_url),
|
||||
static_headers: payload.static_headers,
|
||||
auth_profile_id: payload
|
||||
.auth_profile_id
|
||||
.filter(|value| !value.trim().is_empty()),
|
||||
created_at: existing.as_ref().map(|item| item.created_at).unwrap_or(now),
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.save_workspace_upstream(SaveWorkspaceUpstreamRequest {
|
||||
upstream: &upstream,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.upstream.saved",
|
||||
upstream_id = %upstream.id.as_str(),
|
||||
"workspace upstream saved"
|
||||
);
|
||||
|
||||
Ok(upstream)
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_default_workspace_upstreams(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<(), ApiError> {
|
||||
let existing = self.registry.list_workspace_upstreams(workspace_id).await?;
|
||||
if existing.iter().any(|item| item.name == "Frankfurter") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let existing_open_meteo = existing
|
||||
.iter()
|
||||
.find(|item| {
|
||||
item.name == "Open Meteo"
|
||||
&& item.base_url == "https://api.open-meteo.com"
|
||||
&& item.auth_profile_id.is_none()
|
||||
})
|
||||
.cloned();
|
||||
let upstream = WorkspaceUpstream {
|
||||
id: existing_open_meteo
|
||||
.as_ref()
|
||||
.map(|item| item.id.clone())
|
||||
.unwrap_or_else(|| WorkspaceUpstreamId::new(new_prefixed_id("upstream"))),
|
||||
workspace_id: workspace_id.clone(),
|
||||
name: "Frankfurter".to_owned(),
|
||||
base_url: "https://api.frankfurter.dev".to_owned(),
|
||||
static_headers: json!({}),
|
||||
auth_profile_id: None,
|
||||
created_at: existing_open_meteo
|
||||
.as_ref()
|
||||
.map(|item| item.created_at)
|
||||
.unwrap_or(now),
|
||||
updated_at: now,
|
||||
};
|
||||
self.registry
|
||||
.save_workspace_upstream(SaveWorkspaceUpstreamRequest {
|
||||
upstream: &upstream,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_upstream_payload(payload: &UpstreamPayload) -> Result<(), ApiError> {
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(ApiError::validation("upstream name is required"));
|
||||
}
|
||||
let base_url = normalize_base_url(&payload.base_url);
|
||||
if !(base_url.starts_with("https://") || base_url.starts_with("http://")) {
|
||||
return Err(ApiError::validation(
|
||||
"upstream base_url must start with http:// or https://",
|
||||
));
|
||||
}
|
||||
if !payload.static_headers.is_object() {
|
||||
return Err(ApiError::validation(
|
||||
"upstream static_headers must be a JSON object",
|
||||
));
|
||||
}
|
||||
if let Some(headers) = payload.static_headers.as_object() {
|
||||
for (key, value) in headers {
|
||||
if key.trim().is_empty() || !value.is_string() {
|
||||
return Err(ApiError::validation(
|
||||
"upstream static_headers must contain string values",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_base_url(value: &str) -> String {
|
||||
value.trim().trim_end_matches('/').to_owned()
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
use crank_core::{MembershipRole, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus};
|
||||
use crank_registry::{
|
||||
CreateWorkspaceRequest, UpdateWorkspaceRequest, WorkspaceMembershipRecord, WorkspaceRecord,
|
||||
};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, SessionResponse, UpdateWorkspacePayload, WorkspacePayload, new_prefixed_id,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
pub async fn list_workspaces_for_user(
|
||||
&self,
|
||||
user_id: &crank_core::UserId,
|
||||
) -> Result<Vec<WorkspaceMembershipRecord>, ApiError> {
|
||||
Ok(self.registry.list_workspaces_for_user(user_id).await?)
|
||||
}
|
||||
|
||||
pub async fn user_has_workspace_access(
|
||||
&self,
|
||||
user_id: &crank_core::UserId,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<bool, ApiError> {
|
||||
Ok(self
|
||||
.registry
|
||||
.user_has_workspace_access(user_id, workspace_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_slug = %payload.slug, user_id = %user_id.as_str()))]
|
||||
pub async fn create_workspace(
|
||||
&self,
|
||||
user_id: &crank_core::UserId,
|
||||
payload: WorkspacePayload,
|
||||
) -> Result<WorkspaceRecord, ApiError> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let workspace = Workspace {
|
||||
id: WorkspaceId::new(new_prefixed_id("ws")),
|
||||
slug: payload.slug,
|
||||
display_name: payload.display_name,
|
||||
status: WorkspaceStatus::Active,
|
||||
settings: payload.settings,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.create_workspace(CreateWorkspaceRequest {
|
||||
workspace: &workspace,
|
||||
})
|
||||
.await?;
|
||||
self.registry
|
||||
.ensure_membership(&workspace.id, user_id, MembershipRole::Owner)
|
||||
.await?;
|
||||
self.ensure_default_workspace_upstreams(&workspace.id)
|
||||
.await?;
|
||||
|
||||
Ok(WorkspaceRecord { workspace })
|
||||
}
|
||||
|
||||
pub async fn set_current_workspace(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
user_id: &crank_core::UserId,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<SessionResponse, ApiError> {
|
||||
if !self
|
||||
.user_has_workspace_access(user_id, workspace_id)
|
||||
.await?
|
||||
{
|
||||
return Err(ApiError::forbidden("workspace access denied"));
|
||||
}
|
||||
|
||||
self.registry
|
||||
.set_user_session_current_workspace(session_id, workspace_id)
|
||||
.await?;
|
||||
|
||||
let user = self
|
||||
.registry
|
||||
.get_auth_user_by_id(user_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("user {} was not found", user_id.as_str()),
|
||||
json!({ "user_id": user_id.as_str() }),
|
||||
)
|
||||
})?
|
||||
.user;
|
||||
let memberships = self.registry.list_workspaces_for_user(user_id).await?;
|
||||
|
||||
Ok(SessionResponse {
|
||||
user,
|
||||
memberships,
|
||||
current_workspace_id: Some(workspace_id.as_str().to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_workspace(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<WorkspaceRecord, ApiError> {
|
||||
self.registry
|
||||
.get_workspace(workspace_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
format!("workspace {} was not found", workspace_id.as_str()),
|
||||
json!({ "workspace_id": workspace_id.as_str() }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
|
||||
pub async fn update_workspace(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: UpdateWorkspacePayload,
|
||||
) -> Result<WorkspaceRecord, ApiError> {
|
||||
let existing = self.get_workspace(workspace_id).await?.workspace;
|
||||
let workspace = Workspace {
|
||||
id: existing.id,
|
||||
slug: payload.slug.unwrap_or(existing.slug),
|
||||
display_name: payload.display_name.unwrap_or(existing.display_name),
|
||||
status: payload.status.unwrap_or(existing.status),
|
||||
settings: payload.settings.unwrap_or(existing.settings),
|
||||
created_at: existing.created_at,
|
||||
updated_at: OffsetDateTime::now_utc(),
|
||||
};
|
||||
|
||||
self.registry
|
||||
.update_workspace(UpdateWorkspaceRequest {
|
||||
workspace: &workspace,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(WorkspaceRecord { workspace })
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,4 @@ use crank_runtime::RequestRateLimiter;
|
||||
pub struct AppState {
|
||||
pub service: AdminService,
|
||||
pub api_rate_limiter: RequestRateLimiter,
|
||||
/// Whether to trust `X-Real-IP` / `X-Forwarded-For` for client identification.
|
||||
///
|
||||
/// Only enable when the service sits behind a trusted reverse proxy that
|
||||
/// overwrites these headers (e.g. the bundled nginx). When disabled the
|
||||
/// real TCP peer address is used, which a client cannot spoof.
|
||||
pub trust_forwarded_headers: bool,
|
||||
}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::{io, sync::Mutex};
|
||||
|
||||
use axum::{Json, Router, extract::State, routing::post};
|
||||
use crank_core::{OperationId, WorkspaceId};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use tokio::{net::TcpListener, sync::Notify};
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
#[path = "integration/common.rs"]
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn preserves_external_success_when_invocation_history_is_lost() {
|
||||
let registry = test_registry().await;
|
||||
let registry_for_failure = registry.clone();
|
||||
let storage_root = test_storage_root("observability_history_loss");
|
||||
let upstream = spawn_blocking_upstream_server().await;
|
||||
let log_writer = SharedLogWriter::default();
|
||||
let subscriber = crank_observability::build_subscriber(
|
||||
crank_observability::ObservabilityConfig::new(
|
||||
crank_observability::ServiceIdentity::try_new("admin-api", "test", "test").unwrap(),
|
||||
"info",
|
||||
crank_observability::RedactionLimits::default(),
|
||||
),
|
||||
log_writer.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
tracing::subscriber::set_global_default(subscriber).unwrap();
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream.base_url,
|
||||
"crm_history_loss",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
let request_client = client.clone();
|
||||
let request_url = format!("{base_url}/operations/{operation_id}/test-runs");
|
||||
let before = crank_observability::operational_incident_total(
|
||||
crank_observability::OperationalIncident::InvocationHistoryLost,
|
||||
);
|
||||
|
||||
let request = tokio::spawn(async move {
|
||||
request_client
|
||||
.post(request_url)
|
||||
.header("x-request-id", "req_dc08_admin")
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "dc08-canary-secret@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
upstream.started.notified().await;
|
||||
registry_for_failure
|
||||
.delete_operation(
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
&OperationId::new(operation_id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
upstream.release.notify_one();
|
||||
|
||||
let response = request.await.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_dc08_admin"
|
||||
);
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
assert_eq!(body["ok"], true);
|
||||
assert_eq!(body["response_preview"]["id"], "lead_123");
|
||||
assert_eq!(upstream.calls.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
crank_observability::operational_incident_total(
|
||||
crank_observability::OperationalIncident::InvocationHistoryLost
|
||||
) > before
|
||||
);
|
||||
|
||||
let logs = client
|
||||
.get(format!("{base_url}/logs?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs["items"].as_array().unwrap().is_empty());
|
||||
|
||||
let output = log_writer.output();
|
||||
assert!(!output.contains("dc08-canary-secret"));
|
||||
let incident = output
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.find(|event| event["event"] == "admin.invocation_history.lost")
|
||||
.expect("DC-08 incident");
|
||||
assert_eq!(incident["request_id"], "req_dc08_admin");
|
||||
assert_eq!(incident["fields"]["source"], "admin_test_run");
|
||||
}
|
||||
|
||||
struct BlockingUpstream {
|
||||
base_url: String,
|
||||
started: Arc<Notify>,
|
||||
release: Arc<Notify>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BlockingUpstreamState {
|
||||
started: Arc<Notify>,
|
||||
release: Arc<Notify>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
async fn spawn_blocking_upstream_server() -> BlockingUpstream {
|
||||
let state = BlockingUpstreamState {
|
||||
started: Arc::new(Notify::new()),
|
||||
release: Arc::new(Notify::new()),
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/crm/leads", post(blocking_create_lead))
|
||||
.with_state(state.clone());
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
BlockingUpstream {
|
||||
base_url: format!("http://{address}"),
|
||||
started: state.started,
|
||||
release: state.release,
|
||||
calls: state.calls,
|
||||
}
|
||||
}
|
||||
|
||||
async fn blocking_create_lead(
|
||||
State(state): State<BlockingUpstreamState>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Json<Value> {
|
||||
state.calls.fetch_add(1, Ordering::SeqCst);
|
||||
state.started.notify_one();
|
||||
state.release.notified().await;
|
||||
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"status": "created",
|
||||
"email": payload["email"]
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
mod integration {
|
||||
mod auth_rate_limit;
|
||||
mod common;
|
||||
mod community_access_usage;
|
||||
mod openapi_import;
|
||||
mod operations_agents;
|
||||
mod secrets_import_auth;
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, fmt,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{Json, Router, routing::post};
|
||||
use crank_core::{
|
||||
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
|
||||
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
|
||||
};
|
||||
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_runtime::SecretCrypto;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
||||
service::{AdminService, AdminServiceBuilder, OperationPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
|
||||
const TEST_AUTH_EMAIL: &str = "owner@crank.local";
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
let shutdown = self.shutdown.take();
|
||||
let handle = self.handle.take();
|
||||
|
||||
tokio::task::block_in_place(|| {
|
||||
if let Some(shutdown) = shutdown {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(handle) = handle {
|
||||
let _ = tokio::runtime::Handle::current().block_on(handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TestServer {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for TestServer {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
struct RejectingIdentityProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl IdentityProvider for RejectingIdentityProvider {
|
||||
fn id(&self) -> &str {
|
||||
"rejecting-test-provider"
|
||||
}
|
||||
|
||||
fn kind(&self) -> IdentityProviderKind {
|
||||
IdentityProviderKind::Password
|
||||
}
|
||||
|
||||
async fn login_password(
|
||||
&self,
|
||||
_payload: crank_core::LoginPayload,
|
||||
) -> Result<LoginOutcome, IdentityError> {
|
||||
Err(IdentityError::BadCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn rejects_rapid_login_requests_with_429() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("login_rate_limit");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let app = build_app(AppState {
|
||||
service: test_service(
|
||||
registry,
|
||||
storage_root,
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
),
|
||||
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
||||
crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(),
|
||||
),
|
||||
trust_forwarded_headers: false,
|
||||
});
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let root_url = format!("http://{address}");
|
||||
let first_response = client
|
||||
.post(format!("{root_url}/api/auth/login"))
|
||||
.header("x-request-id", "req_admin_rate_01")
|
||||
.json(&json!({
|
||||
"email": TEST_AUTH_EMAIL,
|
||||
"password": TEST_AUTH_PASSWORD,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first_response.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let second_response = client
|
||||
.post(format!("{root_url}/api/auth/login"))
|
||||
.header("x-request-id", "req_admin_rate_02")
|
||||
.json(&json!({
|
||||
"email": TEST_AUTH_EMAIL,
|
||||
"password": TEST_AUTH_PASSWORD,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
second_response.status(),
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
assert_eq!(
|
||||
second_response.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_admin_rate_02"
|
||||
);
|
||||
let payload = second_response.json::<Value>().await.unwrap();
|
||||
assert_eq!(payload["error"]["code"], "rate_limited");
|
||||
let retry_after_ms = payload["error"]["context"]["retry_after_ms"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert!((1..=1000).contains(&retry_after_ms));
|
||||
|
||||
let _ = shutdown_tx.send(());
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn login_uses_identity_provider_when_configured() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("identity_provider_login");
|
||||
let service = AdminServiceBuilder::new(
|
||||
registry,
|
||||
storage_root,
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
crank_runtime::community_default().build(),
|
||||
)
|
||||
.with_identity_provider(Arc::new(RejectingIdentityProvider))
|
||||
.build();
|
||||
|
||||
let error = match service
|
||||
.login(admin_api::service::LoginPayload {
|
||||
email: TEST_AUTH_EMAIL.to_owned(),
|
||||
password: TEST_AUTH_PASSWORD.to_owned(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("login should delegate to identity provider"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.to_string(), "invalid email or password");
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, fmt,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{Json, Router, routing::post};
|
||||
use crank_core::{
|
||||
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
|
||||
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
|
||||
};
|
||||
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_runtime::SecretCrypto;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
||||
service::{AdminService, AdminServiceBuilder, OperationPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
|
||||
const TEST_AUTH_EMAIL: &str = "owner@crank.local";
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||
|
||||
pub(super) struct TestServer {
|
||||
base_url: String,
|
||||
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
let shutdown = self.shutdown.take();
|
||||
let handle = self.handle.take();
|
||||
|
||||
tokio::task::block_in_place(|| {
|
||||
if let Some(shutdown) = shutdown {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(handle) = handle {
|
||||
let _ = tokio::runtime::Handle::current().block_on(handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TestServer {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for TestServer {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RejectingIdentityProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl IdentityProvider for RejectingIdentityProvider {
|
||||
fn id(&self) -> &str {
|
||||
"rejecting-test-provider"
|
||||
}
|
||||
|
||||
fn kind(&self) -> IdentityProviderKind {
|
||||
IdentityProviderKind::Password
|
||||
}
|
||||
|
||||
async fn login_password(
|
||||
&self,
|
||||
_payload: crank_core::LoginPayload,
|
||||
) -> Result<LoginOutcome, IdentityError> {
|
||||
Err(IdentityError::BadCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_test_app(
|
||||
registry: PostgresRegistry,
|
||||
storage_root: std::path::PathBuf,
|
||||
) -> Router {
|
||||
build_app(AppState {
|
||||
service: test_service(
|
||||
registry,
|
||||
storage_root,
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
),
|
||||
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
||||
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
||||
),
|
||||
trust_forwarded_headers: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn test_service(
|
||||
registry: PostgresRegistry,
|
||||
storage_root: std::path::PathBuf,
|
||||
auth_settings: AuthSettings,
|
||||
secret_crypto: SecretCrypto,
|
||||
) -> AdminService {
|
||||
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
|
||||
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
|
||||
AdminServiceBuilder::new(
|
||||
registry,
|
||||
storage_root,
|
||||
auth_settings,
|
||||
secret_crypto,
|
||||
runtime,
|
||||
)
|
||||
.with_outbound_http_policy(outbound_policy)
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(super) async fn spawn_upstream_server() -> String {
|
||||
let app = Router::new().route("/crm/leads", post(create_lead));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
format!("http://{}", address)
|
||||
}
|
||||
|
||||
pub(super) async fn spawn_admin_api(app: Router) -> TestServer {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
TestServer {
|
||||
base_url: format!(
|
||||
"http://{}/api/admin/workspaces/{}",
|
||||
address, DEFAULT_WORKSPACE_ID
|
||||
),
|
||||
shutdown: Some(shutdown_tx),
|
||||
handle: Some(handle),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn authorized_client(workspace_base_url: impl AsRef<str>) -> reqwest::Client {
|
||||
let root_url = workspace_base_url
|
||||
.as_ref()
|
||||
.split("/api/admin/workspaces/")
|
||||
.next()
|
||||
.unwrap();
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
.post(format!("{root_url}/api/auth/login"))
|
||||
.json(&json!({
|
||||
"email": TEST_AUTH_EMAIL,
|
||||
"password": TEST_AUTH_PASSWORD,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
}
|
||||
|
||||
pub(super) async fn assert_success_json(response: reqwest::Response) -> Value {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap();
|
||||
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"request failed with status {status}: {body}"
|
||||
);
|
||||
|
||||
serde_json::from_str(&body).unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn create_lead(Json(payload): Json<Value>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"status": "created",
|
||||
"email": payload["email"]
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn test_registry() -> PostgresRegistry {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_admin_api").await;
|
||||
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
|
||||
let password_hash = hash_password(TEST_AUTH_PASSWORD, TEST_PASSWORD_PEPPER).unwrap();
|
||||
let user_id = registry
|
||||
.upsert_bootstrap_user(TEST_AUTH_EMAIL, "Test Owner", &password_hash)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.ensure_membership(
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
&user_id,
|
||||
MembershipRole::Owner,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
pub(super) fn test_storage_root(name: &str) -> std::path::PathBuf {
|
||||
env::temp_dir().join(format!(
|
||||
"crank_admin_api_{name}_{}_{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn test_auth_settings() -> AuthSettings {
|
||||
AuthSettings {
|
||||
session_secret: TEST_SESSION_SECRET.to_owned(),
|
||||
password_pepper: TEST_PASSWORD_PEPPER.to_owned(),
|
||||
session_ttl_hours: 24,
|
||||
cookie_secure: false,
|
||||
bootstrap_admin: BootstrapAdminConfig {
|
||||
email: TEST_AUTH_EMAIL.to_owned(),
|
||||
password: TEST_AUTH_PASSWORD.to_owned(),
|
||||
display_name: "Test Owner".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn test_secret_crypto() -> SecretCrypto {
|
||||
SecretCrypto::new(TEST_MASTER_KEY).unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn test_operation_payload(base_url: &str, name: &str) -> OperationPayload {
|
||||
OperationPayload {
|
||||
name: name.to_owned(),
|
||||
display_name: "Create Lead".to_owned(),
|
||||
category: "sales".to_owned(),
|
||||
protocol: Protocol::Rest,
|
||||
security_level: OperationSecurityLevel::Standard,
|
||||
target: Target::Rest(RestTarget {
|
||||
base_url: base_url.to_owned(),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/crm/leads".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
}),
|
||||
input_schema: object_schema("email"),
|
||||
output_schema: object_schema("id"),
|
||||
input_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.email".to_owned(),
|
||||
target: "$.request.body.email".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
output_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.response.body.id".to_owned(),
|
||||
target: "$.output.id".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Create Lead".to_owned(),
|
||||
description: "Creates a CRM lead".to_owned(),
|
||||
tags: vec!["crm".to_owned()],
|
||||
examples: Vec::new(),
|
||||
},
|
||||
wizard_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn object_schema(field_name: &str) -> Schema {
|
||||
Schema {
|
||||
kind: SchemaKind::Object,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::from([(
|
||||
field_name.to_owned(),
|
||||
Schema {
|
||||
kind: SchemaKind::String,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::new(),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
},
|
||||
)]),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1,980 +0,0 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, fmt,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{Json, Router, routing::post};
|
||||
use crank_core::{
|
||||
AgentId, ExecutionConfig, HttpMethod, MembershipRole, OperationId, OperationSecurityLevel,
|
||||
Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
|
||||
};
|
||||
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_runtime::SecretCrypto;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use tokio::net::TcpListener;
|
||||
use uuid::Version;
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
||||
service::{
|
||||
AdminService, AdminServiceBuilder, AgentBindingPayload, AgentPayload, OperationPayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
|
||||
const TEST_AUTH_EMAIL: &str = "owner@crank.local";
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
let shutdown = self.shutdown.take();
|
||||
let handle = self.handle.take();
|
||||
|
||||
tokio::task::block_in_place(|| {
|
||||
if let Some(shutdown) = shutdown {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(handle) = handle {
|
||||
let _ = tokio::runtime::Handle::current().block_on(handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TestServer {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for TestServer {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
struct RejectingIdentityProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl IdentityProvider for RejectingIdentityProvider {
|
||||
fn id(&self) -> &str {
|
||||
"rejecting-test-provider"
|
||||
}
|
||||
|
||||
fn kind(&self) -> IdentityProviderKind {
|
||||
IdentityProviderKind::Password
|
||||
}
|
||||
|
||||
async fn login_password(
|
||||
&self,
|
||||
_payload: crank_core::LoginPayload,
|
||||
) -> Result<LoginOutcome, IdentityError> {
|
||||
Err(IdentityError::BadCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn rejects_workspace_access_management_in_community() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("platform_access");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let members_status = client
|
||||
.get(format!("{base_url}/members"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
let create_invitation_status = client
|
||||
.post(format!("{base_url}/invitations"))
|
||||
.json(&json!({
|
||||
"email": "operator@example.com",
|
||||
"role": "operator"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
|
||||
assert_eq!(members_status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(create_invitation_status, reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn manages_agent_platform_api_keys() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_platform_keys");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created_agent = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "sales-routing",
|
||||
"display_name": "Sales Routing",
|
||||
"description": "Routing agent",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = created_agent["agent_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let created_key = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.json(&json!({
|
||||
"name": "sales-routing-primary",
|
||||
"key_kind": "mcp_client",
|
||||
"scopes": ["read", "write"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let key_id = created_key["api_key"]["api_key"]["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let created_approval_key = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.json(&json!({
|
||||
"name": "sales-routing-approver",
|
||||
"key_kind": "approval",
|
||||
"scopes": ["approve", "deny"],
|
||||
"allowed_origins": ["https://client.example.test"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let invalid_mixed_scope_status = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.json(&json!({
|
||||
"name": "invalid-mixed-scope",
|
||||
"key_kind": "approval",
|
||||
"scopes": ["read", "approve"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
|
||||
let listed_keys = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let revoke_status = client
|
||||
.post(format!(
|
||||
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}/revoke"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
let delete_status = client
|
||||
.delete(format!(
|
||||
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
|
||||
assert_eq!(created_key["api_key"]["api_key"]["agent_id"], agent_id);
|
||||
assert_eq!(created_key["api_key"]["api_key"]["key_kind"], "mcp_client");
|
||||
assert_eq!(
|
||||
created_approval_key["api_key"]["api_key"]["key_kind"],
|
||||
"approval"
|
||||
);
|
||||
assert!(
|
||||
created_approval_key["secret"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("crk_appr_")
|
||||
);
|
||||
assert_eq!(
|
||||
created_approval_key["api_key"]["api_key"]["allowed_origins"],
|
||||
json!(["https://client.example.test"])
|
||||
);
|
||||
assert_eq!(invalid_mixed_scope_status, reqwest::StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
listed_keys["items"][0]["api_key"]["agent_id"],
|
||||
json!(agent_id)
|
||||
);
|
||||
assert_eq!(listed_keys["items"].as_array().unwrap().len(), 2);
|
||||
assert!(created_key["secret"].as_str().unwrap().starts_with("crk_"));
|
||||
assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn returns_community_capabilities() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("community_capabilities");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let root_url = base_url
|
||||
.as_ref()
|
||||
.split("/api/admin/workspaces/")
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let response = assert_success_json(
|
||||
client
|
||||
.get(format!("{root_url}/api/admin/capabilities"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response["edition"], "community");
|
||||
assert_eq!(response["supported_protocols"], json!(["rest"]));
|
||||
assert_eq!(response["supported_security_levels"], json!(["standard"]));
|
||||
assert_eq!(
|
||||
response["machine_access_modes"],
|
||||
json!(["static_agent_key"])
|
||||
);
|
||||
assert_eq!(response["limits"]["max_workspaces"], 1);
|
||||
assert_eq!(response["limits"]["max_users_per_workspace"], 1);
|
||||
assert_eq!(response["limits"]["max_agents_per_workspace"], Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn rejects_response_cache_for_non_get_rest_operation_create() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("community_response_cache_post_reject");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let mut payload = test_operation_payload(&upstream_base_url, "crm_cache_post");
|
||||
payload.execution_config.response_cache = Some(ResponseCachePolicy { ttl_ms: 5_000 });
|
||||
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["code"], "validation_error");
|
||||
assert_eq!(
|
||||
body["error"]["context"]["field"],
|
||||
"execution_config.response_cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn rejects_response_cache_for_operation_with_auth_profile() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("community_response_cache_auth_reject");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let mut payload = test_operation_payload(&upstream_base_url, "crm_cache_auth");
|
||||
payload.target = Target::Rest(RestTarget {
|
||||
base_url: upstream_base_url,
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/crm/leads".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
});
|
||||
payload.execution_config.response_cache = Some(ResponseCachePolicy { ttl_ms: 5_000 });
|
||||
payload.execution_config.auth_profile_ref = Some("auth_profile_01".into());
|
||||
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["code"], "validation_error");
|
||||
assert_eq!(
|
||||
body["error"]["context"]["field"],
|
||||
"execution_config.auth_profile_ref"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn exports_single_workspace_but_rejects_access_lifecycle() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("workspace_access");
|
||||
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let second_user_id = registry
|
||||
.upsert_bootstrap_user("operator-2@crank.local", "Operator Two", "external-hash")
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.ensure_membership(
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
&second_user_id,
|
||||
MembershipRole::Viewer,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let update_member_status = client
|
||||
.patch(format!("{base_url}/members/{}", second_user_id.as_str()))
|
||||
.json(&json!({ "role": "admin" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
assert_eq!(update_member_status, reqwest::StatusCode::NOT_FOUND);
|
||||
|
||||
let exported = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/export"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
exported["workspace"]["workspace"]["id"],
|
||||
DEFAULT_WORKSPACE_ID
|
||||
);
|
||||
assert_eq!(exported["kind"], "workspace_catalog_snapshot");
|
||||
assert_eq!(exported["format_version"], "1");
|
||||
assert_eq!(exported["restorable"], false);
|
||||
assert_eq!(
|
||||
exported["included"],
|
||||
json!([
|
||||
"workspace_settings",
|
||||
"operation_summaries",
|
||||
"agent_summaries",
|
||||
"platform_api_key_metadata"
|
||||
])
|
||||
);
|
||||
assert!(
|
||||
exported["excluded"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("secret_values"))
|
||||
);
|
||||
assert!(
|
||||
exported["excluded"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("invocation_logs_and_usage"))
|
||||
);
|
||||
assert!(exported.get("memberships").is_none());
|
||||
assert!(exported.get("invitations").is_none());
|
||||
|
||||
let delete_member_status = client
|
||||
.delete(format!("{base_url}/members/{}", second_user_id.as_str()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
assert_eq!(delete_member_status, reqwest::StatusCode::NOT_FOUND);
|
||||
|
||||
let delete_workspace_status = client
|
||||
.delete(base_url.as_ref())
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
assert_eq!(
|
||||
delete_workspace_status,
|
||||
reqwest::StatusCode::METHOD_NOT_ALLOWED
|
||||
);
|
||||
|
||||
let root_url = base_url
|
||||
.as_ref()
|
||||
.split("/api/admin/workspaces/")
|
||||
.next()
|
||||
.unwrap();
|
||||
let still_available = assert_success_json(
|
||||
client
|
||||
.get(format!(
|
||||
"{root_url}/api/admin/workspaces/{DEFAULT_WORKSPACE_ID}"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(still_available["workspace"]["id"], DEFAULT_WORKSPACE_ID);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn seeds_demo_assets_for_live_ui() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("demo_seed");
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
storage_root,
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
|
||||
service.bootstrap_admin_user().await.unwrap();
|
||||
service.seed_demo_assets().await.unwrap();
|
||||
|
||||
let smoke_operation = service
|
||||
.create_operation(
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
test_operation_payload("https://example.test", "internal_health_smoke_bound"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let smoke_operation_id = OperationId::new(smoke_operation.operation_id);
|
||||
service
|
||||
.publish_operation(
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
&smoke_operation_id,
|
||||
smoke_operation.version,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let smoke_agent = service
|
||||
.create_agent(
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
AgentPayload {
|
||||
slug: "legacy-smoke-agent".to_owned(),
|
||||
display_name: "Legacy Smoke Agent".to_owned(),
|
||||
description: "Keeps a legacy smoke operation published".to_owned(),
|
||||
instructions: json!({}),
|
||||
tool_selection_policy: Default::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let smoke_agent_id = AgentId::new(smoke_agent.agent_id);
|
||||
service
|
||||
.save_agent_bindings(
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
&smoke_agent_id,
|
||||
vec![AgentBindingPayload {
|
||||
operation_id: smoke_operation_id.as_str().to_owned(),
|
||||
operation_version: smoke_operation.version,
|
||||
tool_name: "legacy_health_smoke".to_owned(),
|
||||
tool_title: "Legacy health smoke".to_owned(),
|
||||
tool_description_override: None,
|
||||
enabled: true,
|
||||
}]
|
||||
.into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
service
|
||||
.publish_agent(
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
&smoke_agent_id,
|
||||
smoke_agent.version,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
service.seed_demo_assets().await.unwrap();
|
||||
|
||||
let owner = registry
|
||||
.get_auth_user_by_email(TEST_AUTH_EMAIL)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let workspaces = service
|
||||
.list_workspaces_for_user(&owner.user.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
workspaces
|
||||
.iter()
|
||||
.any(|item| item.workspace.slug == "default")
|
||||
);
|
||||
assert_eq!(workspaces.len(), 1);
|
||||
|
||||
let default_workspace_id = WorkspaceId::new(DEFAULT_WORKSPACE_ID);
|
||||
let operations = service
|
||||
.list_operations(&default_workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
operations
|
||||
.iter()
|
||||
.any(|operation| operation.name == "frankfurter_latest_rate")
|
||||
);
|
||||
assert!(
|
||||
!operations
|
||||
.iter()
|
||||
.any(|operation| operation.name == "crm_create_lead")
|
||||
);
|
||||
assert!(!operations.iter().any(
|
||||
|operation| operation.name.starts_with("internal_health_smoke_")
|
||||
&& operation.name != "internal_health_smoke_bound"
|
||||
));
|
||||
assert!(
|
||||
operations
|
||||
.iter()
|
||||
.any(|operation| operation.name == "internal_health_smoke_bound")
|
||||
);
|
||||
|
||||
let agents = service.list_agents(&default_workspace_id).await.unwrap();
|
||||
assert!(agents.iter().any(|agent| agent.slug == "currency-rates"));
|
||||
|
||||
assert!(agents.iter().any(|agent| agent.key_count > 0));
|
||||
|
||||
let logs = service
|
||||
.list_logs(
|
||||
&default_workspace_id,
|
||||
admin_api::service::LogsQuery {
|
||||
level: None,
|
||||
search: None,
|
||||
source: None,
|
||||
operation_id: None,
|
||||
agent_id: None,
|
||||
period: None,
|
||||
limit: Some(20),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!logs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn updates_profile_and_changes_password() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("settings_profile");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let root_url = base_url
|
||||
.as_ref()
|
||||
.split("/api/admin/workspaces/")
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let client = authorized_client(&base_url).await;
|
||||
let second_client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
let second_login = second_client
|
||||
.post(format!("{root_url}/api/auth/login"))
|
||||
.json(&json!({
|
||||
"email": TEST_AUTH_EMAIL,
|
||||
"password": TEST_AUTH_PASSWORD,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let second_login_status = second_login.status();
|
||||
let second_login_body = second_login.text().await.unwrap();
|
||||
assert!(
|
||||
second_login_status.is_success(),
|
||||
"second login failed with {second_login_status}: {second_login_body}"
|
||||
);
|
||||
|
||||
let profile = assert_success_json(
|
||||
client
|
||||
.get(format!("{root_url}/api/auth/profile"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(profile["user"]["email"], TEST_AUTH_EMAIL);
|
||||
|
||||
let updated_profile = assert_success_json(
|
||||
client
|
||||
.patch(format!("{root_url}/api/auth/profile"))
|
||||
.json(&json!({
|
||||
"display_name": "Updated Owner",
|
||||
"email": "updated-owner@crank.local"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(updated_profile["user"]["display_name"], "Updated Owner");
|
||||
assert_eq!(
|
||||
updated_profile["user"]["email"],
|
||||
"updated-owner@crank.local"
|
||||
);
|
||||
|
||||
let password_status = client
|
||||
.post(format!("{root_url}/api/auth/password"))
|
||||
.json(&json!({
|
||||
"current_password": TEST_AUTH_PASSWORD,
|
||||
"new_password": "updated-password-123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
assert_eq!(password_status, reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
let current_session_status = client
|
||||
.get(format!("{root_url}/api/auth/profile"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
let other_session_status = second_client
|
||||
.get(format!("{root_url}/api/auth/profile"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
assert_eq!(current_session_status, reqwest::StatusCode::OK);
|
||||
assert_eq!(other_session_status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
|
||||
let relogin_client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
let relogin_status = relogin_client
|
||||
.post(format!("{root_url}/api/auth/login"))
|
||||
.json(&json!({
|
||||
"email": "updated-owner@crank.local",
|
||||
"password": "updated-password-123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
assert_eq!(relogin_status, reqwest::StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn rejects_multi_workspace_session_switching_in_community() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("session_workspace");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let root_url = base_url
|
||||
.as_ref()
|
||||
.split("/api/admin/workspaces/")
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let create_workspace_status = client
|
||||
.post(format!("{root_url}/api/admin/workspaces"))
|
||||
.json(&json!({
|
||||
"slug": "growth-lab",
|
||||
"display_name": "Growth Lab",
|
||||
"settings": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
assert_eq!(
|
||||
create_workspace_status,
|
||||
reqwest::StatusCode::METHOD_NOT_ALLOWED
|
||||
);
|
||||
|
||||
let switch_status = client
|
||||
.post(format!("{root_url}/api/auth/current-workspace"))
|
||||
.json(&json!({ "workspace_id": DEFAULT_WORKSPACE_ID }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
assert_eq!(switch_status, reqwest::StatusCode::NOT_FOUND);
|
||||
|
||||
let session = assert_success_json(
|
||||
client
|
||||
.get(format!("{root_url}/api/auth/session"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(session["current_workspace_id"], DEFAULT_WORKSPACE_ID);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn exposes_logs_and_usage_from_real_test_runs() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("observability");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_observability",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "user@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let logs = client
|
||||
.get(format!("{base_url}/logs?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let log_id = logs["items"][0]["log"]["id"].as_str().unwrap().to_owned();
|
||||
let log_detail = client
|
||||
.get(format!("{base_url}/logs/{log_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let usage = client
|
||||
.get(format!("{base_url}/usage?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_usage = client
|
||||
.get(format!(
|
||||
"{base_url}/usage/operations/{operation_id}?period=7d"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(logs["items"][0]["log"]["source"], "admin_test_run");
|
||||
assert_eq!(logs["items"][0]["operation_name"], "crm_observability");
|
||||
assert_eq!(log_detail["log"]["status"], "ok");
|
||||
assert_eq!(usage["summary"]["rollup"]["calls_total"], 1);
|
||||
assert_eq!(usage["summary"]["rollup"]["calls_ok"], 1);
|
||||
assert_eq!(
|
||||
usage["operations"][0]["operation_name"],
|
||||
"crm_observability"
|
||||
);
|
||||
assert_eq!(operation_usage["rollup"]["calls_total"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn preserves_request_id_for_test_run_invocations() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("observability_request_id");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_observability_request_id",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.header("x-request-id", "req_test_123")
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "user@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_test_123"
|
||||
);
|
||||
response.error_for_status().unwrap();
|
||||
|
||||
let logs = client
|
||||
.get(format!("{base_url}/logs?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(logs["items"][0]["log"]["request_id"], "req_test_123");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn generates_request_id_for_test_run_invocations() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("observability_generated_request_id");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_observability_generated_request_id",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "user@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let request_id = response
|
||||
.headers()
|
||||
.get("x-request-id")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(&request_id).unwrap().get_version(),
|
||||
Some(Version::SortRand)
|
||||
);
|
||||
response.error_for_status().unwrap();
|
||||
|
||||
let logs = client
|
||||
.get(format!("{base_url}/logs?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(logs["items"][0]["log"]["request_id"], request_id);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn returns_structured_context_for_missing_operation_usage() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("missing_operation_usage");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_missing_usage",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let response = client
|
||||
.get(format!(
|
||||
"{base_url}/usage/operations/{operation_id}?period=7d"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["error"]["code"], "not_found");
|
||||
assert_eq!(
|
||||
body["error"]["message"],
|
||||
format!("usage for operation {operation_id} was not found")
|
||||
);
|
||||
assert_eq!(
|
||||
body["error"]["context"],
|
||||
json!({
|
||||
"operation_id": operation_id
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
use admin_api::service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload};
|
||||
use crank_core::WorkspaceId;
|
||||
use crank_registry::ImportJobStatus;
|
||||
use serial_test::serial;
|
||||
|
||||
use super::common::{
|
||||
test_auth_settings, test_registry, test_secret_crypto, test_service, test_storage_root,
|
||||
};
|
||||
|
||||
const OPENAPI3: &str = r#"
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Frankfurter API
|
||||
servers:
|
||||
- url: https://api.frankfurter.dev
|
||||
paths:
|
||||
/v2/latest:
|
||||
get:
|
||||
operationId: latestRates
|
||||
summary: Получить последние курсы валют
|
||||
description: Курсы.
|
||||
tags: [currency]
|
||||
parameters:
|
||||
- name: base
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
base: { type: string }
|
||||
rates:
|
||||
type: object
|
||||
"#;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn previews_openapi_and_creates_draft_operations() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(preview.preview.groups.len(), 1);
|
||||
assert_eq!(
|
||||
preview.preview.groups[0].operations[0].suggested_name,
|
||||
"latest_rates"
|
||||
);
|
||||
let preview_job = registry
|
||||
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(preview_job.status, ImportJobStatus::Pending);
|
||||
|
||||
let created = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created.created.len(), 1);
|
||||
assert_eq!(created.created[0].name, "latest_rates");
|
||||
assert!(created.skipped.is_empty());
|
||||
|
||||
let operations = service.list_operations(&workspace_id).await.unwrap();
|
||||
assert!(
|
||||
operations
|
||||
.iter()
|
||||
.any(|operation| operation.name == "latest_rates")
|
||||
);
|
||||
let imported = operations
|
||||
.iter()
|
||||
.find(|operation| operation.name == "latest_rates")
|
||||
.unwrap();
|
||||
let version = service
|
||||
.get_operation_version(
|
||||
&workspace_id,
|
||||
&imported.id.as_str().into(),
|
||||
imported.current_draft_version,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let import_findings = &version
|
||||
.snapshot
|
||||
.wizard_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.import_findings;
|
||||
assert!(
|
||||
import_findings
|
||||
.iter()
|
||||
.any(|finding| finding.code == "openapi_import.weak_tool_description")
|
||||
);
|
||||
|
||||
let skip_preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let skipped = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&skip_preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(skipped.created.is_empty());
|
||||
assert_eq!(skipped.skipped.len(), 1);
|
||||
assert_eq!(skipped.skipped[0].name, "latest_rates");
|
||||
assert_eq!(skipped.findings[0].code, "operation_name_conflict");
|
||||
|
||||
let rename_preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let renamed = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&rename_preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "rename".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(renamed.created.len(), 1);
|
||||
assert_eq!(renamed.created[0].name, "latest_rates_2");
|
||||
assert_eq!(renamed.findings[0].code, "operation_name_renamed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn concurrent_openapi_import_replays_the_same_atomic_result() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry,
|
||||
test_storage_root("openapi_import_replay"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id = preview.job_id.as_str().into();
|
||||
let payload = OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "rename".to_owned(),
|
||||
};
|
||||
|
||||
let (first, second) = tokio::join!(
|
||||
service.create_openapi_import(&workspace_id, &job_id, payload.clone()),
|
||||
service.create_openapi_import(&workspace_id, &job_id, payload),
|
||||
);
|
||||
let first = first.unwrap();
|
||||
let second = second.unwrap();
|
||||
|
||||
assert_eq!(first.created.len(), 1);
|
||||
assert_eq!(second.created.len(), 1);
|
||||
assert_eq!(
|
||||
first.created[0].operation_id,
|
||||
second.created[0].operation_id
|
||||
);
|
||||
assert_eq!(first.created[0].name, second.created[0].name);
|
||||
assert_eq!(
|
||||
service.list_operations(&workspace_id).await.unwrap().len(),
|
||||
1
|
||||
);
|
||||
|
||||
let conflicting_replay = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(conflicting_replay.is_err());
|
||||
}
|
||||
@@ -1,902 +0,0 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, fmt,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{Json, Router, routing::post};
|
||||
use crank_core::{
|
||||
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
|
||||
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
|
||||
};
|
||||
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_runtime::SecretCrypto;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
||||
service::{AdminService, AdminServiceBuilder, OperationPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
|
||||
const TEST_AUTH_EMAIL: &str = "owner@crank.local";
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
let shutdown = self.shutdown.take();
|
||||
let handle = self.handle.take();
|
||||
|
||||
tokio::task::block_in_place(|| {
|
||||
if let Some(shutdown) = shutdown {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(handle) = handle {
|
||||
let _ = tokio::runtime::Handle::current().block_on(handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TestServer {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for TestServer {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
struct RejectingIdentityProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl IdentityProvider for RejectingIdentityProvider {
|
||||
fn id(&self) -> &str {
|
||||
"rejecting-test-provider"
|
||||
}
|
||||
|
||||
fn kind(&self) -> IdentityProviderKind {
|
||||
IdentityProviderKind::Password
|
||||
}
|
||||
|
||||
async fn login_password(
|
||||
&self,
|
||||
_payload: crank_core::LoginPayload,
|
||||
) -> Result<LoginOutcome, IdentityError> {
|
||||
Err(IdentityError::BadCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn creates_publishes_and_tests_rest_operation() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("lifecycle");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_create_lead",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let listed = client
|
||||
.get(format!("{base_url}/operations"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let published = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let test_run = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "user@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(listed["items"][0]["name"], "crm_create_lead");
|
||||
assert_eq!(
|
||||
listed["items"][0]["target_url"],
|
||||
format!("{upstream_base_url}/crm/leads")
|
||||
);
|
||||
assert_eq!(listed["items"][0]["target_action"], "POST");
|
||||
assert_eq!(published["published_version"], 1);
|
||||
assert_eq!(test_run["ok"], true);
|
||||
assert_eq!(
|
||||
test_run["request_preview"]["body"]["email"],
|
||||
"user@example.com"
|
||||
);
|
||||
assert_eq!(test_run["response_preview"]["id"], "lead_123");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn updates_archives_and_deletes_operation() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation_mutations");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_mutable_operation",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let listed = client
|
||||
.get(format!("{base_url}/operations"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed["total"], 1);
|
||||
assert_eq!(listed["items"][0]["category"], "sales");
|
||||
assert_eq!(
|
||||
listed["items"][0]["target_url"],
|
||||
format!("{upstream_base_url}/crm/leads")
|
||||
);
|
||||
assert_eq!(listed["items"][0]["target_action"], "POST");
|
||||
|
||||
let updated = client
|
||||
.patch(format!("{base_url}/operations/{operation_id}"))
|
||||
.json(&json!({
|
||||
"display_name": "Create Lead Updated",
|
||||
"category": "marketing",
|
||||
"target": {
|
||||
"kind": "rest",
|
||||
"base_url": upstream_base_url,
|
||||
"method": "POST",
|
||||
"path_template": "/crm/leads",
|
||||
"static_headers": {}
|
||||
},
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"required": true,
|
||||
"nullable": false,
|
||||
"fields": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"nullable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"required": true,
|
||||
"nullable": false,
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"nullable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"input_mapping": {
|
||||
"rules": [
|
||||
{
|
||||
"source": "$.mcp.email",
|
||||
"target": "$.request.body.email",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"output_mapping": {
|
||||
"rules": [
|
||||
{
|
||||
"source": "$.response.body.id",
|
||||
"target": "$.output.id",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"execution_config": {
|
||||
"timeout_ms": 1000,
|
||||
"headers": {}
|
||||
},
|
||||
"tool_description": {
|
||||
"title": "Create Lead Updated",
|
||||
"description": "Creates a CRM lead",
|
||||
"tags": ["crm"]
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated["status"], "draft");
|
||||
|
||||
let detail = client
|
||||
.get(format!("{base_url}/operations/{operation_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detail["display_name"], "Create Lead Updated");
|
||||
assert_eq!(detail["category"], "marketing");
|
||||
|
||||
let archived = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/archive"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(archived["status"], "archived");
|
||||
|
||||
let deleted = client
|
||||
.delete(format!("{base_url}/operations/{operation_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deleted["operation_id"], operation_id);
|
||||
|
||||
let missing = client
|
||||
.get(format!("{base_url}/operations/{operation_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let missing_status = missing.status();
|
||||
let missing = missing.json::<Value>().await.unwrap();
|
||||
assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(missing["error"]["code"], "not_found");
|
||||
assert_eq!(
|
||||
missing["error"]["context"],
|
||||
json!({
|
||||
"operation_id": operation_id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn creates_binds_and_publishes_agent() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_lifecycle");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let operation = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_create_lead_agent",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation = assert_success_json(operation).await;
|
||||
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let agent = client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "sales-assistant",
|
||||
"display_name": "Sales Assistant",
|
||||
"description": "Curated sales toolset",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let bindings = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "crm_create_lead_agent",
|
||||
"tool_title": "Create Lead",
|
||||
"enabled": true
|
||||
}
|
||||
]))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let published = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
bindings["bindings"][0]["tool_name"],
|
||||
"crm_create_lead_agent"
|
||||
);
|
||||
assert_eq!(published["published_version"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn saves_and_previews_versioned_agent_tool_search_policy() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_tool_search");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"finance_create_invoice",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
|
||||
assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.json(&json!({"version": 1}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let agent = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "finance-agent",
|
||||
"display_name": "Finance Agent",
|
||||
"description": "Finance workflows",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
|
||||
let catalog = json!({
|
||||
"bindings": [{
|
||||
"operation_id": operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "finance_create_invoice",
|
||||
"tool_title": "Create Lead",
|
||||
"tool_description_override": "Creates an invoice for a customer",
|
||||
"enabled": true
|
||||
}],
|
||||
"tool_selection_policy": {
|
||||
"mode": "search",
|
||||
"groups": [{
|
||||
"id": "finance",
|
||||
"name": "Finance",
|
||||
"description": "Invoices and payments",
|
||||
"tool_names": ["finance_create_invoice"]
|
||||
}],
|
||||
"search": {"max_results": 5}
|
||||
}
|
||||
});
|
||||
let saved = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.json(&catalog)
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(saved["snapshot"]["tool_selection_policy"]["mode"], "search");
|
||||
|
||||
let preview = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/tool-search/preview"))
|
||||
.json(&json!({
|
||||
"query": "create invoice",
|
||||
"group_ids": ["finance"],
|
||||
"bindings": catalog["bindings"],
|
||||
"tool_selection_policy": catalog["tool_selection_policy"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
preview["items"][0]["tool"]["name"],
|
||||
"finance_create_invoice"
|
||||
);
|
||||
|
||||
let published = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.json(&json!({"version": 1}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(published["published_version"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_publish_filters_drafts");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let published_operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_published_tool",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let published_operation_id = published_operation["operation_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
assert_success_json(
|
||||
client
|
||||
.post(format!(
|
||||
"{base_url}/operations/{published_operation_id}/publish"
|
||||
))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let draft_operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_draft_tool",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let draft_operation_id = draft_operation["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let agent = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "collaborative-agent",
|
||||
"display_name": "Collaborative Agent",
|
||||
"description": "Agent with published and draft tools",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
|
||||
|
||||
assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": published_operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "crm_published_tool",
|
||||
"tool_title": "Published Tool",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"operation_id": draft_operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "crm_draft_tool",
|
||||
"tool_title": "Draft Tool",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
}
|
||||
]))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let published = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_detail = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let published_version = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}/versions/1"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let draft_version = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}/versions/2"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(published["published_version"], 1);
|
||||
assert_eq!(agent_detail["current_draft_version"], 2);
|
||||
assert_eq!(agent_detail["latest_published_version"], 1);
|
||||
assert_eq!(published_version["bindings"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
published_version["bindings"][0]["operation_id"],
|
||||
published_operation_id
|
||||
);
|
||||
assert_eq!(draft_version["bindings"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn updates_lists_and_deletes_agent() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_mutations");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let operation = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_create_lead_agents_page",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation = assert_success_json(operation).await;
|
||||
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "support-team",
|
||||
"display_name": "Support Team",
|
||||
"description": "Support workflows",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let agent_id = created["agent_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "crm_create_lead_agents_page",
|
||||
"tool_title": "Create Lead",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
}
|
||||
]))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let listed = client
|
||||
.get(format!("{base_url}/agents"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed["items"][0]["operation_count"], 1);
|
||||
assert_eq!(listed["items"][0]["operation_ids"][0], operation_id);
|
||||
assert_eq!(
|
||||
listed["items"][0]["mcp_endpoint"],
|
||||
"/mcp/v1/default/support-team"
|
||||
);
|
||||
assert_eq!(listed["items"][0]["status"], "published");
|
||||
|
||||
let updated = client
|
||||
.patch(format!("{base_url}/agents/{agent_id}"))
|
||||
.json(&json!({
|
||||
"slug": "support-escalation",
|
||||
"display_name": "Support Escalation",
|
||||
"description": "Escalation workflows"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated["agent_id"], agent_id);
|
||||
|
||||
let detail = client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detail["slug"], "support-escalation");
|
||||
assert_eq!(detail["display_name"], "Support Escalation");
|
||||
assert_eq!(detail["operation_count"], 1);
|
||||
assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-escalation");
|
||||
|
||||
let deleted = client
|
||||
.delete(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deleted["agent_id"], agent_id);
|
||||
|
||||
let missing = client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let missing_status = missing.status();
|
||||
let missing = missing.json::<Value>().await.unwrap();
|
||||
assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(missing["error"]["code"], "not_found");
|
||||
assert_eq!(
|
||||
missing["error"]["context"],
|
||||
json!({
|
||||
"agent_id": agent_id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn unpublishes_and_archives_agent() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_statuses");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_create_lead_agent_status",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let created = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "sales-routing",
|
||||
"display_name": "Sales Routing",
|
||||
"description": "Routing agent",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = created["agent_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "crm_create_lead_agent_status",
|
||||
"tool_title": "Create Lead",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
}
|
||||
]))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let unpublished = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/unpublish"))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unpublished["agent_id"], agent_id);
|
||||
|
||||
let draft_detail = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(draft_detail["status"], "draft");
|
||||
assert_eq!(draft_detail["latest_published_version"], 1);
|
||||
|
||||
let archived = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/archive"))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(archived["agent_id"], agent_id);
|
||||
|
||||
let archived_detail = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(archived_detail["status"], "archived");
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
use std::{
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use admin_api::request_context::{REQUEST_ID_HEADER, apply_request_context};
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{HeaderMap, HeaderValue, Request, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
use crank_observability::{
|
||||
ObservabilityConfig, RedactionLimits, ServiceIdentity, inject_current_trace_context,
|
||||
};
|
||||
use opentelemetry::{global, trace::TracerProvider as _};
|
||||
use opentelemetry_sdk::{propagation::TraceContextPropagator, trace::SdkTracerProvider};
|
||||
use tower::ServiceExt;
|
||||
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt};
|
||||
use uuid::Version;
|
||||
|
||||
static TRACING_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn logs_request_completion_and_rejects_untrusted_values() {
|
||||
let _tracing_test_guard = TRACING_TEST_LOCK.lock().await;
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = crank_observability::build_subscriber(
|
||||
ObservabilityConfig::new(
|
||||
ServiceIdentity::try_new("admin-api", "test", "test").unwrap(),
|
||||
"info",
|
||||
RedactionLimits::default(),
|
||||
),
|
||||
writer.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let _dispatch_guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let app = probe_app();
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/probe")
|
||||
.header(REQUEST_ID_HEADER.as_str(), "req_admin_trace_123")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[REQUEST_ID_HEADER.as_str()]
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"req_admin_trace_123"
|
||||
);
|
||||
let event: serde_json::Value = writer
|
||||
.output()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.find(|event: &serde_json::Value| event["event"] == "admin.request.completed")
|
||||
.unwrap();
|
||||
assert_eq!(event["request_id"], "req_admin_trace_123");
|
||||
assert_eq!(event["fields"]["status"], 200);
|
||||
|
||||
let invalid_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/probe")
|
||||
.header(REQUEST_ID_HEADER.as_str(), "bad,value")
|
||||
.header("traceparent", "canary-invalid-traceparent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let generated = invalid_response.headers()[REQUEST_ID_HEADER.as_str()]
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(generated).unwrap().get_version(),
|
||||
Some(Version::SortRand)
|
||||
);
|
||||
assert!(!writer.output().contains("canary-invalid-traceparent"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn covers_valid_invalid_and_absent_traceparent() {
|
||||
let _tracing_test_guard = TRACING_TEST_LOCK.lock().await;
|
||||
global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
let provider = SdkTracerProvider::builder().build();
|
||||
let tracer = provider.tracer("admin-request-context-test");
|
||||
let subscriber =
|
||||
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let _dispatch_guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let app = trace_probe_app();
|
||||
|
||||
let valid = observed_trace_id(
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/trace")
|
||||
.header(
|
||||
"traceparent",
|
||||
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
|
||||
)
|
||||
.header(REQUEST_ID_HEADER.as_str(), "request-id-is-separate")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let invalid = observed_trace_id(
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/trace")
|
||||
.header("traceparent", "canary-invalid-traceparent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let absent = observed_trace_id(
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.uri("/trace")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(valid, "0af7651916cd43dd8448eb211c80319c");
|
||||
assert_ne!(invalid, valid);
|
||||
assert_ne!(absent, valid);
|
||||
assert_ne!(invalid, absent);
|
||||
provider.shutdown().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn replaces_multiple_request_id_headers_with_one_uuid_v7() {
|
||||
let _tracing_test_guard = TRACING_TEST_LOCK.lock().await;
|
||||
let dispatch = tracing::Dispatch::new(tracing_subscriber::registry());
|
||||
let _dispatch_guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let mut request = Request::builder()
|
||||
.uri("/probe")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request.headers_mut().append(
|
||||
REQUEST_ID_HEADER,
|
||||
HeaderValue::from_static("first-request-id"),
|
||||
);
|
||||
request.headers_mut().append(
|
||||
REQUEST_ID_HEADER,
|
||||
HeaderValue::from_static("second-request-id"),
|
||||
);
|
||||
|
||||
let response = probe_app().oneshot(request).await.unwrap();
|
||||
let generated = response.headers()[REQUEST_ID_HEADER].to_str().unwrap();
|
||||
|
||||
assert_ne!(generated, "first-request-id");
|
||||
assert_ne!(generated, "second-request-id");
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(generated).unwrap().get_version(),
|
||||
Some(Version::SortRand)
|
||||
);
|
||||
}
|
||||
|
||||
fn probe_app() -> Router {
|
||||
Router::new()
|
||||
.route("/probe", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(apply_request_context))
|
||||
}
|
||||
|
||||
fn trace_probe_app() -> Router {
|
||||
Router::new()
|
||||
.route("/trace", get(observed_traceparent))
|
||||
.layer(axum::middleware::from_fn(apply_request_context))
|
||||
}
|
||||
|
||||
async fn observed_traceparent() -> HeaderMap {
|
||||
let mut trace_headers = HeaderMap::new();
|
||||
inject_current_trace_context(&mut trace_headers);
|
||||
let mut response_headers = HeaderMap::new();
|
||||
if let Some(traceparent) = trace_headers.remove("traceparent") {
|
||||
response_headers.insert("x-observed-traceparent", traceparent);
|
||||
}
|
||||
response_headers
|
||||
}
|
||||
|
||||
fn observed_trace_id(response: axum::response::Response) -> String {
|
||||
let traceparent = response.headers()["x-observed-traceparent"]
|
||||
.to_str()
|
||||
.unwrap();
|
||||
traceparent[3..35].to_owned()
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, fmt,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{Json, Router, routing::post};
|
||||
use crank_core::{
|
||||
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
|
||||
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
|
||||
};
|
||||
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_runtime::SecretCrypto;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
||||
service::{AdminService, AdminServiceBuilder, OperationPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
|
||||
const TEST_AUTH_EMAIL: &str = "owner@crank.local";
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
let shutdown = self.shutdown.take();
|
||||
let handle = self.handle.take();
|
||||
|
||||
tokio::task::block_in_place(|| {
|
||||
if let Some(shutdown) = shutdown {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(handle) = handle {
|
||||
let _ = tokio::runtime::Handle::current().block_on(handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TestServer {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for TestServer {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
struct RejectingIdentityProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl IdentityProvider for RejectingIdentityProvider {
|
||||
fn id(&self) -> &str {
|
||||
"rejecting-test-provider"
|
||||
}
|
||||
|
||||
fn kind(&self) -> IdentityProviderKind {
|
||||
IdentityProviderKind::Password
|
||||
}
|
||||
|
||||
async fn login_password(
|
||||
&self,
|
||||
_payload: crank_core::LoginPayload,
|
||||
) -> Result<LoginOutcome, IdentityError> {
|
||||
Err(IdentityError::BadCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn manages_auth_profiles_and_yaml_upsert() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("yaml");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let secret = client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.json(&json!({
|
||||
"name": "crm-api-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": "super-secret-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let secret = assert_success_json(secret).await;
|
||||
let secret_id = secret["id"].as_str().unwrap();
|
||||
|
||||
let auth_profile = client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.json(&json!({
|
||||
"name": "crm-header",
|
||||
"kind": "api_key_header",
|
||||
"config": {
|
||||
"api_key_header": {
|
||||
"header_name": "X-Api-Key",
|
||||
"secret_id": secret_id
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_export_target",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap();
|
||||
let yaml = client
|
||||
.get(format!("{base_url}/operations/{operation_id}/export"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
let imported = client
|
||||
.post(format!("{base_url}/operations/import?mode=upsert"))
|
||||
.body(yaml)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(auth_profile["kind"], "api_key_header");
|
||||
assert_eq!(
|
||||
auth_profile["config"]["api_key_header"]["secret_id"],
|
||||
secret_id
|
||||
);
|
||||
assert_eq!(imported["operation_id"], operation_id);
|
||||
assert_eq!(imported["version"], 2);
|
||||
assert_eq!(imported["import_mode"], "upsert");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn manages_workspace_secrets_without_exposing_plaintext() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("secrets");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.json(&json!({
|
||||
"name": "crm-api-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": "super-secret-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let created = assert_success_json(created).await;
|
||||
let secret_id = created["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let listed = client
|
||||
.get(format!("{base_url}/secrets"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let listed = assert_success_json(listed).await;
|
||||
|
||||
let fetched = client
|
||||
.get(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let fetched = assert_success_json(fetched).await;
|
||||
|
||||
let rotated = client
|
||||
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
||||
.json(&json!({
|
||||
"value": { "token": "rotated-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let rotated = assert_success_json(rotated).await;
|
||||
|
||||
let deleted = client
|
||||
.delete(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let deleted = assert_success_json(deleted).await;
|
||||
|
||||
let missing = client
|
||||
.get(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let missing_status = missing.status();
|
||||
let missing = missing.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(created["name"], "crm-api-token");
|
||||
assert_eq!(created["kind"], "token");
|
||||
assert_eq!(created["current_version"], 1);
|
||||
assert!(created.get("value").is_none());
|
||||
assert_eq!(listed["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(fetched["id"], secret_id);
|
||||
assert!(fetched.get("value").is_none());
|
||||
assert_eq!(rotated["current_version"], 2);
|
||||
assert_eq!(deleted["ok"], true);
|
||||
assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(missing["error"]["code"], "not_found");
|
||||
assert_eq!(
|
||||
missing["error"]["context"],
|
||||
json!({
|
||||
"secret_id": secret_id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn returns_structured_context_for_missing_operation_version() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("missing_operation_version");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_missing_operation_version",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let response = client
|
||||
.get(format!("{base_url}/operations/{operation_id}/versions/99"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["error"]["code"], "not_found");
|
||||
assert_eq!(
|
||||
body["error"]["message"],
|
||||
format!("operation version 99 for {operation_id} was not found")
|
||||
);
|
||||
assert_eq!(
|
||||
body["error"]["context"],
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"version": 99
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn rejects_auth_profile_with_missing_secret() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("missing_secret_auth");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let response = client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.json(&json!({
|
||||
"name": "crm-header",
|
||||
"kind": "api_key_header",
|
||||
"config": {
|
||||
"api_key_header": {
|
||||
"header_name": "X-Api-Key",
|
||||
"secret_id": "secret_missing"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["error"]["code"], "not_found");
|
||||
assert_eq!(
|
||||
body["error"]["message"],
|
||||
"secret secret_missing was not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn rejects_deleting_secret_referenced_by_auth_profile() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("secret_references");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let secret = client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.json(&json!({
|
||||
"name": "crm-api-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": "super-secret-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let secret = assert_success_json(secret).await;
|
||||
let secret_id = secret["id"].as_str().unwrap();
|
||||
|
||||
let auth_profile = client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.json(&json!({
|
||||
"name": "crm-header",
|
||||
"kind": "api_key_header",
|
||||
"config": {
|
||||
"api_key_header": {
|
||||
"header_name": "X-Api-Key",
|
||||
"secret_id": secret_id
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let auth_profile = assert_success_json(auth_profile).await;
|
||||
let auth_profile_id = auth_profile["id"].as_str().unwrap();
|
||||
|
||||
let response = client
|
||||
.delete(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::CONFLICT);
|
||||
assert_eq!(body["error"]["code"], "conflict");
|
||||
assert_eq!(
|
||||
body["error"]["message"],
|
||||
format!("secret {secret_id} is referenced by auth profile {auth_profile_id}")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn uploads_samples_and_generates_draft() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("draft");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_draft_target",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap();
|
||||
|
||||
client
|
||||
.post(format!(
|
||||
"{base_url}/operations/{operation_id}/samples/input-json"
|
||||
))
|
||||
.json(&json!({ "email": "user@example.com", "name": "Ada" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.post(format!(
|
||||
"{base_url}/operations/{operation_id}/samples/output-json"
|
||||
))
|
||||
.json(&json!({ "id": "lead_123", "status": "created" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let generated = client
|
||||
.post(format!(
|
||||
"{base_url}/operations/{operation_id}/drafts/generate"
|
||||
))
|
||||
.json(&json!({
|
||||
"sources": ["input_json_sample", "output_json_sample"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(generated["generated_draft"]["status"], "available");
|
||||
assert_eq!(
|
||||
generated["input_schema"]["fields"]["email"]["type"],
|
||||
"string"
|
||||
);
|
||||
assert_eq!(
|
||||
generated["output_mapping"]["rules"][0]["target"],
|
||||
"$.output.id"
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
#[path = "integration/request_context.rs"]
|
||||
mod request_context;
|
||||
@@ -1,2 +0,0 @@
|
||||
#[path = "unit/error.rs"]
|
||||
mod error;
|
||||
@@ -1,54 +0,0 @@
|
||||
use admin_api::error::{runtime_error_context, runtime_test_failure};
|
||||
use crank_runtime::RuntimeError;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn runtime_test_failure_includes_structured_context() {
|
||||
let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest {
|
||||
field: "request.headers".to_owned(),
|
||||
reason: "must be an object".to_owned(),
|
||||
});
|
||||
|
||||
assert_eq!(payload["code"], "runtime_request_error");
|
||||
assert_eq!(
|
||||
payload["context"],
|
||||
json!({
|
||||
"field": "request.headers",
|
||||
"reason": "must be an object"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_error_context_includes_secret_crypto_operation() {
|
||||
let context = runtime_error_context(&RuntimeError::SecretCrypto {
|
||||
operation: "decode secret envelope",
|
||||
details: "bad base64".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
context,
|
||||
json!({
|
||||
"operation": "decode secret envelope",
|
||||
"details": "bad base64"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_test_failure_includes_runtime_overload_context() {
|
||||
let payload = runtime_test_failure(&RuntimeError::ConcurrencyLimitExceeded {
|
||||
kind: "window",
|
||||
limit: 16,
|
||||
});
|
||||
|
||||
assert_eq!(payload["code"], "runtime_overloaded");
|
||||
assert_eq!(
|
||||
payload["context"],
|
||||
json!({
|
||||
"kind": "window",
|
||||
"limit": 16
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ name = "mcp-server"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -16,12 +15,10 @@ axum.workspace = true
|
||||
base64.workspace = true
|
||||
crank-community-mcp = { path = "../../crates/crank-community-mcp" }
|
||||
crank-core = { path = "../../crates/crank-core" }
|
||||
crank-observability = { path = "../../crates/crank-observability" }
|
||||
crank-registry = { path = "../../crates/crank-registry" }
|
||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
futures-util = "0.3"
|
||||
metrics.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
@@ -36,11 +33,4 @@ uuid.workspace = true
|
||||
[dev-dependencies]
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
crank-test-support = { path = "../../crates/crank-test-support" }
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry-proto.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
prost.workspace = true
|
||||
reqwest.workspace = true
|
||||
tower.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.96.1-bookworm AS deps
|
||||
FROM rust:1.85-bookworm AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -36,7 +36,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
SQLX_OFFLINE=true cargo build --release -p mcp-server
|
||||
|
||||
FROM rust:1.96.1-bookworm AS builder
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+3403
-70
File diff suppressed because it is too large
Load Diff
@@ -1,227 +0,0 @@
|
||||
use std::{
|
||||
io,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{Json, Router, routing::post};
|
||||
use crank_core::{PlatformApiKeyScope, WorkspaceId};
|
||||
use crank_observability::{
|
||||
ObservabilityConfig, OperationalIncident, RedactionLimits, ServiceIdentity,
|
||||
operational_incident_total,
|
||||
};
|
||||
use crank_registry::{ListInvocationLogsQuery, PublishRequest};
|
||||
use serde_json::{Value, json};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
#[path = "integration/common.rs"]
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
const CANARY_SECRET: &str = "dc08-canary-secret";
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_mcp_result_when_postgres_rejects_invocation_history() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_calls = Arc::new(AtomicUsize::new(0));
|
||||
let upstream_base_url = spawn_counted_upstream(Arc::clone(&upstream_calls)).await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_dc08");
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-dc08").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-dc08",
|
||||
"mcp-dc08",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
install_history_failure_trigger(®istry).await;
|
||||
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = crank_observability::build_subscriber(
|
||||
ObservabilityConfig::new(
|
||||
ServiceIdentity::try_new("mcp-server", "test", "test").unwrap(),
|
||||
"info",
|
||||
RedactionLimits::default(),
|
||||
),
|
||||
writer.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
tracing::subscriber::set_global_default(subscriber).unwrap();
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-dc08");
|
||||
let session_id = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
let before = operational_incident_total(OperationalIncident::InvocationHistoryLost);
|
||||
|
||||
let result = post_jsonrpc_response(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&session_id),
|
||||
Some("req_mcp_dc08"),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_dc08",
|
||||
"arguments": {
|
||||
"email": format!("{CANARY_SECRET}@example.com")
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["result"]["structuredContent"],
|
||||
json!({"id": "lead_123"})
|
||||
);
|
||||
assert_eq!(result["result"]["isError"], false);
|
||||
assert_eq!(upstream_calls.load(Ordering::SeqCst), 1);
|
||||
assert!(operational_incident_total(OperationalIncident::InvocationHistoryLost) > before);
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &WorkspaceId::new("ws_default"),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: None,
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: None,
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs.is_empty());
|
||||
|
||||
let output = writer.output();
|
||||
assert!(!output.contains(CANARY_SECRET));
|
||||
let incidents = output
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter(|event| event["event"] == "mcp.invocation_history.lost")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(incidents.len(), 1);
|
||||
assert_eq!(incidents[0]["request_id"], "req_mcp_dc08");
|
||||
assert_eq!(incidents[0]["fields"]["source"], "agent_tool_call");
|
||||
assert_eq!(incidents[0]["fields"]["invocation_status"], "ok");
|
||||
}
|
||||
|
||||
async fn install_history_failure_trigger(registry: &crank_registry::PostgresRegistry) {
|
||||
sqlx::query(
|
||||
r#"
|
||||
create function fail_dc08_invocation_history() returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if new.request_id = 'req_mcp_dc08' then
|
||||
raise exception 'forced DC-08 invocation history failure';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$
|
||||
"#,
|
||||
)
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
r#"
|
||||
create trigger fail_dc08_invocation_history
|
||||
before insert on invocation_logs
|
||||
for each row execute function fail_dc08_invocation_history()
|
||||
"#,
|
||||
)
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn spawn_counted_upstream(calls: Arc<AtomicUsize>) -> String {
|
||||
let app = Router::new().route(
|
||||
"/crm/leads",
|
||||
post(move |Json(payload): Json<Value>| {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"email": payload["email"]
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#[path = "integration/common.rs"]
|
||||
mod common;
|
||||
#[path = "integration/execution_stages.rs"]
|
||||
mod execution_stages;
|
||||
@@ -1,6 +0,0 @@
|
||||
mod integration {
|
||||
mod catalog_access;
|
||||
mod common;
|
||||
mod tool_search;
|
||||
mod transport_protocol;
|
||||
}
|
||||
@@ -1,677 +0,0 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
mod approval_access;
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
http::header,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
routing::{get, post},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
|
||||
ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, InvocationSource,
|
||||
Operation, OperationApprovalMode, OperationApprovalPayloadPreviewMode, OperationApprovalPolicy,
|
||||
OperationApprovalRiskLevel, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
||||
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::{
|
||||
CreateAgentRequest, CreateApprovalRequest, CreatePlatformApiKeyRequest,
|
||||
ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest,
|
||||
SaveAgentBindingsRequest,
|
||||
};
|
||||
use crank_runtime::{
|
||||
InMemoryCoordinationStateStore, RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor,
|
||||
SecretCrypto,
|
||||
};
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use futures_util::stream;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::sleep;
|
||||
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
||||
|
||||
use crank_community_mcp::{
|
||||
auth::{CommunityMachineCredentialVerifier, SharedMachineCredentialVerifier},
|
||||
build_app,
|
||||
catalog::PublishedToolCatalog,
|
||||
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
|
||||
};
|
||||
|
||||
fn test_workspace_id() -> WorkspaceId {
|
||||
WorkspaceId::new("ws_default")
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_workspace_slug() -> &'static str {
|
||||
"default"
|
||||
}
|
||||
|
||||
fn test_agent_id(agent_slug: &str) -> AgentId {
|
||||
AgentId::new(format!("agent_{agent_slug}"))
|
||||
}
|
||||
|
||||
fn build_test_app(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
) -> axum::Router {
|
||||
build_test_app_with_rate_limit(
|
||||
registry,
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_test_app_with_rate_limit(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
rate_limit_config: RequestRateLimitConfig,
|
||||
) -> axum::Router {
|
||||
build_test_app_with_store(
|
||||
registry,
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
rate_limit_config,
|
||||
std::sync::Arc::new(InMemorySessionStore::default()),
|
||||
std::sync::Arc::new(CommunityMachineCredentialVerifier),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_test_app_with_store(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
rate_limit_config: RequestRateLimitConfig,
|
||||
sessions: SharedSessionStore,
|
||||
credential_verifier: SharedMachineCredentialVerifier,
|
||||
) -> axum::Router {
|
||||
build_app(
|
||||
registry,
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
SecretCrypto::new("test-master-key").unwrap(),
|
||||
crank_runtime::community_with_outbound_policy(
|
||||
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
)
|
||||
.build(),
|
||||
RequestRateLimiter::new(rate_limit_config),
|
||||
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
||||
sessions,
|
||||
credential_verifier,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshes_published_tools_without_restart() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-refresh");
|
||||
publish_agent_with_bindings(®istry, "sales-refresh", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-refresh",
|
||||
"mcp-refresh",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let before_publish = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let operation = test_operation(&upstream_base_url, "crm_publish_later");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.save_agent_bindings(SaveAgentBindingsRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
agent_id: &test_agent_id("sales-refresh"),
|
||||
agent_version: 1,
|
||||
bindings: &[binding_for_operation(&operation)],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let after_publish = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(before_publish["result"]["tools"], json!([]));
|
||||
assert_eq!(
|
||||
after_publish["result"]["tools"][0]["name"],
|
||||
"crm_publish_later"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shares_published_catalog_snapshot_across_instances() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_catalog_shared");
|
||||
let coordination_store = std::sync::Arc::new(InMemoryCoordinationStateStore::default());
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-shared-catalog").await;
|
||||
|
||||
let catalog_a = PublishedToolCatalog::new(
|
||||
registry.clone(),
|
||||
Duration::from_secs(60),
|
||||
coordination_store.clone(),
|
||||
);
|
||||
let tools_a = catalog_a
|
||||
.list_tools(test_workspace_slug(), "sales-shared-catalog")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tools_a.len(), 1);
|
||||
|
||||
registry
|
||||
.unpublish_agent(
|
||||
&test_workspace_id(),
|
||||
&test_agent_id("sales-shared-catalog"),
|
||||
&OffsetDateTime::parse("2026-03-26T10:05:00Z", &Rfc3339).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let catalog_b = PublishedToolCatalog::new(
|
||||
registry.clone(),
|
||||
Duration::from_secs(60),
|
||||
coordination_store,
|
||||
);
|
||||
let tools_b = catalog_b
|
||||
.list_tools(test_workspace_slug(), "sales-shared-catalog")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tools_b.len(), 1);
|
||||
assert_eq!(tools_b[0].tool_name, operation.name);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_only_bound_tools_for_agent_context() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation_a = test_operation(&upstream_base_url, "crm_create_lead");
|
||||
let operation_b = test_operation(&upstream_base_url, "crm_update_lead");
|
||||
|
||||
for operation in [&operation_a, &operation_b] {
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-a",
|
||||
vec![binding_for_operation(&operation_a)],
|
||||
)
|
||||
.await;
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-b",
|
||||
vec![binding_for_operation(&operation_b)],
|
||||
)
|
||||
.await;
|
||||
let api_key_a = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-a",
|
||||
"mcp-agent-a",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let api_key_b = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-b",
|
||||
"mcp-agent-b",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_a_url = agent_mcp_url(&base_url, "sales-a");
|
||||
let agent_b_url = agent_mcp_url(&base_url, "sales-b");
|
||||
let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await;
|
||||
let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await;
|
||||
|
||||
let tools_a = post_jsonrpc(
|
||||
&client,
|
||||
&agent_a_url,
|
||||
&api_key_a,
|
||||
Some(&session_a),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let tools_b = post_jsonrpc(
|
||||
&client,
|
||||
&agent_b_url,
|
||||
&api_key_b,
|
||||
Some(&session_b),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(tools_a["result"]["tools"][0]["name"], "crm_create_lead");
|
||||
assert_eq!(tools_b["result"]["tools"][0]["name"], "crm_update_lead");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_initialize_with_key_from_different_agent() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation_a = test_operation(&upstream_base_url, "crm_create_lead");
|
||||
let operation_b = test_operation(&upstream_base_url, "crm_update_lead");
|
||||
|
||||
for operation in [&operation_a, &operation_b] {
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-a",
|
||||
vec![binding_for_operation(&operation_a)],
|
||||
)
|
||||
.await;
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-b",
|
||||
vec![binding_for_operation(&operation_b)],
|
||||
)
|
||||
.await;
|
||||
let api_key_a = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-a",
|
||||
"mcp-agent-a-only",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(agent_mcp_url(&base_url, "sales-b"))
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key_a}"))
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_initialize_without_platform_api_key() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-auth", vec![]).await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(agent_mcp_url(&base_url, "sales-auth"))
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_initialize_with_approval_platform_api_key() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-approval-key", vec![]).await;
|
||||
let api_key =
|
||||
create_approval_platform_api_key(®istry, "sales-approval-key", "approval-only").await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(agent_mcp_url(&base_url, "sales-approval-key"))
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_tool_call_with_read_only_platform_api_key() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_read_only");
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-read-only").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-read-only",
|
||||
"mcp-read",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-read-only");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
let response = client
|
||||
.post(&mcp_url)
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Session-Id", initialized_session)
|
||||
.header("MCP-Protocol-Version", "2025-11-25")
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_read_only",
|
||||
"arguments": {
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_rapid_initialize_requests_with_429() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_rate_limited");
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-rate-limited").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-rate-limited",
|
||||
"mcp-rate-limit",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_test_app_with_rate_limit(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
RequestRateLimitConfig::new(1, 1).unwrap(),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-rate-limited");
|
||||
|
||||
let first_response = client
|
||||
.post(&mcp_url)
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("x-request-id", "req_rate_limit_01")
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first_response.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let second_response = client
|
||||
.post(&mcp_url)
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("x-request-id", "req_rate_limit_02")
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
second_response.status(),
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
assert_eq!(
|
||||
second_response.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_rate_limit_02"
|
||||
);
|
||||
assert_eq!(
|
||||
second_response.headers()["retry-after"].to_str().unwrap(),
|
||||
"1"
|
||||
);
|
||||
|
||||
let payload = second_response.json::<Value>().await.unwrap();
|
||||
assert_eq!(payload["error"]["code"], json!(-32029));
|
||||
assert_eq!(
|
||||
payload["error"]["data"]["code"],
|
||||
json!("request_rate_limited")
|
||||
);
|
||||
let retry_after_ms = payload["error"]["data"]["retry_after_ms"].as_u64().unwrap();
|
||||
assert!((1..=1000).contains(&retry_after_ms));
|
||||
}
|
||||
@@ -1,872 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_key_lists_and_decides_pending_requests() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_human_approval");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-human-approval",
|
||||
vec![binding_for_operation(&operation)],
|
||||
)
|
||||
.await;
|
||||
let approval = ApprovalRequest {
|
||||
id: ApprovalRequestId::new("approval_mcp_01"),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id: test_agent_id("sales-human-approval"),
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: 1,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
request_payload: json!({"email": "ada@example.com"}),
|
||||
response_payload: None,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5),
|
||||
decided_at: None,
|
||||
decided_by_key_id: None,
|
||||
decision_note: None,
|
||||
};
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let approval_key =
|
||||
create_approval_platform_api_key(®istry, "sales-human-approval", "approval-http").await;
|
||||
let mcp_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-human-approval",
|
||||
"mcp-human-approval",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let approvals_url = format!(
|
||||
"{}/approvals",
|
||||
agent_mcp_url(&base_url, "sales-human-approval")
|
||||
);
|
||||
|
||||
let rejected = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {mcp_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||
|
||||
let pending = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.status(), reqwest::StatusCode::OK);
|
||||
let pending_body = pending.json::<Value>().await.unwrap();
|
||||
assert_eq!(pending_body["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
pending_body["items"][0]["approval"]["request_payload"],
|
||||
json!({"email": "ada@example.com"})
|
||||
);
|
||||
|
||||
let approve_url = format!(
|
||||
"{}/approvals/{}/approve",
|
||||
agent_mcp_url(&base_url, "sales-human-approval"),
|
||||
approval.id
|
||||
);
|
||||
let approved = client
|
||||
.post(&approve_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.header("x-request-id", "req_approval_execute_123")
|
||||
.json(&json!({ "approve": "yes", "note": "confirmed by test" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(approved.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
approved.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_approval_execute_123"
|
||||
);
|
||||
let approved_body = approved.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
approved_body["approval"]["status"],
|
||||
Value::String("completed".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
approved_body["approval"]["response_payload"],
|
||||
json!({ "id": "lead_123" })
|
||||
);
|
||||
|
||||
let status_url = format!(
|
||||
"{}/approvals/{}",
|
||||
agent_mcp_url(&base_url, "sales-human-approval"),
|
||||
approval.id
|
||||
);
|
||||
let current = client
|
||||
.get(&status_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(current.status(), reqwest::StatusCode::OK);
|
||||
let current_body = current.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
current_body["approval"]["status"],
|
||||
Value::String("completed".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
current_body["approval"]["response_payload"],
|
||||
json!({ "id": "lead_123" })
|
||||
);
|
||||
|
||||
let repeated_approve = client
|
||||
.post(&approve_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.json(&json!({ "approve": "yes", "note": "duplicate confirmation" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repeated_approve.status(), reqwest::StatusCode::OK);
|
||||
let repeated_body = repeated_approve.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
repeated_body["approval"]["status"],
|
||||
Value::String("completed".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
repeated_body["approval"]["response_payload"],
|
||||
json!({ "id": "lead_123" })
|
||||
);
|
||||
|
||||
let pending_after = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(pending_after["items"].as_array().unwrap().is_empty());
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: Some(InvocationSource::AgentToolCall),
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: Some(&test_agent_id("sales-human-approval")),
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(logs.len(), 1);
|
||||
assert_eq!(
|
||||
logs[0].log.request_id.as_deref(),
|
||||
Some("req_approval_execute_123")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_key_denies_without_executing_upstream() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_human_deny");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-human-deny",
|
||||
vec![binding_for_operation(&operation)],
|
||||
)
|
||||
.await;
|
||||
let approval = ApprovalRequest {
|
||||
id: ApprovalRequestId::new("approval_mcp_deny_01"),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id: test_agent_id("sales-human-deny"),
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: 1,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
request_payload: json!({"email": "deny@example.com"}),
|
||||
response_payload: None,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5),
|
||||
decided_at: None,
|
||||
decided_by_key_id: None,
|
||||
decision_note: None,
|
||||
};
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let approval_key =
|
||||
create_approval_platform_api_key(®istry, "sales-human-deny", "approval-deny-http").await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let deny_url = format!(
|
||||
"{}/approvals/{}/deny",
|
||||
agent_mcp_url(&base_url, "sales-human-deny"),
|
||||
approval.id
|
||||
);
|
||||
|
||||
let denied = client
|
||||
.post(&deny_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.json(&json!({ "approve": "no", "note": "rejected by test" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(denied.status(), reqwest::StatusCode::OK);
|
||||
let denied_body = denied.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
denied_body["approval"]["status"],
|
||||
Value::String("denied".to_owned())
|
||||
);
|
||||
|
||||
let repeated_deny = client
|
||||
.post(&deny_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.json(&json!({ "approve": "no", "note": "duplicate rejection" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repeated_deny.status(), reqwest::StatusCode::OK);
|
||||
let repeated_body = repeated_deny.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
repeated_body["approval"]["status"],
|
||||
Value::String("denied".to_owned())
|
||||
);
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: Some(InvocationSource::AgentToolCall),
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: Some(&test_agent_id("sales-human-deny")),
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_key_expires_without_executing_upstream() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_human_expired");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-human-expired",
|
||||
vec![binding_for_operation(&operation)],
|
||||
)
|
||||
.await;
|
||||
let approval = ApprovalRequest {
|
||||
id: ApprovalRequestId::new("approval_mcp_expired_01"),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id: test_agent_id("sales-human-expired"),
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: 1,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
request_payload: json!({"email": "expired@example.com"}),
|
||||
response_payload: None,
|
||||
created_at: OffsetDateTime::now_utc() - time::Duration::minutes(10),
|
||||
expires_at: OffsetDateTime::now_utc() - time::Duration::minutes(5),
|
||||
decided_at: None,
|
||||
decided_by_key_id: None,
|
||||
decision_note: None,
|
||||
};
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let approval_key =
|
||||
create_approval_platform_api_key(®istry, "sales-human-expired", "approval-expired-http")
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let approve_url = format!(
|
||||
"{}/approvals/{}/approve",
|
||||
agent_mcp_url(&base_url, "sales-human-expired"),
|
||||
approval.id
|
||||
);
|
||||
|
||||
let expired = client
|
||||
.post(&approve_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.json(&json!({ "approve": "yes", "note": "too late" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(expired.status(), reqwest::StatusCode::OK);
|
||||
let expired_body = expired.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
expired_body["approval"]["status"],
|
||||
Value::String("expired".to_owned())
|
||||
);
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: Some(InvocationSource::AgentToolCall),
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: Some(&test_agent_id("sales-human-expired")),
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_call_with_approval_policy_creates_pending_request() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval");
|
||||
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
|
||||
required: true,
|
||||
mode: OperationApprovalMode::Custom,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
ttl_seconds: 300,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||
elicitation_message: None,
|
||||
});
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-gated").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-gated",
|
||||
"mcp-gated",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let approval_key =
|
||||
create_approval_platform_api_key(®istry, "sales-gated", "approval-gated").await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-gated");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let tool_call = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_requires_human_approval",
|
||||
"arguments": {
|
||||
"email": "ada@example.com"
|
||||
}
|
||||
}
|
||||
});
|
||||
let tool_result = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
tool_call.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
tool_result["result"]["structuredContent"]["status"],
|
||||
"approval_required"
|
||||
);
|
||||
assert_eq!(tool_result["result"]["isError"], false);
|
||||
let approval_id = tool_result["result"]["structuredContent"]["approval_id"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
assert!(approval_id.starts_with("approval_"));
|
||||
|
||||
let repeated_tool_result = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
tool_call,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
repeated_tool_result["result"]["structuredContent"]["approval_id"], approval_id,
|
||||
"deduplicated tools/call must return the persisted approval id",
|
||||
);
|
||||
|
||||
let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated"));
|
||||
let pending = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(pending["items"][0]["approval"]["id"], approval_id);
|
||||
assert_eq!(
|
||||
pending["items"][0]["approval"]["request_payload"]["email"],
|
||||
"ada@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_http_endpoints_enforce_request_rate_limit() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_approval_rate_limit");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-approval-rate-limit").await;
|
||||
let approval_key = create_approval_platform_api_key(
|
||||
®istry,
|
||||
"sales-approval-rate-limit",
|
||||
"approval-rate-limit",
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app_with_rate_limit(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
RequestRateLimitConfig::new(1, 1).unwrap(),
|
||||
))
|
||||
.await;
|
||||
let approvals_url = format!(
|
||||
"{}/approvals",
|
||||
agent_mcp_url(&base_url, "sales-approval-rate-limit")
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let allowed = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(allowed.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let limited = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(limited.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(limited.headers().contains_key(header::RETRY_AFTER));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unverified_session_ids_do_not_create_approval_rate_limit_buckets() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_approval_session_rate_limit");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-approval-session-rate-limit").await;
|
||||
let approval_key = create_approval_platform_api_key(
|
||||
®istry,
|
||||
"sales-approval-session-rate-limit",
|
||||
"approval-session-rate-limit",
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app_with_rate_limit(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
RequestRateLimitConfig::new(1, 1).unwrap(),
|
||||
))
|
||||
.await;
|
||||
let approvals_url = format!(
|
||||
"{}/approvals",
|
||||
agent_mcp_url(&base_url, "sales-approval-session-rate-limit")
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let allowed = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.header("MCP-Session-Id", "unverified-session-a")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(allowed.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let limited = client
|
||||
.get(&approvals_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
|
||||
.header("MCP-Session-Id", "unverified-session-b")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(limited.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(limited.headers().contains_key(header::RETRY_AFTER));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_does_not_repeat_interrupted_mutating_approval() {
|
||||
let registry = test_registry().await;
|
||||
let (upstream_base_url, upstream_calls) = spawn_counted_approval_upstream().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_interrupted_approval");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-interrupted-approval").await;
|
||||
let approval_key_name = "approval-interrupted";
|
||||
create_approval_platform_api_key(®istry, "sales-interrupted-approval", approval_key_name)
|
||||
.await;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let approval = ApprovalRequest {
|
||||
id: ApprovalRequestId::new("approval_interrupted_mutation"),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id: test_agent_id("sales-interrupted-approval"),
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: operation.version,
|
||||
status: ApprovalRequestStatus::Pending,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
request_payload: json!({"email": "interrupted@example.com"}),
|
||||
response_payload: None,
|
||||
created_at: now - time::Duration::minutes(10),
|
||||
expires_at: now + time::Duration::minutes(5),
|
||||
decided_at: None,
|
||||
decided_by_key_id: None,
|
||||
decision_note: None,
|
||||
};
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let approval_key_id = PlatformApiKeyId::new(format!("pk_{approval_key_name}"));
|
||||
registry
|
||||
.decide_approval_request(crank_registry::DecideApprovalRequest {
|
||||
workspace_id: &approval.workspace_id,
|
||||
agent_id: &approval.agent_id,
|
||||
approval_id: &approval.id,
|
||||
status: ApprovalRequestStatus::Approved,
|
||||
decided_at: now - time::Duration::minutes(10),
|
||||
decided_by_key_id: &approval_key_id,
|
||||
response_payload: Some(json!({"approve": "yes"})),
|
||||
decision_note: None,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
registry
|
||||
.claim_approval_request(
|
||||
&approval.workspace_id,
|
||||
&approval.agent_id,
|
||||
&approval.id,
|
||||
now - time::Duration::minutes(7),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let _app = build_test_app_with_approval_recovery(registry.clone());
|
||||
let failed = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let current = registry
|
||||
.get_approval_request_for_agent(
|
||||
&approval.workspace_id,
|
||||
&approval.agent_id,
|
||||
&approval.id,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if current.approval.status == ApprovalRequestStatus::Failed {
|
||||
break current;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("recovery must quarantine interrupted execution");
|
||||
|
||||
assert_eq!(
|
||||
failed.approval.response_payload.unwrap()["error"]["code"],
|
||||
"approval_execution_outcome_unknown"
|
||||
);
|
||||
assert_eq!(
|
||||
upstream_calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0,
|
||||
"recovery must not repeat a mutating upstream request"
|
||||
);
|
||||
}
|
||||
|
||||
fn build_test_app_with_approval_recovery(registry: PostgresRegistry) -> Router {
|
||||
crank_community_mcp::build_app_with_background_workers(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
SecretCrypto::new("test-master-key").unwrap(),
|
||||
crank_runtime::community_with_outbound_policy(
|
||||
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
)
|
||||
.build(),
|
||||
RequestRateLimiter::new(RequestRateLimitConfig::new(10_000, 10_000).unwrap()),
|
||||
Arc::new(InMemoryCoordinationStateStore::default()),
|
||||
Arc::new(InMemorySessionStore::default()),
|
||||
Arc::new(CommunityMachineCredentialVerifier),
|
||||
)
|
||||
}
|
||||
|
||||
async fn spawn_counted_approval_upstream() -> (String, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let handler_calls = Arc::clone(&calls);
|
||||
let app = Router::new().route(
|
||||
"/crm/leads",
|
||||
post(move |Json(payload): Json<Value>| {
|
||||
let calls = Arc::clone(&handler_calls);
|
||||
async move {
|
||||
calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"email": payload["email"]
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
(format!("http://{address}"), calls)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn elicitation_approval_requires_client_capability() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let mut operation = test_operation(&upstream_base_url, "crm_requires_elicitation");
|
||||
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
|
||||
required: true,
|
||||
mode: OperationApprovalMode::Elicitation,
|
||||
risk_level: OperationApprovalRiskLevel::Normal,
|
||||
ttl_seconds: 300,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||
elicitation_message: Some("Подтвердите создание лида.".to_owned()),
|
||||
});
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::now_utc(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-elicitation-no-capability",
|
||||
vec![binding_for_operation(&operation)],
|
||||
)
|
||||
.await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-elicitation-no-capability",
|
||||
"mcp-elicitation-no-capability",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-elicitation-no-capability");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let tool_result = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_requires_elicitation",
|
||||
"arguments": {
|
||||
"email": "ada@example.com"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(tool_result["result"]["isError"], true);
|
||||
assert_eq!(
|
||||
tool_result["result"]["structuredContent"]["error"]["code"],
|
||||
"approval_elicitation_not_supported"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn elicitation_approval_uses_session_capability_without_approval_key() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let mut operation = test_operation(&upstream_base_url, "crm_requires_elicitation_supported");
|
||||
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
|
||||
required: true,
|
||||
mode: OperationApprovalMode::Elicitation,
|
||||
risk_level: OperationApprovalRiskLevel::Normal,
|
||||
ttl_seconds: 300,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||
elicitation_message: Some("Подтвердите создание лида.".to_owned()),
|
||||
});
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::now_utc(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_with_bindings(
|
||||
®istry,
|
||||
"sales-elicitation-supported",
|
||||
vec![binding_for_operation(&operation)],
|
||||
)
|
||||
.await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-elicitation-supported",
|
||||
"mcp-elicitation-supported",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-elicitation-supported");
|
||||
let initialized_session = initialize_session_with_capabilities(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
json!({ "elicitation": {} }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tool_result = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 8,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_requires_elicitation_supported",
|
||||
"arguments": {
|
||||
"email": "ada@example.com"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(tool_result["result"]["isError"], false);
|
||||
assert_eq!(
|
||||
tool_result["result"]["structuredContent"]["status"],
|
||||
"elicitation_required"
|
||||
);
|
||||
assert_eq!(
|
||||
tool_result["result"]["structuredContent"]["message"],
|
||||
"Подтвердите создание лида."
|
||||
);
|
||||
assert_eq!(
|
||||
tool_result["result"]["structuredContent"]["payload_preview"]["email"],
|
||||
"ada@example.com"
|
||||
);
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
http::header,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
routing::{get, post},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
|
||||
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
|
||||
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
|
||||
ToolSelectionPolicy, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::{
|
||||
CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry,
|
||||
PublishAgentRequest, PublishRequest, SaveAgentBindingsRequest,
|
||||
};
|
||||
use crank_runtime::{
|
||||
InMemoryCoordinationStateStore, RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor,
|
||||
SecretCrypto,
|
||||
};
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use futures_util::stream;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::sleep;
|
||||
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
||||
|
||||
use crank_community_mcp::{
|
||||
auth::{CommunityMachineCredentialVerifier, SharedMachineCredentialVerifier},
|
||||
build_app,
|
||||
catalog::PublishedToolCatalog,
|
||||
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
|
||||
};
|
||||
|
||||
pub(super) fn test_workspace_id() -> WorkspaceId {
|
||||
WorkspaceId::new("ws_default")
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_workspace_slug() -> &'static str {
|
||||
"default"
|
||||
}
|
||||
|
||||
fn test_agent_id(agent_slug: &str) -> AgentId {
|
||||
AgentId::new(format!("agent_{agent_slug}"))
|
||||
}
|
||||
|
||||
pub(super) fn build_test_app(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
) -> axum::Router {
|
||||
build_test_app_with_rate_limit(
|
||||
registry,
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_test_app_with_rate_limit(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
rate_limit_config: RequestRateLimitConfig,
|
||||
) -> axum::Router {
|
||||
build_test_app_with_store(
|
||||
registry,
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
rate_limit_config,
|
||||
std::sync::Arc::new(InMemorySessionStore::default()),
|
||||
std::sync::Arc::new(CommunityMachineCredentialVerifier),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_test_app_with_store(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
rate_limit_config: RequestRateLimitConfig,
|
||||
sessions: SharedSessionStore,
|
||||
credential_verifier: SharedMachineCredentialVerifier,
|
||||
) -> axum::Router {
|
||||
build_app(
|
||||
registry,
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
SecretCrypto::new("test-master-key").unwrap(),
|
||||
crank_runtime::community_with_outbound_policy(
|
||||
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
)
|
||||
.build(),
|
||||
RequestRateLimiter::new(rate_limit_config),
|
||||
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
||||
sessions,
|
||||
credential_verifier,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn initialize_session(
|
||||
client: &reqwest::Client,
|
||||
mcp_url: &str,
|
||||
api_key: &str,
|
||||
) -> String {
|
||||
initialize_session_with_capabilities(client, mcp_url, api_key, json!({})).await
|
||||
}
|
||||
|
||||
pub(super) async fn initialize_session_with_capabilities(
|
||||
client: &reqwest::Client,
|
||||
mcp_url: &str,
|
||||
api_key: &str,
|
||||
capabilities: Value,
|
||||
) -> String {
|
||||
let initialize_response = client
|
||||
.post(mcp_url)
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25",
|
||||
"capabilities": capabilities
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(initialize_response.status(), reqwest::StatusCode::OK);
|
||||
let session_id = initialize_response
|
||||
.headers()
|
||||
.get("MCP-Session-Id")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let initialized_response = client
|
||||
.post(mcp_url)
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Session-Id", &session_id)
|
||||
.header("MCP-Protocol-Version", "2025-11-25")
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized",
|
||||
"params": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(initialized_response.status(), reqwest::StatusCode::ACCEPTED);
|
||||
|
||||
session_id
|
||||
}
|
||||
|
||||
pub(super) async fn post_jsonrpc(
|
||||
client: &reqwest::Client,
|
||||
mcp_url: &str,
|
||||
api_key: &str,
|
||||
session_id: Option<&str>,
|
||||
payload: Value,
|
||||
) -> Value {
|
||||
post_jsonrpc_response(client, mcp_url, api_key, session_id, None, payload)
|
||||
.await
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn post_jsonrpc_response(
|
||||
client: &reqwest::Client,
|
||||
mcp_url: &str,
|
||||
api_key: &str,
|
||||
session_id: Option<&str>,
|
||||
request_id: Option<&str>,
|
||||
payload: Value,
|
||||
) -> reqwest::Response {
|
||||
let mut request = client
|
||||
.post(mcp_url)
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Protocol-Version", "2025-11-25");
|
||||
|
||||
if let Some(session_id) = session_id {
|
||||
request = request.header("MCP-Session-Id", session_id);
|
||||
}
|
||||
|
||||
if let Some(request_id) = request_id {
|
||||
request = request.header("x-request-id", request_id);
|
||||
}
|
||||
|
||||
request.json(&payload).send().await.unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn create_platform_api_key(
|
||||
registry: &PostgresRegistry,
|
||||
agent_slug: &str,
|
||||
name: &str,
|
||||
scopes: &[PlatformApiKeyScope],
|
||||
) -> String {
|
||||
create_platform_api_key_with_kind(
|
||||
registry,
|
||||
agent_slug,
|
||||
name,
|
||||
PlatformApiKeyKind::McpClient,
|
||||
scopes,
|
||||
"crk",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn create_approval_platform_api_key(
|
||||
registry: &PostgresRegistry,
|
||||
agent_slug: &str,
|
||||
name: &str,
|
||||
) -> String {
|
||||
create_platform_api_key_with_kind(
|
||||
registry,
|
||||
agent_slug,
|
||||
name,
|
||||
PlatformApiKeyKind::Approval,
|
||||
&[
|
||||
PlatformApiKeyScope::ReadPending,
|
||||
PlatformApiKeyScope::Approve,
|
||||
PlatformApiKeyScope::Deny,
|
||||
],
|
||||
"crk_appr",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_platform_api_key_with_kind(
|
||||
registry: &PostgresRegistry,
|
||||
agent_slug: &str,
|
||||
name: &str,
|
||||
key_kind: PlatformApiKeyKind,
|
||||
scopes: &[PlatformApiKeyScope],
|
||||
prefix: &str,
|
||||
) -> String {
|
||||
let secret = format!("{prefix}_{}_{}", name, uuid::Uuid::now_v7().simple());
|
||||
let api_key = PlatformApiKey {
|
||||
id: PlatformApiKeyId::new(format!("pk_{name}")),
|
||||
workspace_id: test_workspace_id(),
|
||||
agent_id: Some(test_agent_id(agent_slug)),
|
||||
key_kind,
|
||||
name: name.to_owned(),
|
||||
prefix: secret.chars().take(16).collect(),
|
||||
scopes: scopes.to_vec(),
|
||||
status: PlatformApiKeyStatus::Active,
|
||||
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
allowed_origins: Vec::new(),
|
||||
};
|
||||
|
||||
registry
|
||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||
api_key: &api_key,
|
||||
secret_hash: &hash_access_secret(&secret),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
secret
|
||||
}
|
||||
|
||||
pub(super) fn hash_access_secret(secret: &str) -> String {
|
||||
let digest = Sha256::digest(secret.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest)
|
||||
}
|
||||
|
||||
pub(super) async fn spawn_upstream_server() -> String {
|
||||
let app = Router::new()
|
||||
.route("/sse/logs", get(stream_logs))
|
||||
.route("/crm/leads", post(create_lead))
|
||||
.route("/crm/slow-leads", post(create_slow_lead));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
format!("http://{}", address)
|
||||
}
|
||||
|
||||
pub(super) async fn spawn_mcp_server(app: Router) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
format!("http://{}", address)
|
||||
}
|
||||
|
||||
pub(super) fn agent_mcp_url(base_url: &str, agent_slug: &str) -> String {
|
||||
format!("{base_url}/v1/{}/{}", test_workspace_slug(), agent_slug)
|
||||
}
|
||||
|
||||
pub(super) async fn publish_agent_for_operation(
|
||||
registry: &PostgresRegistry,
|
||||
operation: &Operation<Schema, MappingSet>,
|
||||
agent_slug: &str,
|
||||
) {
|
||||
publish_agent_with_bindings(registry, agent_slug, vec![binding_for_operation(operation)]).await;
|
||||
}
|
||||
|
||||
pub(super) async fn publish_agent_with_bindings(
|
||||
registry: &PostgresRegistry,
|
||||
agent_slug: &str,
|
||||
bindings: Vec<AgentOperationBinding>,
|
||||
) {
|
||||
publish_agent_with_policy(
|
||||
registry,
|
||||
agent_slug,
|
||||
bindings,
|
||||
ToolSelectionPolicy::default(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn publish_agent_with_policy(
|
||||
registry: &PostgresRegistry,
|
||||
agent_slug: &str,
|
||||
bindings: Vec<AgentOperationBinding>,
|
||||
tool_selection_policy: ToolSelectionPolicy,
|
||||
) {
|
||||
let agent_id = AgentId::new(format!("agent_{agent_slug}"));
|
||||
let agent = Agent {
|
||||
id: agent_id.clone(),
|
||||
workspace_id: test_workspace_id(),
|
||||
slug: agent_slug.to_owned(),
|
||||
display_name: format!("Agent {agent_slug}"),
|
||||
description: "Curated MCP toolset".to_owned(),
|
||||
status: AgentStatus::Draft,
|
||||
current_draft_version: 1,
|
||||
latest_published_version: None,
|
||||
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
updated_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_at: None,
|
||||
};
|
||||
let version = AgentVersion {
|
||||
agent_id: agent_id.clone(),
|
||||
version: 1,
|
||||
status: AgentStatus::Draft,
|
||||
instructions: json!({}),
|
||||
tool_selection_policy,
|
||||
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
};
|
||||
|
||||
registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &version,
|
||||
bindings: &bindings,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
agent_id: &agent_id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub(super) fn binding_for_operation(
|
||||
operation: &Operation<Schema, MappingSet>,
|
||||
) -> AgentOperationBinding {
|
||||
AgentOperationBinding {
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: 1,
|
||||
tool_name: operation.name.clone(),
|
||||
tool_title: operation.tool_description.title.clone(),
|
||||
tool_description_override: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn create_lead(Json(payload): Json<Value>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"email": payload["email"]
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn create_slow_lead(Json(payload): Json<Value>) -> Json<Value> {
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"email": payload["email"]
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn stream_logs()
|
||||
-> Sse<impl futures_util::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
let events = vec![
|
||||
json!({ "level": "info", "message": "sync started" }),
|
||||
json!({ "level": "warn", "message": "cache warmup slow" }),
|
||||
json!({ "level": "error", "message": "upstream timeout" }),
|
||||
];
|
||||
let stream = stream::iter(events.into_iter().map(|payload| {
|
||||
Ok::<_, std::convert::Infallible>(Event::default().data(payload.to_string()))
|
||||
}));
|
||||
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
|
||||
}
|
||||
|
||||
pub(super) async fn test_registry() -> PostgresRegistry {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_mcp_server").await;
|
||||
PostgresRegistry::connect(&database_url).await.unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn test_operation(base_url: &str, name: &str) -> Operation<Schema, MappingSet> {
|
||||
Operation {
|
||||
id: OperationId::new(format!("op_{name}")),
|
||||
name: name.to_owned(),
|
||||
display_name: "Create Lead".to_owned(),
|
||||
category: "sales".to_owned(),
|
||||
protocol: Protocol::Rest,
|
||||
security_level: crank_core::OperationSecurityLevel::Standard,
|
||||
status: OperationStatus::Published,
|
||||
version: 1,
|
||||
target: Target::Rest(RestTarget {
|
||||
base_url: base_url.to_owned(),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/crm/leads".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
}),
|
||||
input_schema: object_schema("email"),
|
||||
output_schema: object_schema("id"),
|
||||
input_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.email".to_owned(),
|
||||
target: "$.request.body.email".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
output_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.response.body.id".to_owned(),
|
||||
target: "$.output.id".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Create Lead".to_owned(),
|
||||
description: "Creates a CRM lead".to_owned(),
|
||||
tags: Vec::new(),
|
||||
examples: Vec::new(),
|
||||
},
|
||||
samples: None,
|
||||
generated_draft: None,
|
||||
config_export: None,
|
||||
wizard_state: None,
|
||||
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
updated_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_at: Some(OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn object_schema(field_name: &str) -> Schema {
|
||||
Schema {
|
||||
kind: SchemaKind::Object,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::from([(
|
||||
field_name.to_owned(),
|
||||
Schema {
|
||||
kind: SchemaKind::String,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::new(),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
},
|
||||
)]),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, Bytes, to_bytes},
|
||||
extract::State,
|
||||
http::{HeaderMap, Request, StatusCode, header},
|
||||
routing::post,
|
||||
};
|
||||
use crank_core::{InvocationSource, PlatformApiKeyScope};
|
||||
use crank_observability::{
|
||||
OtlpBatchConfig, OtlpTraceConfig, ServiceIdentity, build_tracer_provider,
|
||||
};
|
||||
use crank_registry::{ListInvocationLogsQuery, PublishRequest};
|
||||
use opentelemetry::global;
|
||||
use opentelemetry_proto::tonic::{
|
||||
collector::trace::v1::ExportTraceServiceRequest, common::v1::any_value, trace::v1::Span,
|
||||
};
|
||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||
use prost::Message;
|
||||
use serde_json::{Value, json};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
use tracing::instrument::WithSubscriber;
|
||||
use tracing_subscriber::{Layer, filter::filter_fn, layer::SubscriberExt};
|
||||
|
||||
use super::common::{
|
||||
build_test_app, create_platform_api_key, publish_agent_for_operation, test_operation,
|
||||
test_registry, test_workspace_id,
|
||||
};
|
||||
|
||||
const REMOTE_TRACE_ID: &str = "0af7651916cd43dd8448eb211c80319c";
|
||||
const REQUEST_ID: &str = "req_stage_end_to_end";
|
||||
const CANARY: &str = "dc-stage-canary-secret";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exports_real_tool_stages_without_sensitive_data() {
|
||||
global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
let (otlp_endpoint, request_rx, collector) = spawn_otlp_collector().await;
|
||||
let trace_config = OtlpTraceConfig::try_new(
|
||||
Some(otlp_endpoint),
|
||||
Some("http/protobuf".to_owned()),
|
||||
Duration::from_secs(2),
|
||||
OtlpBatchConfig::try_new(128, 128, Duration::from_secs(300), Duration::from_secs(2))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let identity = ServiceIdentity::try_new("mcp-server", "0.3.1", "integration-test").unwrap();
|
||||
let (provider, tracer) = build_tracer_provider(&identity, &trace_config)
|
||||
.unwrap()
|
||||
.expect("enabled OTLP provider");
|
||||
let subscriber = tracing_subscriber::registry().with(
|
||||
tracing_opentelemetry::layer()
|
||||
.with_tracer(tracer)
|
||||
.with_filter(filter_fn(|metadata| {
|
||||
metadata.is_span() && metadata.target() == "crank::trace"
|
||||
})),
|
||||
);
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
|
||||
let registry = test_registry().await;
|
||||
let observed_traceparent = Arc::new(Mutex::new(None));
|
||||
let upstream_base_url = spawn_upstream(Arc::clone(&observed_traceparent)).await;
|
||||
let operation = test_operation(&upstream_base_url, "stage_end_to_end");
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("test"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("test"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "stage-agent").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"stage-agent",
|
||||
"stage-key",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let app = build_test_app(registry.clone(), Duration::ZERO, None);
|
||||
|
||||
let call_result = async {
|
||||
let initialized = send_jsonrpc(
|
||||
app.clone(),
|
||||
&api_key,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25",
|
||||
"capabilities": {}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(initialized.status(), StatusCode::OK);
|
||||
let session_id = initialized
|
||||
.headers()
|
||||
.get("MCP-Session-Id")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let notification = send_jsonrpc(
|
||||
app.clone(),
|
||||
&api_key,
|
||||
Some(&session_id),
|
||||
None,
|
||||
None,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized",
|
||||
"params": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(notification.status(), StatusCode::ACCEPTED);
|
||||
|
||||
send_jsonrpc(
|
||||
app,
|
||||
&api_key,
|
||||
Some(&session_id),
|
||||
Some(REQUEST_ID),
|
||||
Some("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "stage_end_to_end",
|
||||
"arguments": { "email": CANARY }
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
.with_subscriber(dispatch)
|
||||
.await;
|
||||
|
||||
assert_eq!(call_result.status(), StatusCode::OK);
|
||||
let body = to_bytes(call_result.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(body["result"]["isError"], false);
|
||||
provider.force_flush().unwrap();
|
||||
let request = tokio::time::timeout(Duration::from_secs(2), request_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
collector.abort();
|
||||
assert_eq!(
|
||||
request
|
||||
.headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/x-protobuf")
|
||||
);
|
||||
assert!(
|
||||
!request
|
||||
.body
|
||||
.windows(CANARY.len())
|
||||
.any(|window| window == CANARY.as_bytes())
|
||||
);
|
||||
let export = ExportTraceServiceRequest::decode(request.body).unwrap();
|
||||
let spans = export
|
||||
.resource_spans
|
||||
.iter()
|
||||
.flat_map(|resource| &resource.scope_spans)
|
||||
.flat_map(|scope| &scope.spans)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let traceparent = observed_traceparent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.expect("upstream traceparent");
|
||||
assert_eq!(&traceparent[3..35], REMOTE_TRACE_ID);
|
||||
|
||||
let expected_trace_id = decode_trace_id(REMOTE_TRACE_ID);
|
||||
let trace_spans = spans
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|span| span.trace_id.as_slice() == expected_trace_id)
|
||||
.collect::<Vec<_>>();
|
||||
for expected in [
|
||||
"mcp.request",
|
||||
"mcp.rate_limit",
|
||||
"mcp.access.check",
|
||||
"mcp.catalog.load",
|
||||
"mcp.tools.resolve",
|
||||
"runtime.execute",
|
||||
"runtime.arguments.map",
|
||||
"upstream.http",
|
||||
"runtime.response.transform",
|
||||
"history.write",
|
||||
"db.query",
|
||||
] {
|
||||
assert!(
|
||||
trace_spans.iter().any(|span| span.name == expected),
|
||||
"missing span {expected}"
|
||||
);
|
||||
}
|
||||
assert!(!trace_spans.iter().any(|span| span.name == "approval.check"));
|
||||
assert!(
|
||||
!trace_spans
|
||||
.iter()
|
||||
.any(|span| span.name == "runtime.idempotency")
|
||||
);
|
||||
|
||||
let root = trace_spans
|
||||
.iter()
|
||||
.find(|span| span.name == "mcp.request")
|
||||
.expect("mcp root");
|
||||
let runtime = trace_spans
|
||||
.iter()
|
||||
.find(|span| span.name == "runtime.execute")
|
||||
.expect("runtime");
|
||||
assert!(!runtime.parent_span_id.is_empty());
|
||||
assert_eq!(runtime.parent_span_id, root.span_id);
|
||||
|
||||
let history = trace_spans
|
||||
.iter()
|
||||
.find(|span| span.name == "history.write")
|
||||
.expect("history write");
|
||||
let history_db = trace_spans
|
||||
.iter()
|
||||
.find(|span| {
|
||||
span.name == "db.query"
|
||||
&& string_attribute(span, "db.operation") == Some("invocation_history.write")
|
||||
})
|
||||
.expect("history PostgreSQL write");
|
||||
assert_eq!(history_db.parent_span_id, history.span_id);
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: Some(InvocationSource::AgentToolCall),
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: None,
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(logs.len(), 1);
|
||||
assert_eq!(logs[0].log.request_id.as_deref(), Some(REQUEST_ID));
|
||||
|
||||
provider.shutdown().unwrap();
|
||||
}
|
||||
|
||||
struct OtlpRequest {
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
}
|
||||
|
||||
async fn spawn_otlp_collector() -> (
|
||||
String,
|
||||
tokio::sync::oneshot::Receiver<OtlpRequest>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (request_tx, request_rx) = tokio::sync::oneshot::channel();
|
||||
let sender = Arc::new(Mutex::new(Some(request_tx)));
|
||||
let app = Router::new().route(
|
||||
"/v1/traces",
|
||||
post({
|
||||
let sender = Arc::clone(&sender);
|
||||
move |headers: HeaderMap, body: Bytes| {
|
||||
let sender = Arc::clone(&sender);
|
||||
async move {
|
||||
if let Some(sender) = sender.lock().unwrap().take() {
|
||||
let _ = sender.send(OtlpRequest { headers, body });
|
||||
}
|
||||
StatusCode::OK
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
let collector = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
(format!("http://{address}/v1/traces"), request_rx, collector)
|
||||
}
|
||||
|
||||
fn decode_trace_id(value: &str) -> [u8; 16] {
|
||||
let mut bytes = [0_u8; 16];
|
||||
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).unwrap();
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn string_attribute<'a>(span: &'a Span, key: &str) -> Option<&'a str> {
|
||||
span.attributes.iter().find_map(|attribute| {
|
||||
let value = attribute.value.as_ref()?.value.as_ref()?;
|
||||
(attribute.key == key)
|
||||
.then_some(value)
|
||||
.and_then(|value| match value {
|
||||
any_value::Value::StringValue(value) => Some(value.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_jsonrpc(
|
||||
app: Router,
|
||||
api_key: &str,
|
||||
session_id: Option<&str>,
|
||||
request_id: Option<&str>,
|
||||
traceparent: Option<&str>,
|
||||
payload: Value,
|
||||
) -> axum::response::Response {
|
||||
let mut request = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/default/stage-agent")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Protocol-Version", "2025-11-25");
|
||||
if let Some(session_id) = session_id {
|
||||
request = request.header("MCP-Session-Id", session_id);
|
||||
}
|
||||
if let Some(request_id) = request_id {
|
||||
request = request.header("x-request-id", request_id);
|
||||
}
|
||||
if let Some(traceparent) = traceparent {
|
||||
request = request.header("traceparent", traceparent);
|
||||
}
|
||||
app.oneshot(request.body(Body::from(payload.to_string())).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn spawn_upstream(observed: Arc<Mutex<Option<String>>>) -> String {
|
||||
async fn create_lead(
|
||||
State(observed): State<Arc<Mutex<Option<String>>>>,
|
||||
headers: HeaderMap,
|
||||
Json(_payload): Json<Value>,
|
||||
) -> Json<Value> {
|
||||
*observed.lock().unwrap() = headers
|
||||
.get("traceparent")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
Json(json!({ "id": "lead_123" }))
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/crm/leads", post(create_lead))
|
||||
.with_state(observed);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://{address}")
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{HeaderValue, Request, StatusCode},
|
||||
};
|
||||
use opentelemetry::{
|
||||
global,
|
||||
trace::{TraceId, TracerProvider as _},
|
||||
};
|
||||
use opentelemetry_sdk::{
|
||||
error::OTelSdkResult,
|
||||
propagation::TraceContextPropagator,
|
||||
trace::{SdkTracerProvider, SpanData, SpanExporter},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
use tracing::instrument::WithSubscriber;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
use super::common::{build_test_app, test_registry};
|
||||
|
||||
const REMOTE_TRACE_ID: &str = "0af7651916cd43dd8448eb211c80319c";
|
||||
static TRACING_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn covers_valid_invalid_and_absent_traceparent_on_mcp_boundary() {
|
||||
let _tracing_test_guard = TRACING_TEST_LOCK.lock().await;
|
||||
global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
let exported = Arc::new(Mutex::new(Vec::new()));
|
||||
let provider = SdkTracerProvider::builder()
|
||||
.with_simple_exporter(CapturingExporter(Arc::clone(&exported)))
|
||||
.build();
|
||||
let tracer = provider.tracer("mcp-request-context-test");
|
||||
let subscriber =
|
||||
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let app = build_test_app(test_registry().await, Duration::ZERO, None);
|
||||
|
||||
let (valid, invalid, absent) = async {
|
||||
let valid = send_health(
|
||||
app.clone(),
|
||||
Some("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"),
|
||||
Some("request-id-is-separate"),
|
||||
)
|
||||
.await;
|
||||
let invalid = send_health(
|
||||
app.clone(),
|
||||
Some("canary-invalid-traceparent"),
|
||||
Some("bad,value"),
|
||||
)
|
||||
.await;
|
||||
let absent = send_health(app, None, None).await;
|
||||
(valid, invalid, absent)
|
||||
}
|
||||
.with_subscriber(dispatch)
|
||||
.await;
|
||||
provider.force_flush().unwrap();
|
||||
|
||||
assert_eq!(valid.status, StatusCode::OK);
|
||||
assert_eq!(valid.request_id.as_deref(), Some("request-id-is-separate"));
|
||||
assert_eq!(invalid.status, StatusCode::OK);
|
||||
assert_eq!(absent.status, StatusCode::OK);
|
||||
assert!(valid.traceparent_response.is_none());
|
||||
assert!(invalid.traceparent_response.is_none());
|
||||
assert!(absent.traceparent_response.is_none());
|
||||
|
||||
let trace_ids: Vec<_> = exported
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|span| span.name.as_ref() == "mcp.request")
|
||||
.map(|span| span.span_context.trace_id())
|
||||
.collect();
|
||||
assert_eq!(trace_ids.len(), 3);
|
||||
assert_eq!(trace_ids[0].to_string(), REMOTE_TRACE_ID);
|
||||
assert_ne!(trace_ids[1], trace_ids[0]);
|
||||
assert_ne!(trace_ids[2], trace_ids[0]);
|
||||
assert_ne!(trace_ids[1], trace_ids[2]);
|
||||
assert!(!trace_ids.contains(&TraceId::INVALID));
|
||||
provider.shutdown().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replaces_multiple_request_id_headers_with_one_uuid_v7() {
|
||||
let _tracing_test_guard = TRACING_TEST_LOCK.lock().await;
|
||||
let app = build_test_app(test_registry().await, Duration::ZERO, None);
|
||||
let mut request = Request::builder()
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request
|
||||
.headers_mut()
|
||||
.append("x-request-id", HeaderValue::from_static("first-request-id"));
|
||||
request.headers_mut().append(
|
||||
"x-request-id",
|
||||
HeaderValue::from_static("second-request-id"),
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(request)
|
||||
.with_subscriber(tracing_subscriber::registry())
|
||||
.await
|
||||
.unwrap();
|
||||
let generated = response.headers()["x-request-id"].to_str().unwrap();
|
||||
|
||||
assert_ne!(generated, "first-request-id");
|
||||
assert_ne!(generated, "second-request-id");
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(generated).unwrap().get_version(),
|
||||
Some(uuid::Version::SortRand)
|
||||
);
|
||||
}
|
||||
|
||||
async fn send_health(
|
||||
app: axum::Router,
|
||||
traceparent: Option<&str>,
|
||||
request_id: Option<&str>,
|
||||
) -> ProbeResponse {
|
||||
let mut request = Request::builder().uri("/health");
|
||||
if let Some(traceparent) = traceparent {
|
||||
request = request.header("traceparent", traceparent);
|
||||
}
|
||||
if let Some(request_id) = request_id {
|
||||
request = request.header("x-request-id", request_id);
|
||||
}
|
||||
let response = app
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ProbeResponse {
|
||||
status: response.status(),
|
||||
request_id: response
|
||||
.headers()
|
||||
.get("x-request-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned),
|
||||
traceparent_response: response
|
||||
.headers()
|
||||
.get("traceparent")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
struct ProbeResponse {
|
||||
status: StatusCode,
|
||||
request_id: Option<String>,
|
||||
traceparent_response: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CapturingExporter(Arc<Mutex<Vec<SpanData>>>);
|
||||
|
||||
impl SpanExporter for CapturingExporter {
|
||||
async fn export(&self, batch: Vec<SpanData>) -> OTelSdkResult {
|
||||
self.0.lock().unwrap().extend(batch);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
use super::common::*;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crank_core::{
|
||||
PlatformApiKeyScope, ToolAccessMode, ToolGroup, ToolSearchSettings, ToolSelectionPolicy,
|
||||
};
|
||||
use crank_registry::PublishRequest;
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_mode_discovers_and_calls_tools_through_meta_tools() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let invoice = test_operation(&upstream_base_url, "create_invoice");
|
||||
let ticket = test_operation(&upstream_base_url, "create_support_ticket");
|
||||
for operation in [&invoice, &ticket] {
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
publish_agent_with_policy(
|
||||
®istry,
|
||||
"business-search",
|
||||
vec![
|
||||
binding_for_operation(&invoice),
|
||||
binding_for_operation(&ticket),
|
||||
],
|
||||
ToolSelectionPolicy {
|
||||
mode: ToolAccessMode::Search,
|
||||
groups: vec![
|
||||
ToolGroup {
|
||||
id: "finance".to_owned(),
|
||||
name: "Finance".to_owned(),
|
||||
description: "Invoices and payments".to_owned(),
|
||||
tool_names: vec![invoice.name.clone()],
|
||||
},
|
||||
ToolGroup {
|
||||
id: "support".to_owned(),
|
||||
name: "Support".to_owned(),
|
||||
description: "Customer support tickets".to_owned(),
|
||||
tool_names: vec![ticket.name.clone()],
|
||||
},
|
||||
],
|
||||
search: ToolSearchSettings { max_results: 5 },
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"business-search",
|
||||
"mcp-search",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "business-search");
|
||||
let session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let listed = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&session),
|
||||
json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
listed["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|tool| tool["name"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["search_tools", "call_tool"]
|
||||
);
|
||||
|
||||
let search = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&session),
|
||||
json!({
|
||||
"jsonrpc":"2.0","id":3,"method":"tools/call",
|
||||
"params":{"name":"search_tools","arguments":{"query":"invoice","group_ids":["finance"]}}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
search["result"]["structuredContent"]["tools"][0]["name"],
|
||||
"create_invoice"
|
||||
);
|
||||
assert_eq!(
|
||||
search["result"]["structuredContent"]["catalog_revision"],
|
||||
"agent-version-1"
|
||||
);
|
||||
|
||||
let stale_call = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&session),
|
||||
json!({
|
||||
"jsonrpc":"2.0","id":4,"method":"tools/call",
|
||||
"params":{"name":"call_tool","arguments":{
|
||||
"name":"create_invoice",
|
||||
"arguments":{"email":"user@example.com"},
|
||||
"catalog_revision":"agent-version-0"
|
||||
}}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(stale_call["result"]["isError"], true);
|
||||
assert_eq!(
|
||||
stale_call["result"]["structuredContent"]["error"]["code"],
|
||||
"catalog_revision_changed"
|
||||
);
|
||||
|
||||
let call = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&session),
|
||||
json!({
|
||||
"jsonrpc":"2.0","id":5,"method":"tools/call",
|
||||
"params":{"name":"call_tool","arguments":{
|
||||
"name":"create_invoice",
|
||||
"arguments":{"email":"user@example.com"},
|
||||
"catalog_revision":"agent-version-1"
|
||||
}}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(call["result"]["isError"], false);
|
||||
assert_eq!(call["result"]["structuredContent"]["id"], "lead_123");
|
||||
}
|
||||
@@ -1,964 +0,0 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
http::header,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
routing::{get, post},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
|
||||
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
|
||||
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolAccessMode, ToolDescription, ToolGroup,
|
||||
ToolSearchSettings, ToolSelectionPolicy, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::{
|
||||
CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry,
|
||||
PublishAgentRequest, PublishRequest, SaveAgentBindingsRequest,
|
||||
};
|
||||
use crank_runtime::{
|
||||
InMemoryCoordinationStateStore, RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor,
|
||||
SecretCrypto,
|
||||
};
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use futures_util::stream;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::sleep;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
use uuid::Version;
|
||||
|
||||
use crank_community_mcp::{
|
||||
auth::{CommunityMachineCredentialVerifier, SharedMachineCredentialVerifier},
|
||||
build_app,
|
||||
catalog::PublishedToolCatalog,
|
||||
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
|
||||
};
|
||||
use crank_observability::{
|
||||
ObservabilityConfig, RedactionLimits, ServiceIdentity, build_subscriber,
|
||||
};
|
||||
|
||||
fn test_workspace_id() -> WorkspaceId {
|
||||
WorkspaceId::new("ws_default")
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_workspace_slug() -> &'static str {
|
||||
"default"
|
||||
}
|
||||
|
||||
fn test_agent_id(agent_slug: &str) -> AgentId {
|
||||
AgentId::new(format!("agent_{agent_slug}"))
|
||||
}
|
||||
|
||||
fn build_test_app(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
) -> axum::Router {
|
||||
build_test_app_with_rate_limit(
|
||||
registry,
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_test_app_with_rate_limit(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
rate_limit_config: RequestRateLimitConfig,
|
||||
) -> axum::Router {
|
||||
build_test_app_with_store(
|
||||
registry,
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
rate_limit_config,
|
||||
std::sync::Arc::new(InMemorySessionStore::default()),
|
||||
std::sync::Arc::new(CommunityMachineCredentialVerifier),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_test_app_with_store(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
rate_limit_config: RequestRateLimitConfig,
|
||||
sessions: SharedSessionStore,
|
||||
credential_verifier: SharedMachineCredentialVerifier,
|
||||
) -> axum::Router {
|
||||
build_app(
|
||||
registry,
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
SecretCrypto::new("test-master-key").unwrap(),
|
||||
crank_runtime::community_with_outbound_policy(
|
||||
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
|
||||
)
|
||||
.build(),
|
||||
RequestRateLimiter::new(rate_limit_config),
|
||||
std::sync::Arc::new(InMemoryCoordinationStateStore::default()),
|
||||
sessions,
|
||||
credential_verifier,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initializes_lists_and_calls_published_tool_via_mcp() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_create_lead");
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-rest").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-rest",
|
||||
"mcp-rest",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-rest");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let tools = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let call_result = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_create_lead",
|
||||
"arguments": {
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(tools["result"]["tools"][0]["name"], "crm_create_lead");
|
||||
assert_eq!(
|
||||
call_result["result"]["structuredContent"],
|
||||
json!({ "id": "lead_123" })
|
||||
);
|
||||
assert_eq!(call_result["result"]["isError"], false);
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: Some(crank_core::InvocationSource::AgentToolCall),
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: None,
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(logs.len(), 1);
|
||||
assert_eq!(
|
||||
logs[0].log.source,
|
||||
crank_core::InvocationSource::AgentToolCall
|
||||
);
|
||||
assert_eq!(logs[0].log.status, crank_core::InvocationStatus::Ok);
|
||||
assert_eq!(logs[0].log.tool_name, "crm_create_lead");
|
||||
|
||||
let keys = registry
|
||||
.list_platform_api_keys(&test_workspace_id())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(keys[0].api_key.last_used_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_request_id_for_tool_call_invocations() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_request_id");
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-request-id").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-request-id",
|
||||
"mcp-request-id",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-request-id");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let response = post_jsonrpc_response(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
Some("req_test_123"),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_request_id",
|
||||
"arguments": {
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
response.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_test_123"
|
||||
);
|
||||
let call_result = response.json::<Value>().await.unwrap();
|
||||
assert_eq!(call_result["result"]["isError"], false);
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: Some(crank_core::InvocationSource::AgentToolCall),
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: None,
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(logs.len(), 1);
|
||||
assert_eq!(logs[0].log.request_id.as_deref(), Some("req_test_123"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generates_request_id_for_tool_call_responses_and_logs() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_generated_request_id");
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-generated-request-id").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-generated-request-id",
|
||||
"mcp-generated-request-id",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-generated-request-id");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let response = post_jsonrpc_response(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
None,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_generated_request_id",
|
||||
"arguments": {
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let request_id = response
|
||||
.headers()
|
||||
.get("x-request-id")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(&request_id).unwrap().get_version(),
|
||||
Some(Version::SortRand)
|
||||
);
|
||||
|
||||
let call_result = response.json::<Value>().await.unwrap();
|
||||
assert_eq!(call_result["result"]["isError"], false);
|
||||
|
||||
let logs = registry
|
||||
.list_invocation_logs(ListInvocationLogsQuery {
|
||||
workspace_id: &test_workspace_id(),
|
||||
level: None,
|
||||
search_text: None,
|
||||
source: Some(crank_core::InvocationSource::AgentToolCall),
|
||||
operation_id: Some(&operation.id),
|
||||
agent_id: None,
|
||||
created_after: None,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(logs.len(), 1);
|
||||
assert_eq!(logs[0].log.request_id.as_deref(), Some(request_id.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn emits_request_id_in_mcp_ingress_logs() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url, "crm_request_trace");
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-request-trace").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-request-trace",
|
||||
"mcp-request-trace",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = build_subscriber(
|
||||
ObservabilityConfig::new(
|
||||
ServiceIdentity::try_new("mcp-server", "test", "test").unwrap(),
|
||||
"info",
|
||||
RedactionLimits::default(),
|
||||
),
|
||||
writer.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-request-trace");
|
||||
|
||||
let response = post_jsonrpc_response(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
None,
|
||||
Some("req_mcp_trace_123"),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
response.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_mcp_trace_123"
|
||||
);
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let logs = writer.output();
|
||||
let event = logs
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.find(|event| event["event"] == "mcp.request.received")
|
||||
.unwrap();
|
||||
assert_eq!(event["service"], "mcp-server");
|
||||
assert_eq!(event["request_id"], "req_mcp_trace_123");
|
||||
assert_eq!(event["fields"]["agent_slug"], "sales-request-trace");
|
||||
assert_eq!(event["fields"]["workspace_slug"], "default");
|
||||
assert_eq!(event["fields"]["jsonrpc_method"], "initialize");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requires_initialized_notification_before_tool_methods() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-init", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-init",
|
||||
"mcp-init",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-init");
|
||||
let initialize_response = client
|
||||
.post(&mcp_url)
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let session_id = initialize_response
|
||||
.headers()
|
||||
.get("MCP-Session-Id")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let tools_list = client
|
||||
.post(&mcp_url)
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Session-Id", session_id)
|
||||
.header("MCP-Protocol-Version", "2025-11-25")
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tools_list["error"]["code"], -32002);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_can_return_sse_response_when_client_prefers_event_stream() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-sse-init", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-sse-init",
|
||||
"mcp-sse-init",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(agent_mcp_url(&base_url, "sales-sse-init"))
|
||||
.header(header::ACCEPT, "text/event-stream, application/json")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"text/event-stream"
|
||||
);
|
||||
assert!(response.headers().get("MCP-Session-Id").is_some());
|
||||
|
||||
let body = response.text().await.unwrap();
|
||||
assert!(body.contains("\"jsonrpc\":\"2.0\""));
|
||||
assert!(body.contains("\"protocolVersion\":\"2025-11-25\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_opens_sse_stream_for_initialized_session() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-get-sse", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-get-sse",
|
||||
"mcp-get-sse",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-get-sse");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let response = client
|
||||
.get(&mcp_url)
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Session-Id", &initialized_session)
|
||||
.header("MCP-Protocol-Version", "2025-11-25")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"text/event-stream"
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("MCP-Session-Id")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
initialized_session
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_returns_not_found_for_expired_transport_session() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-expired-session", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-expired-session",
|
||||
"mcp-expired-session",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let session_store = Arc::new(InMemorySessionStore::default());
|
||||
let session_id = session_store
|
||||
.create(
|
||||
"2025-11-25",
|
||||
test_workspace_slug(),
|
||||
"sales-expired-session",
|
||||
false,
|
||||
OffsetDateTime::parse("2026-05-01T10:00:00Z", &Rfc3339).unwrap(),
|
||||
Some(OffsetDateTime::parse("2026-05-01T10:00:01Z", &Rfc3339).unwrap()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
session_store
|
||||
.mark_initialized(
|
||||
&session_id,
|
||||
OffsetDateTime::parse("2026-05-01T10:00:01Z", &Rfc3339).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let base_url = spawn_mcp_server(build_test_app_with_store(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
||||
session_store,
|
||||
std::sync::Arc::new(CommunityMachineCredentialVerifier),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let response = client
|
||||
.get(agent_mcp_url(&base_url, "sales-expired-session"))
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Session-Id", &session_id)
|
||||
.header("MCP-Protocol-Version", "2025-11-25")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_rapid_transport_get_requests_with_429() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-get-rate-limit", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-get-rate-limit",
|
||||
"mcp-get-rate-limit",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app_with_rate_limit(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
RequestRateLimitConfig::new(1, 2).unwrap(),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-get-rate-limit");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let first_response = client
|
||||
.get(&mcp_url)
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Session-Id", &initialized_session)
|
||||
.header("MCP-Protocol-Version", "2025-11-25")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first_response.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let second_response = client
|
||||
.get(&mcp_url)
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Session-Id", &initialized_session)
|
||||
.header("MCP-Protocol-Version", "2025-11-25")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
second_response.status(),
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
assert!(second_response.headers().get(header::RETRY_AFTER).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_requires_session_header() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-get-sse-missing", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-get-sse-missing",
|
||||
"mcp-get-sse-missing",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let response = client
|
||||
.get(agent_mcp_url(&base_url, "sales-get-sse-missing"))
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("x-request-id", "req_early_mcp_error")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
response.headers()["x-request-id"].to_str().unwrap(),
|
||||
"req_early_mcp_error"
|
||||
);
|
||||
|
||||
let invalid_response = client
|
||||
.get(agent_mcp_url(&base_url, "sales-get-sse-missing"))
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("x-request-id", "bad,value")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let generated = invalid_response.headers()["x-request-id"].to_str().unwrap();
|
||||
assert_eq!(
|
||||
uuid::Uuid::parse_str(generated).unwrap().get_version(),
|
||||
Some(Version::SortRand)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_terminates_transport_session() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-delete-session", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-delete-session",
|
||||
"mcp-delete-session",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-delete-session");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let delete_response = client
|
||||
.delete(&mcp_url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Session-Id", &initialized_session)
|
||||
.header("MCP-Protocol-Version", "2025-11-25")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
let after_delete = client
|
||||
.get(&mcp_url)
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Session-Id", &initialized_session)
|
||||
.header("MCP-Protocol-Version", "2025-11-25")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(after_delete.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_accepts_json_only_response_negotiation() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-json-accept", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-json-accept",
|
||||
"mcp-json-accept",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(agent_mcp_url(&base_url, "sales-json-accept"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.json(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-11-25"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap(),
|
||||
"application/json"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_get_with_protocol_version_mismatch() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-get-bad-version", vec![]).await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"sales-get-bad-version",
|
||||
"mcp-get-bad-version",
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-get-bad-version");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let response = client
|
||||
.get(&mcp_url)
|
||||
.header(header::ACCEPT, "text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
|
||||
.header("MCP-Session-Id", &initialized_session)
|
||||
.header("MCP-Protocol-Version", "2025-06-18")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#[path = "integration/common.rs"]
|
||||
mod common;
|
||||
#[path = "integration/request_context.rs"]
|
||||
mod request_context;
|
||||
+1
-1
@@ -6,7 +6,7 @@ COPY apps/ui/package.json apps/ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY apps/ui ./
|
||||
COPY crank-community.png ./crank-community.png
|
||||
COPY Crank.png ./Crank.png
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
+11
-35
@@ -271,7 +271,6 @@
|
||||
.table-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.table-wrap thead tr {
|
||||
background: var(--bg-canvas);
|
||||
@@ -301,21 +300,6 @@
|
||||
.table-wrap tbody tr:last-child { border-bottom: none; }
|
||||
.table-wrap tbody tr:hover { background: var(--bg-overlay); }
|
||||
.table-wrap td { padding: 14px 16px; vertical-align: middle; }
|
||||
.table-wrap th:nth-child(2),
|
||||
.table-wrap td:nth-child(2) { width: 112px; }
|
||||
.table-wrap th:nth-child(3),
|
||||
.table-wrap td:nth-child(3) { width: 118px; }
|
||||
.table-wrap th:nth-child(4),
|
||||
.table-wrap td:nth-child(4) { width: 140px; }
|
||||
.table-wrap th:nth-child(5),
|
||||
.table-wrap td:nth-child(5) { width: 280px; }
|
||||
.table-wrap th:last-child,
|
||||
.table-wrap td:last-child {
|
||||
width: 96px;
|
||||
min-width: 96px;
|
||||
padding-left: 8px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.op-name { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.op-display { font-size: 12px; color: var(--text-muted); margin-top: 3px; }
|
||||
@@ -334,6 +318,9 @@
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.badge-rest { color: var(--blue); background: var(--blue-bg); border-color: var(--blue-border); }
|
||||
.badge-graphql { color: var(--purple); background: var(--purple-bg); border-color: var(--purple-border); }
|
||||
.badge-grpc { color: #14b8a6; background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); }
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -370,42 +357,31 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.target-url-cell span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.target-url-cell a { color: inherit; text-decoration: none; }
|
||||
.target-url-cell svg { flex-shrink: 0; opacity: 0.5; }
|
||||
|
||||
.date-cell { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
min-width: 74px;
|
||||
}
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; justify-content: flex-end; }
|
||||
.row-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
padding: 5px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.1s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 34px;
|
||||
}
|
||||
.row-btn:hover { border-color: var(--bg-muted); color: var(--text-primary); background: var(--bg-overlay); }
|
||||
.row-btn.danger:hover { border-color: var(--red-border); color: var(--red); background: var(--red-bg); }
|
||||
|
||||
.row-btn-edit {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/* Local font assets are copied from pinned @fontsource packages during the UI build. */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/inter-cyrillic-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/inter-latin-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/inter-cyrillic-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/inter-latin-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('../fonts/inter-cyrillic-600-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('../fonts/inter-latin-600-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/inter-cyrillic-700-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/inter-latin-700-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/jetbrains-mono-cyrillic-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/jetbrains-mono-latin-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/jetbrains-mono-cyrillic-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/jetbrains-mono-latin-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
+1
-39
@@ -114,10 +114,6 @@
|
||||
overflow: hidden;
|
||||
z-index: 200;
|
||||
}
|
||||
.user-dropdown[hidden],
|
||||
.mobile-nav[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.user-dropdown-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
@@ -154,40 +150,6 @@
|
||||
max-width: 1160px;
|
||||
margin: 0 auto;
|
||||
padding: 36px 40px 60px;
|
||||
animation: page-enter 0.18s ease-out both;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
body.page-leaving .page,
|
||||
body.page-leaving .wizard-body,
|
||||
body.page-leaving .ws-setup-body {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
@keyframes page-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
body.page-leaving .page,
|
||||
body.page-leaving .wizard-body,
|
||||
body.page-leaving .ws-setup-body {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Hamburger button (hidden on desktop) ── */
|
||||
@@ -246,7 +208,7 @@ body.page-leaving .ws-setup-body {
|
||||
.mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
/* ── Responsive breakpoints ── */
|
||||
@media (max-width: 980px) {
|
||||
@media (max-width: 720px) {
|
||||
.navbar { padding: 0 16px; }
|
||||
.nav-links { display: none; }
|
||||
.nav-hamburger { display: flex; }
|
||||
|
||||
@@ -197,6 +197,38 @@
|
||||
.login-divider-line { flex: 1; height: 1px; background: var(--border); }
|
||||
.login-divider-text { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
.btn-sso {
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.btn-sso:hover { background: var(--bg-muted); color: var(--text-primary); }
|
||||
|
||||
.btn-sso:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-sso:disabled:hover {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-inline-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -134,188 +134,3 @@
|
||||
}
|
||||
|
||||
.refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); }
|
||||
|
||||
.approval-panel {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.approval-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.approval-panel-title {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.approval-panel-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.approval-refresh-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.approval-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.approval-empty {
|
||||
padding: 20px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-canvas);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.approval-empty-error {
|
||||
border-color: rgba(248, 81, 73, 0.35);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.approval-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-canvas);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.approval-pending {
|
||||
border-color: rgba(210, 153, 34, 0.45);
|
||||
background: linear-gradient(180deg, rgba(210, 153, 34, 0.08), var(--bg-canvas) 46%);
|
||||
}
|
||||
|
||||
.approval-completed {
|
||||
border-color: rgba(63, 185, 80, 0.28);
|
||||
}
|
||||
|
||||
.approval-failed,
|
||||
.approval-denied,
|
||||
.approval-expired {
|
||||
border-color: rgba(248, 81, 73, 0.26);
|
||||
}
|
||||
|
||||
.approval-item-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approval-item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.approval-item-meta,
|
||||
.approval-timing,
|
||||
.approval-note {
|
||||
margin-top: 5px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.approval-item-body {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.approval-status {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.35px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.approval-status-pending {
|
||||
color: var(--amber);
|
||||
border-color: rgba(210, 153, 34, 0.45);
|
||||
background: rgba(210, 153, 34, 0.1);
|
||||
}
|
||||
|
||||
.approval-status-completed {
|
||||
color: var(--green);
|
||||
border-color: rgba(63, 185, 80, 0.35);
|
||||
background: rgba(63, 185, 80, 0.1);
|
||||
}
|
||||
|
||||
.approval-status-denied,
|
||||
.approval-status-expired,
|
||||
.approval-status-failed {
|
||||
color: var(--red);
|
||||
border-color: rgba(248, 81, 73, 0.35);
|
||||
background: rgba(248, 81, 73, 0.09);
|
||||
}
|
||||
|
||||
.approval-payload-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.approval-payload-label {
|
||||
margin-bottom: 5px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.45px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.approval-payload-code {
|
||||
margin: 0;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: #161b22;
|
||||
padding: 10px;
|
||||
color: #c9d1d9;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.approval-panel-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.approval-refresh-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.approval-item-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.approval-status {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-498
@@ -1,498 +0,0 @@
|
||||
.page-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.openapi-import-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.openapi-import-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2400;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 28px 16px;
|
||||
overflow: auto;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.openapi-import-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: rgba(1, 4, 9, 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.openapi-import-dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1040px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 56px);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: var(--bg-canvas);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.openapi-import-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 22px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
|
||||
.openapi-import-header h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.openapi-import-header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.openapi-import-body {
|
||||
overflow: auto;
|
||||
padding: 20px 24px 24px;
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
|
||||
.openapi-import-upload {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.openapi-file-label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#openapi-import-document {
|
||||
min-height: 132px;
|
||||
max-height: 34vh;
|
||||
resize: vertical;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: #0d1117;
|
||||
color: var(--text-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
#openapi-import-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.openapi-file-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.openapi-file-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.openapi-file-name {
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.openapi-import-actions,
|
||||
.openapi-import-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.openapi-import-status {
|
||||
margin-top: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.openapi-import-status.error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.openapi-import-preview {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.openapi-import-source,
|
||||
.openapi-import-group {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.openapi-import-source {
|
||||
padding: 14px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.openapi-import-server-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.openapi-import-server-row label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#openapi-import-server,
|
||||
#openapi-import-conflict-mode,
|
||||
#openapi-import-search,
|
||||
#openapi-import-method-filter,
|
||||
.openapi-import-server-custom {
|
||||
max-width: 520px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.openapi-import-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(160px, 220px) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.openapi-import-filter {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.openapi-import-filter label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#openapi-import-search,
|
||||
#openapi-import-method-filter {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.openapi-import-bulk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.openapi-import-groups {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.openapi-import-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-group-count {
|
||||
margin-left: auto;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.openapi-import-group-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.openapi-import-operation {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.openapi-import-operation:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.openapi-import-operation-title {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-operation-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.openapi-import-method {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-glow);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-findings {
|
||||
grid-column: 2 / -1;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.openapi-import-finding {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
color: var(--amber);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.openapi-import-finding-info {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.openapi-import-finding-error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.openapi-import-finding strong {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.openapi-import-mapping-preview {
|
||||
grid-column: 2 / -1;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.openapi-import-mapping-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.openapi-import-mapping-label {
|
||||
min-width: 52px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.openapi-import-mapping-chip {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.openapi-import-mapping-more {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-document-findings,
|
||||
.openapi-import-result {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.openapi-import-result strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.openapi-import-primary-result {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.openapi-import-primary-result .btn-primary {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.openapi-import-result-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.openapi-import-result-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.9fr) minmax(260px, 1.5fr) auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.openapi-import-result-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.openapi-import-result-head {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.openapi-import-result-name {
|
||||
color: var(--text-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-result-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.openapi-import-result-findings {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.openapi-import-result-ok {
|
||||
color: var(--green);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-result-more {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-result-action {
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.openapi-import-result-skipped {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.openapi-import-created-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.openapi-import-created-list a {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.openapi-import-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.openapi-import-bulk-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.openapi-import-operation {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.openapi-import-result-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.openapi-import-method {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
+103
-186
@@ -3,10 +3,6 @@
|
||||
Used by: api-keys, logs, usage, settings
|
||||
══════════════════════════════════════════════════ */
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Dropdown divider (used in navbar and elsewhere) ── */
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
@@ -376,12 +372,6 @@
|
||||
|
||||
.data-table .col-actions { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
|
||||
.data-table .table-action-btn {
|
||||
min-height: 30px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
BADGES
|
||||
══════════════════════════════════════════════════ */
|
||||
@@ -767,10 +757,7 @@
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: calc(100dvh - 48px);
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -804,8 +791,8 @@
|
||||
}
|
||||
.modal-close:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
|
||||
.modal-body { padding: 20px; overflow: auto; }
|
||||
.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: flex-end; gap: 8px; flex-shrink: 0; }
|
||||
.modal-body { padding: 20px; }
|
||||
.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
RESPONSIVE
|
||||
@@ -936,6 +923,81 @@
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.ws-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
min-width: 230px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.45);
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ws-dropdown-label {
|
||||
padding: 10px 14px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ws-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ws-dropdown-item:hover { background: rgba(255,255,255,0.04); }
|
||||
.ws-dropdown-item.active { background: rgba(56,139,253,0.08); }
|
||||
|
||||
.ws-item-dot {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-item-info { flex: 1; min-width: 0; }
|
||||
.ws-item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ws-item-role { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
.ws-dropdown-footer { border-top: 1px solid var(--border-subtle); padding: 6px 8px; }
|
||||
.ws-dropdown-create {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.ws-dropdown-create:hover { background: rgba(255,255,255,0.04); color: var(--text-primary); }
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Agents page — info callout
|
||||
═══════════════════════════════════════════════════ */
|
||||
@@ -1403,177 +1465,6 @@
|
||||
.agents-rec-callout svg { flex-shrink: 0; color: #d2991f; margin-top: 1px; }
|
||||
.agents-rec-callout strong { color: var(--text-primary); }
|
||||
|
||||
.tool-access-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.tool-access-option {
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-canvas);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-access-option:hover { border-color: var(--border-muted, #444c56); }
|
||||
.tool-access-option.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(45, 212, 191, 0.07);
|
||||
}
|
||||
.tool-access-option-title {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.tool-access-option.active .tool-access-option-title { color: var(--accent); }
|
||||
.tool-access-option-body {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tool-search-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tool-search-config-header,
|
||||
.tool-group-card-header,
|
||||
.tool-search-preview-controls,
|
||||
.tool-search-result,
|
||||
.tool-group-assignment-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.tool-search-config-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tool-search-config-header .drawer-section-sub { margin: 3px 0 0; }
|
||||
.tool-search-config-header .btn-ghost-sm { white-space: nowrap; }
|
||||
.tool-group-empty {
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 7px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tool-group-card,
|
||||
.tool-search-preview {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
.tool-group-card-header { margin-bottom: 10px; }
|
||||
.tool-group-card-header strong { font-size: 12px; color: var(--text-primary); }
|
||||
.tool-group-card-header .agent-action-btn img { width: 13px; height: 13px; }
|
||||
.tool-group-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.tool-group-fields label,
|
||||
.tool-group-description,
|
||||
.tool-search-limit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
.tool-group-description { margin-top: 9px; }
|
||||
.tool-group-assignments {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-group-assignments > .tool-search-config-title {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
.tool-group-assignment-row {
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tool-group-assignment-row:last-child { border-bottom: 0; }
|
||||
.tool-group-assignment-tool { min-width: 130px; }
|
||||
.tool-group-assignment-tool strong,
|
||||
.tool-group-assignment-tool code,
|
||||
.tool-search-result strong,
|
||||
.tool-search-result code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool-group-assignment-tool strong,
|
||||
.tool-search-result strong { color: var(--text-primary); font-size: 11px; }
|
||||
.tool-group-assignment-tool code,
|
||||
.tool-search-result code { margin-top: 3px; color: var(--text-muted); font-size: 10px; }
|
||||
.tool-group-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
.tool-group-chip {
|
||||
max-width: 150px;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-group-chip.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(45, 212, 191, 0.08);
|
||||
color: var(--accent);
|
||||
}
|
||||
.tool-search-limit { max-width: 190px; }
|
||||
.tool-search-preview .drawer-section-sub { margin-top: 4px; }
|
||||
.tool-search-preview-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 132px auto;
|
||||
}
|
||||
.tool-search-results {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tool-search-result { padding: 9px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
.tool-search-result:last-child { border-bottom: 0; }
|
||||
.tool-search-result > div { min-width: 0; }
|
||||
.tool-search-result > span { color: var(--text-muted); font-size: 10px; }
|
||||
.tool-search-preview > .tool-group-empty { margin-top: 10px; }
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.tool-access-options,
|
||||
.tool-group-fields,
|
||||
.tool-search-preview-controls { grid-template-columns: 1fr; }
|
||||
.tool-group-assignment-row { flex-direction: column; }
|
||||
.tool-group-chips { justify-content: flex-start; }
|
||||
.tool-search-limit { max-width: none; }
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Settings — Members enhanced
|
||||
@@ -1657,6 +1548,32 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Workspace dropdown management links ── */
|
||||
.ws-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.ws-dropdown-mgmt-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ws-dropdown-mgmt-link:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ws-dropdown-mgmt-link svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.toast-stack {
|
||||
top: 72px;
|
||||
|
||||
+437
-451
File diff suppressed because it is too large
Load Diff
@@ -37,11 +37,7 @@
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.ws-setup-back:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); }
|
||||
@@ -51,8 +47,6 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 24px 80px;
|
||||
animation: page-enter 0.18s ease-out both;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
.ws-setup-container {
|
||||
width: 100%;
|
||||
@@ -111,7 +105,6 @@
|
||||
.ws-color-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
|
||||
@@ -21,6 +21,17 @@
|
||||
"target_url": "https://api.acme.com/v1/users/{id}",
|
||||
"created_at": "2026-03-18"
|
||||
},
|
||||
{
|
||||
"id": "op_03",
|
||||
"name": "search_knowledge_base",
|
||||
"display_name": "Search Knowledge Base",
|
||||
"protocol": "graphql",
|
||||
"method": null,
|
||||
"status": "active",
|
||||
"category": "Search",
|
||||
"target_url": "https://graph.notion-int.acme.com/graphql",
|
||||
"created_at": "2026-03-14"
|
||||
},
|
||||
{
|
||||
"id": "op_04",
|
||||
"name": "send_slack_message",
|
||||
@@ -32,6 +43,17 @@
|
||||
"target_url": "https://slack.com/api/chat.postMessage",
|
||||
"created_at": "2026-03-10"
|
||||
},
|
||||
{
|
||||
"id": "op_05",
|
||||
"name": "stream_telemetry",
|
||||
"display_name": "Stream Telemetry Events",
|
||||
"protocol": "grpc",
|
||||
"method": null,
|
||||
"status": "draft",
|
||||
"category": "Observability",
|
||||
"target_url": "grpc://telemetry.internal.acme.com:9090",
|
||||
"created_at": "2026-03-05"
|
||||
},
|
||||
{
|
||||
"id": "op_06",
|
||||
"name": "update_deal_stage",
|
||||
@@ -43,6 +65,28 @@
|
||||
"target_url": "https://api.acme.com/v2/crm/deals/{id}",
|
||||
"created_at": "2026-02-28"
|
||||
},
|
||||
{
|
||||
"id": "op_07",
|
||||
"name": "fetch_invoice",
|
||||
"display_name": "Fetch Invoice",
|
||||
"protocol": "rest",
|
||||
"method": "GET",
|
||||
"status": "active",
|
||||
"category": "Billing",
|
||||
"target_url": "https://billing.acme.com/v1/invoices/{id}",
|
||||
"created_at": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"id": "op_08",
|
||||
"name": "list_products",
|
||||
"display_name": "List Products",
|
||||
"protocol": "graphql",
|
||||
"method": null,
|
||||
"status": "active",
|
||||
"category": "Catalog",
|
||||
"target_url": "https://shop.acme.com/graphql",
|
||||
"created_at": "2026-02-15"
|
||||
},
|
||||
{
|
||||
"id": "op_09",
|
||||
"name": "create_support_ticket",
|
||||
@@ -65,6 +109,17 @@
|
||||
"target_url": "https://orders.acme.com/v2/status/{id}",
|
||||
"created_at": "2026-02-05"
|
||||
},
|
||||
{
|
||||
"id": "op_11",
|
||||
"name": "sync_contacts",
|
||||
"display_name": "Sync Contacts",
|
||||
"protocol": "grpc",
|
||||
"method": null,
|
||||
"status": "active",
|
||||
"category": "CRM",
|
||||
"target_url": "grpc://contacts.internal.acme.com:9091",
|
||||
"created_at": "2026-01-28"
|
||||
},
|
||||
{
|
||||
"id": "op_12",
|
||||
"name": "send_email_campaign",
|
||||
|
||||
+29
-124
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Agents</title>
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -22,11 +22,22 @@
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Single workspace indicator -->
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<div class="ws-switcher-trigger">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +52,9 @@
|
||||
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu" @click.stop>
|
||||
<div class="nav-avatar" @click="openDropdown = openDropdown === 'user' ? null : 'user'" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
@@ -49,12 +63,12 @@
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role" id="user-ws-role">—</div>
|
||||
</div>
|
||||
<a class="user-dropdown-item" href="/settings">
|
||||
<button class="user-dropdown-item" onclick="window.location.href='/settings'">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</a>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" @click="window.CrankAuth.logout()">
|
||||
<button class="user-dropdown-item danger" onclick="window.CrankAuth.logout()">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
@@ -79,7 +93,7 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-heading" data-i18n="agents.title">Agents</h1>
|
||||
<p class="page-subheading" x-text="subtitleText()">Group your MCP tools by AI agent.</p>
|
||||
<p class="page-subheading" x-text="subtitleText()">Named MCP endpoints that expose a curated subset of operations to an LLM</p>
|
||||
</div>
|
||||
<button class="btn-new" @click="openCreate()">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
@@ -133,7 +147,7 @@
|
||||
<!-- Info callout -->
|
||||
<div class="agents-info-callout">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 11V8M8 5v-.5"/></svg>
|
||||
<span x-text="agentCalloutText()">Group MCP tools around concrete tasks for a concrete agent. Do not give one LLM too many tools at once: it can increase mistakes and hallucinations. Each agent has its own API keys.</span>
|
||||
<span x-text="agentCalloutText()">Each agent gets its own MCP endpoint.</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
@@ -154,7 +168,7 @@
|
||||
</div>
|
||||
<div class="empty-state-title" data-i18n="agents.empty.initial.title">No agents yet</div>
|
||||
<div class="empty-state-sub" x-text="emptyStateText()">Create your first agent to get a dedicated MCP endpoint with a curated set of tools.</div>
|
||||
<div class="empty-state-sub" style="margin-top:8px;" x-text="tKey('agents.empty.initial.next_step')">After creating an agent, issue an API key for it on the API Keys page.</div>
|
||||
<div class="empty-state-sub" style="margin-top:8px;" x-text="tKey('agents.empty.initial.next_step')">After creation, issue separate machine-access keys for this endpoint on the API Keys page.</div>
|
||||
<button class="btn-new" @click="openCreate()" style="margin-top: 16px;">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="agents.new">New agent</span>
|
||||
@@ -284,12 +298,12 @@
|
||||
<div class="form-hint" x-show="form.slug">
|
||||
<span data-i18n="agents.drawer.endpoint">MCP endpoint</span>: <code style="font-family:monospace;font-size:11px;color:var(--accent)" x-text="'/mcp/v1/' + (localStorage.getItem('crank_workspace_slug') || 'default') + '/' + form.slug"></code>
|
||||
</div>
|
||||
<div class="form-hint" data-i18n="agents.drawer.slug_hint">Slug is used as part of the endpoint to identify the agent.</div>
|
||||
<div class="form-hint" data-i18n="agents.drawer.slug_hint">Keep the slug stable after clients start using this endpoint. Changing it changes the MCP path.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 14px;">
|
||||
<label class="form-label"><span data-i18n="agents.drawer.description">Description</span> <span class="form-label-optional" data-i18n="agents.drawer.optional">(optional)</span></label>
|
||||
<textarea class="form-textarea" rows="3" data-i18n-ph="agents.drawer.placeholder.description" placeholder="What does this agent do? What scenario is it for?"
|
||||
<textarea class="form-textarea" rows="3" data-i18n-ph="agents.drawer.placeholder.description" placeholder="What does this agent do? What LLM or use-case is it for?"
|
||||
x-model="form.description"></textarea>
|
||||
</div>
|
||||
|
||||
@@ -315,18 +329,7 @@
|
||||
<div class="drawer-section-title" data-i18n="agents.drawer.operations">Operations</div>
|
||||
<span class="drawer-section-count" x-text="tfKey('agents.drawer.selected', { count: form.selectedOps.length })"></span>
|
||||
</div>
|
||||
<div class="drawer-section-sub" x-text="operationsSubText()">Select the MCP tools available to this agent.</div>
|
||||
<div class="agents-rec-callout" x-show="agentToolFindings.length > 0" style="display:none; margin-bottom: 12px;">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/><path d="M8 4.5v4M8 11.5v.2"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong data-i18n="agents.drawer.finding.title">Recommendation</strong>
|
||||
<template x-for="finding in agentToolFindings" :key="finding">
|
||||
<div x-text="finding"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-section-sub" x-text="operationsSubText()">Select which operations this agent exposes. LLMs connecting to this agent will only see these tools.</div>
|
||||
|
||||
<div class="ops-picker">
|
||||
<!-- Search -->
|
||||
@@ -359,122 +362,24 @@
|
||||
<!-- Footer count -->
|
||||
<div class="ops-picker-footer" x-show="form.selectedOps.length > 0">
|
||||
<span x-text="tfKey('agents.drawer.ops_selected', { count: form.selectedOps.length })"></span>
|
||||
<button @click="clearSelectedOperations()" style="background:none;border:none;color:var(--text-muted);font-size:12px;cursor:pointer;margin-left:8px;" data-i18n="agents.drawer.clear_all">Clear all</button>
|
||||
<button @click="form.selectedOps = []" style="background:none;border:none;color:var(--text-muted);font-size:12px;cursor:pointer;margin-left:8px;" data-i18n="agents.drawer.clear_all">Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommendation callout -->
|
||||
<div class="agents-rec-callout" x-show="form.selectedOps.length > 15 && form.accessMode === 'direct'">
|
||||
<div class="agents-rec-callout" x-show="form.selectedOps.length > 15">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polygon points="8,1 15,14 1,14" fill="none"/><path d="M8 6v4M8 11.5v.5"/></svg>
|
||||
<span x-text="tfKey('agents.drawer.recommendation', { count: form.selectedOps.length })">You've selected tools.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-section-title" data-i18n="agents.drawer.access.title">Tool access</div>
|
||||
<div class="drawer-section-sub" data-i18n="agents.drawer.access.subtitle">Choose how the model receives this agent's tool catalog.</div>
|
||||
|
||||
<div class="tool-access-options">
|
||||
<button class="tool-access-option" :class="{ active: form.accessMode === 'direct' }" @click="setAccessMode('direct')">
|
||||
<span class="tool-access-option-title" data-i18n="agents.drawer.access.direct">Show tools immediately</span>
|
||||
<span class="tool-access-option-body" data-i18n="agents.drawer.access.direct_hint">Best for a small curated catalog. MCP clients receive every tool in tools/list.</span>
|
||||
</button>
|
||||
<button class="tool-access-option" :class="{ active: form.accessMode === 'search' }" @click="setAccessMode('search')">
|
||||
<span class="tool-access-option-title" data-i18n="agents.drawer.access.search">Select tools on demand</span>
|
||||
<span class="tool-access-option-body" data-i18n="agents.drawer.access.search_hint">The model sees search_tools and call_tool, then discovers only relevant schemas.</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-search-config" x-show="form.accessMode === 'search'" style="display:none">
|
||||
<div class="tool-search-config-header">
|
||||
<div>
|
||||
<div class="tool-search-config-title" data-i18n="agents.drawer.groups.title">Catalog sections</div>
|
||||
<div class="drawer-section-sub" data-i18n="agents.drawer.groups.subtitle">Sections help the model narrow a search without blocking catalog-wide discovery.</div>
|
||||
</div>
|
||||
<button class="btn-ghost-sm" @click="addToolGroup()" data-i18n="agents.drawer.groups.add">Add section</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-group-empty" x-show="form.groups.length === 0" data-i18n="agents.drawer.groups.empty">No sections yet. Search will use the entire selected catalog.</div>
|
||||
<template x-for="(group, groupIndex) in form.groups" :key="groupIndex">
|
||||
<div class="tool-group-card">
|
||||
<div class="tool-group-card-header">
|
||||
<strong x-text="group.name || tKey('agents.drawer.groups.untitled')"></strong>
|
||||
<button class="agent-action-btn danger" @click="removeToolGroup(groupIndex)" :title="tKey('agents.drawer.groups.remove')">
|
||||
<img src="/icons/general/trash.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-group-fields">
|
||||
<label>
|
||||
<span data-i18n="agents.drawer.groups.name">Name</span>
|
||||
<input class="form-input" type="text" :value="group.name" @input="onToolGroupName(groupIndex, $event.target.value)" data-i18n-ph="agents.drawer.groups.name_placeholder" placeholder="Finance">
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="agents.drawer.groups.id">Identifier</span>
|
||||
<input class="form-input input-mono" type="text" :value="group.id" @input="onToolGroupId(groupIndex, $event.target.value)" placeholder="finance">
|
||||
</label>
|
||||
</div>
|
||||
<label class="tool-group-description">
|
||||
<span data-i18n="agents.drawer.groups.description">Description for the model</span>
|
||||
<textarea class="form-textarea" rows="2" x-model="group.description" @input="resetSearchPreview()" data-i18n-ph="agents.drawer.groups.description_placeholder" placeholder="Invoices, payments and refunds"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="tool-group-assignments" x-show="form.groups.length > 0 && selectedOperations.length > 0">
|
||||
<div class="tool-search-config-title" data-i18n="agents.drawer.groups.assign">Assign tools to sections</div>
|
||||
<template x-for="operation in selectedOperations" :key="operation.id">
|
||||
<div class="tool-group-assignment-row">
|
||||
<div class="tool-group-assignment-tool">
|
||||
<strong x-text="operation.display_name || operation.name"></strong>
|
||||
<code x-text="operation.name"></code>
|
||||
</div>
|
||||
<div class="tool-group-chips">
|
||||
<template x-for="(group, groupIndex) in form.groups" :key="groupIndex">
|
||||
<button class="tool-group-chip" :class="{ active: toolInGroup(groupIndex, operation.name) }" @click="toggleToolGroup(groupIndex, operation.name)" x-text="group.name || group.id || '—'"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<label class="tool-search-limit">
|
||||
<span data-i18n="agents.drawer.search.limit">Maximum results per search</span>
|
||||
<input class="form-input" type="number" min="1" max="20" x-model.number="form.searchMaxResults" @input="resetSearchPreview()">
|
||||
</label>
|
||||
|
||||
<div class="tool-search-preview">
|
||||
<div class="tool-search-config-title" data-i18n="agents.drawer.search.preview_title">Test tool selection</div>
|
||||
<div class="drawer-section-sub" data-i18n="agents.drawer.search.preview_subtitle">Enter a task and verify which tools the model will receive.</div>
|
||||
<div class="tool-search-preview-controls">
|
||||
<input class="form-input" type="text" x-model="searchPreviewQuery" @keydown.enter.prevent="previewToolSearch()" data-i18n-ph="agents.drawer.search.query_placeholder" placeholder="Create an invoice for a customer">
|
||||
<select class="form-select" x-model="searchPreviewGroup">
|
||||
<option value="" data-i18n="agents.drawer.search.all_groups">All sections</option>
|
||||
<template x-for="group in form.groups" :key="group.id">
|
||||
<option :value="group.id" x-text="group.name || group.id"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button class="btn-primary-sm" :disabled="searchPreviewLoading || !searchPreviewQuery.trim() || !catalogConfigValid" @click="previewToolSearch()" x-text="searchPreviewLoading ? tKey('agents.drawer.search.testing') : tKey('agents.drawer.search.test')">Test</button>
|
||||
</div>
|
||||
<div class="tool-search-results" x-show="searchPreviewItems.length > 0">
|
||||
<template x-for="item in searchPreviewItems" :key="item.tool.name">
|
||||
<div class="tool-search-result">
|
||||
<div><strong x-text="item.tool.title"></strong><code x-text="item.tool.name"></code></div>
|
||||
<span x-text="item.tool.group_ids.join(', ')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="tool-group-empty" x-show="searchPreviewRan && !searchPreviewLoading && searchPreviewItems.length === 0" data-i18n="agents.drawer.search.no_results">No tools matched this task.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /drawer-body -->
|
||||
|
||||
<!-- Drawer footer -->
|
||||
<div class="drawer-footer">
|
||||
<button class="btn-ghost-sm" style="padding: 8px 16px; font-size: 13px;" @click="closeDrawer()" data-i18n="agents.drawer.cancel">Cancel</button>
|
||||
<button class="btn-primary-sm" style="padding: 8px 20px; font-size: 13px;"
|
||||
:disabled="!form.display_name.trim() || !form.slug.trim() || !catalogConfigValid"
|
||||
:disabled="!form.display_name.trim() || !form.slug.trim()"
|
||||
@click="saveAgent()"
|
||||
x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')">
|
||||
Create agent
|
||||
|
||||
+71
-95
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Agent Keys</title>
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -23,75 +23,9 @@
|
||||
.scope-checkbox-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.scope-checkbox-desc { font-size: 11.5px; color: var(--text-muted); }
|
||||
.keys-card-list { display: none; }
|
||||
.key-kind-tabs {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
.key-kind-tab {
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.key-kind-tab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.key-kind-header-control {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
.key-kind-header-hint {
|
||||
grid-column: 1 / -1;
|
||||
max-width: 520px;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.api-keys-page-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.api-keys-page-header .page-header-text {
|
||||
max-width: 680px;
|
||||
}
|
||||
.api-keys-page-header .page-header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.approval-warning-callout {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--amber-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--amber-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.approval-warning-callout svg {
|
||||
color: var(--amber);
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
#keys-table-wrap { display: none; }
|
||||
.keys-card-list { display: grid; }
|
||||
.key-kind-header-control { grid-template-columns: 1fr; justify-content: stretch; width: 100%; }
|
||||
.key-kind-tabs { width: 100%; }
|
||||
.key-kind-tab { flex: 1; }
|
||||
.key-kind-header-hint { text-align: left; }
|
||||
}
|
||||
</style>
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
@@ -105,11 +39,22 @@
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Single workspace indicator -->
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<div class="ws-switcher-trigger">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,6 +70,11 @@
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
|
||||
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
|
||||
<div class="user-menu">
|
||||
@@ -160,23 +110,16 @@
|
||||
<!-- ═══════════════════ PAGE ═══════════════════ -->
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header api-keys-page-header">
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="apikeys.title">Agent Keys</h1>
|
||||
<p class="page-subtitle" data-i18n="apikeys.subtitle">These keys connect an MCP client to the MCP server and are issued for a specific agent.</p>
|
||||
<p class="page-subtitle" data-i18n="apikeys.subtitle">Keys authenticate external MCP clients against a specific AI agent endpoint.</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<div class="key-kind-header-control">
|
||||
<div class="key-kind-tabs" role="tablist" aria-label="Key type">
|
||||
<button class="key-kind-tab active" id="key-kind-mcp-client" type="button" data-key-kind="mcp_client" data-i18n="apikeys.kind.mcp">MCP clients</button>
|
||||
<button class="key-kind-tab" id="key-kind-approval" type="button" data-key-kind="approval" data-i18n="apikeys.kind.approval">Approvals</button>
|
||||
</div>
|
||||
<button class="btn-primary" id="btn-create-key" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||
<span id="btn-create-key-label" data-i18n="apikeys.new">Create key</span>
|
||||
</button>
|
||||
<div class="field-hint key-kind-header-hint" id="key-kind-hint"></div>
|
||||
</div>
|
||||
<button class="btn-primary" id="btn-create-key" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||
<span data-i18n="apikeys.new">Create key</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -194,8 +137,8 @@
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="apikeys.agent.title">Agent selection</div>
|
||||
<div class="section-card-subtitle" id="agent-access-subtitle" data-i18n="apikeys.agent.subtitle">Select the AI agent this key is issued for.</div>
|
||||
<div class="section-card-title" data-i18n="apikeys.agent.title">Agent access</div>
|
||||
<div class="section-card-subtitle" id="agent-access-subtitle" data-i18n="apikeys.agent.subtitle">Select the AI agent whose MCP endpoint should accept this key.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="display:grid;gap:12px;">
|
||||
@@ -207,6 +150,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" id="machine-access-title" data-i18n="apikeys.machine_access.title">Machine access modes</div>
|
||||
<div class="section-card-subtitle" id="machine-access-subtitle" data-i18n="apikeys.machine_access.subtitle">This build exposes the stable machine-access contract and shows which modes are available now.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="display:grid;gap:10px;">
|
||||
<div style="font-size:13px;color:var(--text-secondary);line-height:1.65;" id="machine-access-summary" data-testid="machine-access-summary">
|
||||
Community currently supports static AI-agent keys.
|
||||
</div>
|
||||
<div class="callout info">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div id="machine-access-note" data-i18n="apikeys.machine_access.note">Short-lived and one-time token issuance uses the same public HTTP surface, but requires a commercial edition.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
@@ -225,7 +189,7 @@
|
||||
<tr>
|
||||
<th data-i18n="apikeys.th.name">Name</th>
|
||||
<th data-i18n="apikeys.th.prefix">Key prefix</th>
|
||||
<th data-i18n="apikeys.th.scopes">Access</th>
|
||||
<th data-i18n="apikeys.th.scopes">Scopes</th>
|
||||
<th data-i18n="apikeys.th.created">Created</th>
|
||||
<th data-i18n="apikeys.th.last">Last used</th>
|
||||
<th data-i18n="apikeys.th.status">Status</th>
|
||||
@@ -241,9 +205,27 @@
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="apikeys.scope_ref">Access reference</div>
|
||||
<div class="section-card-title" data-i18n="apikeys.scope_ref">Scope reference</div>
|
||||
</div>
|
||||
<div class="section-card-body" id="scope-reference-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
|
||||
<div class="section-card-body" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
|
||||
<div>
|
||||
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;display:flex;align-items:center;gap:6px;">
|
||||
<span class="badge badge-scope">read</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.read">Initialize MCP sessions, ping the server, and list tools for a workspace agent.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
|
||||
<span class="badge badge-scope">write</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.write">Execute `tools/call` requests against published agent toolsets.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
|
||||
<span class="badge badge-scope">deploy</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.deploy">Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -253,7 +235,7 @@
|
||||
<div class="modal-overlay" id="modal-create">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modal-create-title" data-i18n="apikeys.modal.title">Create agent key</span>
|
||||
<span class="modal-title" data-i18n="apikeys.modal.title">Create agent key</span>
|
||||
<button class="modal-close" id="modal-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
@@ -266,14 +248,8 @@
|
||||
<input class="field-input" id="new-key-name" type="text" data-i18n-ph="apikeys.modal.name_placeholder" placeholder="e.g. Production, CI pipeline" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="apikeys.modal.name_hint">A descriptive label to identify the key. Only visible to admins.</div>
|
||||
</div>
|
||||
<div class="approval-warning-callout" id="approval-key-warning" hidden>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
|
||||
</svg>
|
||||
<div data-i18n="apikeys.approval.warning">Do not pass this key to an LLM or MCP client. It is only for an external interface where a human confirms an action.</div>
|
||||
</div>
|
||||
<div class="field-group" style="margin-bottom:0;">
|
||||
<label class="field-label" data-i18n="apikeys.modal.scopes">Access</label>
|
||||
<label class="field-label" data-i18n="apikeys.modal.scopes">Scopes</label>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;margin-top:2px;" id="scope-checkboxes">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<div class="field-group" style="margin-top:8px;">
|
||||
<label class="field-label">Interface language</label>
|
||||
<div class="lang-switcher" style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="lang-btn" data-lang="en">
|
||||
<button class="lang-btn" data-lang="en" onclick="setLang('en')">
|
||||
<span class="lang-flag">🇬🇧</span>
|
||||
<span data-i18n="settings.lang.en">English</span>
|
||||
</button>
|
||||
<button class="lang-btn" data-lang="ru">
|
||||
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">
|
||||
<span class="lang-flag">🇷🇺</span>
|
||||
<span data-i18n="settings.lang.ru">Русский</span>
|
||||
</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user