Compare commits
72 Commits
2dab9d666e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63f8ee333f | |||
| 0241d186ea | |||
| 46892ee61c | |||
| 8318e4b560 | |||
| 626f2845e2 | |||
| 502e339809 | |||
| dca97bd69b | |||
| 061873058e | |||
| c98c7c8ce2 | |||
| fd8571ad10 | |||
| c7e5efa976 | |||
| 4da13c0811 | |||
| 4cad7f1c46 | |||
| 700a684257 | |||
| 2b2ff92146 | |||
| d34c8a73d6 | |||
| 9ba2aa3f38 | |||
| 209b3e1485 | |||
| 3b51cb89df | |||
| 861502aabc | |||
| 327bea6f33 | |||
| 87d9ba2299 | |||
| de1bcc5cae | |||
| 2f6e1d5e51 | |||
| 0d828257c0 | |||
| 8b8f2fc6c5 | |||
| 7aad3b1228 | |||
| 8ce00ede31 | |||
| 267061e226 | |||
| 78d3052a61 | |||
| 0d193b84dd | |||
| 8a1cc1746f | |||
| d83ab541d9 | |||
| 5922aea68f | |||
| d739f17393 | |||
| dbb75871ae | |||
| 50ae60952a | |||
| 5fb3d37329 | |||
| 6d36b5e262 | |||
| 42dd796927 | |||
| 04f63e690c | |||
| 052b9356ec | |||
| a8aa3248c9 | |||
| 87cdb95b80 | |||
| 5f8149d0d1 | |||
| 3472d06a70 | |||
| e6e6cc5144 | |||
| 9331ee1d89 | |||
| c77065756d | |||
| cab9282c50 | |||
| 9705c5d8cf | |||
| 697add5115 | |||
| 35841359b3 | |||
| 5a52a7a565 | |||
| 08b2a3967b | |||
| 56488aa9f9 | |||
| 8f05b801fe | |||
| 0f509b1317 | |||
| 7df9b48513 | |||
| a31862aeeb | |||
| d01c8e1f1a | |||
| ac45d5dc2d | |||
| d5dff49857 | |||
| 17585a9438 | |||
| 75b94f0f3a | |||
| 75b0db0eaf | |||
| 365041d666 | |||
| b22ed048f2 | |||
| 11874eb16e | |||
| f931d9b51f | |||
| f1fcfdef3d | |||
| 37fc79c9ec |
+9
-1
@@ -24,13 +24,21 @@ 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_LOG_LEVEL=info
|
||||
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=false
|
||||
CRANK_DEMO_SEED=true
|
||||
CRANK_BASE_URL=https://crank.example.com
|
||||
|
||||
+286
-14
@@ -11,30 +11,40 @@ 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
|
||||
@@ -43,6 +53,7 @@ jobs:
|
||||
rustfmt --version
|
||||
cargo clippy --version
|
||||
docker --version
|
||||
docker info
|
||||
|
||||
- name: Run tooling unit tests
|
||||
run: python3 -m unittest discover -s tests/unit
|
||||
@@ -116,6 +127,29 @@ 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
|
||||
@@ -155,3 +189,241 @@ jobs:
|
||||
|
||||
- name: Validate Community deployment manifest
|
||||
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
||||
|
||||
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' \
|
||||
.
|
||||
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_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_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
|
||||
"
|
||||
|
||||
- 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"
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
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
|
||||
"
|
||||
|
||||
- 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"
|
||||
@@ -22,6 +22,23 @@ 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
|
||||
|
||||
Generated
+1321
-708
File diff suppressed because it is too large
Load Diff
+9
-5
@@ -5,10 +5,12 @@ members = [
|
||||
"crates/crank-community-auth",
|
||||
"crates/crank-community-mcp",
|
||||
"crates/crank-core",
|
||||
"crates/crank-import",
|
||||
"crates/crank-schema",
|
||||
"crates/crank-mapping",
|
||||
"crates/crank-registry",
|
||||
"crates/crank-runtime",
|
||||
"crates/crank-test-support",
|
||||
"crates/crank-adapter-rest",
|
||||
]
|
||||
resolver = "3"
|
||||
@@ -16,26 +18,28 @@ resolver = "3"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.96"
|
||||
version = "0.3.1"
|
||||
|
||||
[workspace.dependencies]
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
axum = "0.8"
|
||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||
axum-extra = { version = "0.12", features = ["cookie"] }
|
||||
base64 = "0.22"
|
||||
hkdf = "0.12"
|
||||
rand = "0.8"
|
||||
rand = "0.10"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
sha2 = "0.10"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "macros", "json", "time"] }
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "json", "time", "uuid"] }
|
||||
thiserror = "2"
|
||||
time = { version = "0.3", features = ["formatting", "parsing", "serde"] }
|
||||
time = { version = "0.3.53", features = ["formatting", "parsing", "serde"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
uuid = { version = "1", features = ["serde", "v7"] }
|
||||
testcontainers = { version = "0.27", features = ["blocking"] }
|
||||
testcontainers-modules = { version = "0.15", features = ["postgres", "blocking"] }
|
||||
|
||||
@@ -196,8 +196,8 @@ deploy/community/
|
||||
|
||||
- Rust toolchain из [`rust-toolchain.toml`](./rust-toolchain.toml);
|
||||
- Node.js и npm;
|
||||
- PostgreSQL;
|
||||
- Docker, если хотите запускать полный стенд контейнерами.
|
||||
- PostgreSQL, если запускаете сервисы вручную;
|
||||
- Docker для полного стенда и Rust-тестов с временной PostgreSQL.
|
||||
|
||||
Быстрее всего поднять окружение так же, как для обычного запуска:
|
||||
|
||||
@@ -210,6 +210,8 @@ docker compose up -d postgres
|
||||
|
||||
Приложения читают настройки из переменных окружения. Можно использовать `.env` через свое окружение разработки, `direnv`, IDE или любой другой привычный способ загрузки переменных.
|
||||
|
||||
Rust-тесты сами поднимают временный PostgreSQL через Testcontainers. Отдельно запускать тестовую БД или задавать URL тестовой базы не нужно.
|
||||
|
||||
Сервер панели управления:
|
||||
|
||||
```bash
|
||||
@@ -255,9 +257,17 @@ npx playwright test
|
||||
|
||||
## Документация
|
||||
|
||||
- [Настройки запуска](docs/runtime-config.md)
|
||||
- [Развертывание](docs/deployment.md)
|
||||
- [Документация](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)
|
||||
|
||||
## Участие в разработке
|
||||
|
||||
@@ -16,6 +16,7 @@ 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-registry = { path = "../../crates/crank-registry" }
|
||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||
@@ -35,5 +36,6 @@ uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
crank-test-support = { path = "../../crates/crank-test-support" }
|
||||
reqwest.workspace = true
|
||||
serial_test = "3"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.85-bookworm AS deps
|
||||
FROM rust:1.96.1-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.85-bookworm AS builder
|
||||
FROM rust:1.96.1-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+12
-2209
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,520 @@
|
||||
use crank_core::{
|
||||
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
|
||||
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
|
||||
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
|
||||
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: Value,
|
||||
}
|
||||
|
||||
#[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, 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 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 WorkspaceExportResponse {
|
||||
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,
|
||||
}
|
||||
@@ -347,6 +347,10 @@ 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::Storage(_) | RegistryError::Serialization(_) => {
|
||||
Self::internal(value.to_string())
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ mod tests {
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod dto;
|
||||
pub mod error;
|
||||
pub mod import_guidance;
|
||||
pub mod rate_limit;
|
||||
|
||||
@@ -10,7 +10,7 @@ use crank_community_auth::PasswordIdentityProvider;
|
||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||
use crank_runtime::{
|
||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||
RuntimeLimits, SecretCrypto, community_default,
|
||||
RuntimeLimits, SecretCrypto,
|
||||
};
|
||||
use sqlx::postgres::PgConnectOptions;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -56,7 +56,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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 runtime = community_default()
|
||||
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::from_env()?;
|
||||
let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy.clone())
|
||||
.with_limits(runtime_limits)
|
||||
.with_response_cache(cache_stores.response.clone())
|
||||
.with_coordination_store(cache_stores.coordination.clone())
|
||||
@@ -70,6 +71,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
secret_crypto,
|
||||
runtime,
|
||||
)
|
||||
.with_outbound_http_policy(outbound_http_policy)
|
||||
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
||||
.build();
|
||||
service.bootstrap_admin_user().await?;
|
||||
@@ -83,9 +85,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
} 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!(
|
||||
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
|
||||
@@ -101,7 +105,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
info!("admin-api listening on {}", socket_addr);
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
axum::serve(listener, make_service).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header::{COOKIE, HeaderMap},
|
||||
extract::{ConnectInfo, Request, State},
|
||||
http::HeaderMap,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use crank_runtime::RateLimitRejection;
|
||||
|
||||
use crate::{auth::SESSION_COOKIE_NAME, error::ApiError, state::AppState};
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
pub async fn apply_api_rate_limit(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let key = rate_limit_key(request.headers(), request.uri().path());
|
||||
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(rejection) = state.api_rate_limiter.check(&key).await {
|
||||
return Err(ApiError::rate_limited_with_context(
|
||||
"request rate limit exceeded",
|
||||
@@ -30,56 +41,64 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_limit_key(headers: &HeaderMap, path: &str) -> String {
|
||||
if let Some(session_id) = session_id_from_headers(headers) {
|
||||
return format!("session:{session_id}");
|
||||
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}");
|
||||
}
|
||||
|
||||
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}");
|
||||
if let Some(peer_ip) = peer_ip {
|
||||
return format!("ip:{peer_ip}");
|
||||
}
|
||||
|
||||
format!("anonymous:{path}")
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
/// 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);
|
||||
}
|
||||
|
||||
None
|
||||
header_value(headers, "x-forwarded-for")?
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.rfind(|value| !value.is_empty())
|
||||
.and_then(parse_ip)
|
||||
}
|
||||
|
||||
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 keys_by_session_cookie_first() {
|
||||
fn unverified_session_cookie_cannot_change_client_key() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
COOKIE,
|
||||
@@ -88,19 +107,86 @@ mod tests {
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login"),
|
||||
"session:sess_123"
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
"ip:10.0.0.5"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_forwarded_ip() {
|
||||
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() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("10.0.0.5, 10.0.0.6"),
|
||||
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"),
|
||||
);
|
||||
|
||||
assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5");
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod agents;
|
||||
pub mod auth;
|
||||
pub mod auth_profiles;
|
||||
pub mod capabilities;
|
||||
pub mod imports;
|
||||
pub mod observability;
|
||||
pub mod operations;
|
||||
pub mod secrets;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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)))
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
routes::access::WorkspacePath,
|
||||
service::{LogsQuery, UsageRequestQuery},
|
||||
service::{ApprovalsQuery, LogsQuery, UsageRequestQuery},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,12 @@ 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,
|
||||
@@ -55,6 +61,32 @@ 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>,
|
||||
|
||||
+51
-3217
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,492 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, UsagePeriod,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crank_registry::{
|
||||
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
|
||||
SaveAgentBindingsRequest, UsageBucket, UsageQuery,
|
||||
};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, AgentBindingPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
|
||||
CreatedAgentResponse, PublishAgentResponse, UpdateAgentPayload, agent_mcp_endpoint,
|
||||
format_timestamp, map_agent_summary_view, new_prefixed_id, today_start_utc,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
#[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,
|
||||
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,
|
||||
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!(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: Vec<AgentBindingPayload>,
|
||||
) -> Result<AgentVersionRecord, ApiError> {
|
||||
let agent = self.get_agent(workspace_id, agent_id).await?;
|
||||
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<_>>();
|
||||
|
||||
self.registry
|
||||
.save_agent_bindings(SaveAgentBindingsRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
agent_version: agent.current_draft_version,
|
||||
bindings: &bindings,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
agent_id = %agent_id.as_str(),
|
||||
version = agent.current_draft_version,
|
||||
binding_count = bindings.len(),
|
||||
"agent bindings saved"
|
||||
);
|
||||
|
||||
self.get_agent_version(workspace_id, agent_id, agent.current_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?;
|
||||
|
||||
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!(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!(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!(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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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(match payload.key_kind {
|
||||
PlatformApiKeyKind::McpClient => "crk",
|
||||
PlatformApiKeyKind::Approval => "crk_appr",
|
||||
});
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
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
|
||||
.upsert_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,
|
||||
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(user_id, &password_hash)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
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!(
|
||||
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)
|
||||
.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: json!({
|
||||
"max_tools": 4,
|
||||
"prefer_tag": ["currency", "exchange-rate"]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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::{
|
||||
CreateImportJobRequest, FinishImportJobRequest, ImportJobId, ImportJobKind, ImportJobStatus,
|
||||
};
|
||||
use serde_json::json;
|
||||
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::Completed,
|
||||
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 preview: crank_import::rest::ImportPreview =
|
||||
serde_json::from_value(job.preview_payload.clone())
|
||||
.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 created = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
let mut findings = Vec::new();
|
||||
let mut created_ids = 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);
|
||||
if let Some(existing_name) = self
|
||||
.find_operation_by_name(workspace_id, &draft.name)
|
||||
.await?
|
||||
.map(|operation| operation.name)
|
||||
{
|
||||
if payload.conflict_mode == "skip" {
|
||||
skipped.push(OpenApiImportSkippedOperation {
|
||||
operation_key: candidate.key.clone(),
|
||||
name: draft.name.clone(),
|
||||
reason: "operation with this name already exists".to_owned(),
|
||||
});
|
||||
findings.push(ImportFinding {
|
||||
code: "operation_name_conflict".to_owned(),
|
||||
severity: ImportFindingSeverity::Warning,
|
||||
message: format!(
|
||||
"Операция {} уже существует и была пропущена.",
|
||||
draft.name
|
||||
),
|
||||
operation_key: Some(candidate.key.clone()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let renamed = self
|
||||
.next_available_operation_name(workspace_id, &draft.name)
|
||||
.await?;
|
||||
findings.push(ImportFinding {
|
||||
code: "operation_name_renamed".to_owned(),
|
||||
severity: ImportFindingSeverity::Info,
|
||||
message: format!(
|
||||
"Операция {existing_name} уже существует, новый черновик создан как {renamed}."
|
||||
),
|
||||
operation_key: Some(candidate.key.clone()),
|
||||
});
|
||||
draft.name = renamed;
|
||||
}
|
||||
|
||||
let payload = 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,
|
||||
};
|
||||
let result = self.create_operation(workspace_id, payload).await?;
|
||||
created_ids.push(result.operation_id.clone());
|
||||
created.push(OpenApiImportCreatedOperation {
|
||||
operation_id: result.operation_id,
|
||||
name: draft.name,
|
||||
version: result.version,
|
||||
});
|
||||
}
|
||||
|
||||
let finished_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.finish_import_job(FinishImportJobRequest {
|
||||
id: job_id,
|
||||
status: ImportJobStatus::Completed,
|
||||
created_operation_ids: &json!(created_ids),
|
||||
error_text: None,
|
||||
finished_at: &finished_at,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
created = created.len(),
|
||||
skipped = skipped.len(),
|
||||
"openapi import created drafts"
|
||||
);
|
||||
|
||||
Ok(OpenApiImportCreateResponse {
|
||||
created,
|
||||
skipped,
|
||||
findings,
|
||||
})
|
||||
}
|
||||
|
||||
async fn next_available_operation_name(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
base_name: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
for index in 2.. {
|
||||
let candidate = format!("{base_name}_{index}");
|
||||
if self
|
||||
.find_operation_by_name(workspace_id, &candidate)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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 {
|
||||
#[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() }),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
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.validate_operation_payload(&payload)?;
|
||||
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 now = OffsetDateTime::now_utc();
|
||||
let operation_id = OperationId::new(new_prefixed_id("op"));
|
||||
let snapshot = RegistryOperation {
|
||||
id: operation_id.clone(),
|
||||
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,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.create_operation(workspace_id, &snapshot, None)
|
||||
.await?;
|
||||
info!(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),
|
||||
})
|
||||
}
|
||||
|
||||
#[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!(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!(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 request_preview =
|
||||
match build_request_preview(&record.snapshot.input_mapping, &payload.input) {
|
||||
Ok(preview) => preview,
|
||||
Err(error) => {
|
||||
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,
|
||||
))],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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)],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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!(
|
||||
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!(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()
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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!(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!(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!(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!(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(())
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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!(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()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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,4 +5,10 @@ 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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
mod integration {
|
||||
mod auth_rate_limit;
|
||||
mod common;
|
||||
mod community_access_usage;
|
||||
mod openapi_import;
|
||||
mod operations_agents;
|
||||
mod secrets_import_auth;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
#![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");
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
#![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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
#![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 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!(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: json!({}),
|
||||
},
|
||||
)
|
||||
.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,
|
||||
}],
|
||||
)
|
||||
.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 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 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!(!request_id.is_empty());
|
||||
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
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use admin_api::service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload};
|
||||
use crank_core::WorkspaceId;
|
||||
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,
|
||||
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 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 skipped = 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!(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 renamed = 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: "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");
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
#![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 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");
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
#![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"
|
||||
);
|
||||
}
|
||||
@@ -33,4 +33,5 @@ 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" }
|
||||
reqwest.workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.85-bookworm AS deps
|
||||
FROM rust:1.96.1-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.85-bookworm AS builder
|
||||
FROM rust:1.96.1-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+5
-1783
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
mod integration {
|
||||
mod catalog_access;
|
||||
mod common;
|
||||
mod transport_protocol;
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
#![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));
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
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}"))
|
||||
.json(&json!({ "approve": "yes", "note": "confirmed by test" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(approved.status(), reqwest::StatusCode::OK);
|
||||
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);
|
||||
}
|
||||
|
||||
#[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_result = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_requires_human_approval",
|
||||
"arguments": {
|
||||
"email": "ada@example.com"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.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 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 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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
#![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,
|
||||
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},
|
||||
};
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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>,
|
||||
) {
|
||||
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: json!({}),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
#![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, ToolDescription, 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},
|
||||
};
|
||||
|
||||
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!(!request_id.is_empty());
|
||||
|
||||
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]
|
||||
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 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 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 _ = tracing::subscriber::set_global_default(subscriber);
|
||||
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();
|
||||
assert!(
|
||||
logs.contains("mcp request received"),
|
||||
"captured logs did not include ingress marker: {logs}"
|
||||
);
|
||||
assert!(logs.contains("req_mcp_trace_123"));
|
||||
assert!(logs.contains("sales-request-trace"));
|
||||
assert!(logs.contains("default"));
|
||||
assert!(logs.contains("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}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* 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;
|
||||
}
|
||||
+35
-1
@@ -154,6 +154,40 @@
|
||||
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) ── */
|
||||
@@ -212,7 +246,7 @@
|
||||
.mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
/* ── Responsive breakpoints ── */
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 980px) {
|
||||
.navbar { padding: 0 16px; }
|
||||
.nav-links { display: none; }
|
||||
.nav-hamburger { display: flex; }
|
||||
|
||||
@@ -134,3 +134,188 @@
|
||||
}
|
||||
|
||||
.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
@@ -0,0 +1,498 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -767,7 +767,10 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -801,8 +804,8 @@
|
||||
}
|
||||
.modal-close:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
|
||||
.modal-body { padding: 20px; }
|
||||
.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.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; }
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
RESPONSIVE
|
||||
|
||||
+352
-1
@@ -153,6 +153,8 @@
|
||||
padding: 40px 40px 140px;
|
||||
gap: 36px;
|
||||
align-items: flex-start;
|
||||
animation: page-enter 0.18s ease-out both;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.checkbox-pill {
|
||||
@@ -187,6 +189,192 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mapping-builder-card .config-card-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mapping-table {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(130px, 1fr)
|
||||
24px
|
||||
minmax(105px, 0.65fr)
|
||||
minmax(130px, 1fr)
|
||||
minmax(120px, 0.8fr)
|
||||
minmax(150px, 0.9fr)
|
||||
34px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.response-mapping-row {
|
||||
grid-template-columns: minmax(160px, 1fr) 24px minmax(140px, 1fr) 34px;
|
||||
}
|
||||
|
||||
.mapping-field {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapping-field-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: end;
|
||||
width: 24px;
|
||||
height: 34px;
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mapping-default-value {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.mapping-row-remove {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--red-border);
|
||||
border-radius: 8px;
|
||||
background: var(--red-bg);
|
||||
color: var(--red);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.mapping-row-remove:hover {
|
||||
border-color: rgba(248, 81, 73, 0.45);
|
||||
background: rgba(248, 81, 73, 0.16);
|
||||
color: #ff7b72;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mapping-builder-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.advanced-mapping-details {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.advanced-mapping-details summary {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.advanced-mapping-details[open] summary {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.mapping-response-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.85fr) minmax(320px, 1.15fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.json-tree-picker {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.json-tree-node {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 7px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.json-tree-node:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mapping-warnings {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mapping-warning {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.34);
|
||||
border-radius: 10px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mapping-warning strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.mapping-row,
|
||||
.response-mapping-row,
|
||||
.mapping-response-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.mapping-row-remove {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
STEP SIDEBAR
|
||||
══════════════════════════════════════════════════ */
|
||||
@@ -279,7 +467,11 @@
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: background 0.15s;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
@@ -925,6 +1117,31 @@
|
||||
.toggle-label { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.toggle-desc { font-size: 11.5px; color: var(--text-muted); margin-top: 1px; }
|
||||
|
||||
.approval-toggle-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.approval-toggle-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.approval-config-fields {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
background: rgba(13, 17, 23, 0.42);
|
||||
}
|
||||
|
||||
.approval-preview-pill {
|
||||
align-self: end;
|
||||
min-height: 38px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
BOTTOM ACTION BAR — frosted dark glass
|
||||
══════════════════════════════════════════════════ */
|
||||
@@ -1117,6 +1334,18 @@
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.form-group > .code-textarea {
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 8px !important;
|
||||
background: #0d1117 !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
.form-group > .code-textarea:focus {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 3px var(--accent-ring), inset 0 0 0 1px rgba(255, 255, 255, 0.02) !important;
|
||||
}
|
||||
|
||||
/* ── Section divider ── */
|
||||
.section-divider {
|
||||
display: flex;
|
||||
@@ -1525,6 +1754,16 @@
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.quality-finding-jump {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.quality-focus-target {
|
||||
outline: 2px solid rgba(13, 148, 136, 0.85);
|
||||
box-shadow: 0 0 0 4px rgba(13, 148, 136, 0.14);
|
||||
transition: box-shadow 0.2s ease, outline-color 0.2s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.agent-preview-summary {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1589,6 +1828,118 @@
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.progress-strip {
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wizard-body {
|
||||
display: grid;
|
||||
padding: 24px 16px 120px;
|
||||
}
|
||||
|
||||
.step-sidebar {
|
||||
position: static;
|
||||
width: auto;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.step-sidebar-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.step-sidebar-header {
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.steps-list::before {
|
||||
left: calc((100% - 32px) / 10);
|
||||
right: calc((100% - 32px) / 10);
|
||||
top: 27px;
|
||||
bottom: auto;
|
||||
width: auto;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-height: 96px;
|
||||
padding: 10px 6px 9px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-help {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
width: 100%;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
margin-bottom: 3px;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
display: -webkit-box;
|
||||
min-height: 28px;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.step-status-text {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.action-bar-inner {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.progress-label,
|
||||
.progress-pct,
|
||||
.btn-save-draft,
|
||||
.step-counter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
min-height: 88px;
|
||||
padding: 9px 4px 8px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.step-status-text {
|
||||
font-size: 9.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════
|
||||
HTTP method picker (Step 4 REST)
|
||||
══════════════════════════════════════════════ */
|
||||
|
||||
@@ -37,7 +37,11 @@
|
||||
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); }
|
||||
@@ -47,6 +51,8 @@
|
||||
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%;
|
||||
@@ -105,6 +111,7 @@
|
||||
.ws-color-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Agents</title>
|
||||
<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/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -49,12 +49,12 @@
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role" id="user-ws-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" onclick="window.location.href='/settings'">
|
||||
<a class="user-dropdown-item" href="/settings">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" onclick="window.CrankAuth.logout()">
|
||||
<button class="user-dropdown-item danger" @click="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>
|
||||
|
||||
+87
-26
@@ -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 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/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -23,9 +23,75 @@
|
||||
.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>
|
||||
@@ -94,16 +160,23 @@
|
||||
<!-- ═══════════════════ PAGE ═══════════════════ -->
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header api-keys-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>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,25 +243,7 @@
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="apikeys.scope_ref">Access reference</div>
|
||||
</div>
|
||||
<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 class="section-card-body" id="scope-reference-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,7 +253,7 @@
|
||||
<div class="modal-overlay" id="modal-create">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" data-i18n="apikeys.modal.title">Create agent key</span>
|
||||
<span class="modal-title" id="modal-create-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"/>
|
||||
@@ -211,6 +266,12 @@
|
||||
<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>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;margin-top:2px;" id="scope-checkboxes">
|
||||
|
||||
@@ -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" onclick="setLang('en')">
|
||||
<button class="lang-btn" data-lang="en">
|
||||
<span class="lang-flag">🇬🇧</span>
|
||||
<span data-i18n="settings.lang.en">English</span>
|
||||
</button>
|
||||
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">
|
||||
<button class="lang-btn" data-lang="ru">
|
||||
<span class="lang-flag">🇷🇺</span>
|
||||
<span data-i18n="settings.lang.ru">Русский</span>
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Sign in</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/login.css">
|
||||
<script src="%CRANK_BUNDLE_LOGIN%"></script>
|
||||
|
||||
+15
-1
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Logs</title>
|
||||
<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/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -78,6 +78,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card approval-panel">
|
||||
<div class="approval-panel-header">
|
||||
<div>
|
||||
<div class="approval-panel-title" data-i18n="approvals.title">Human confirmations</div>
|
||||
<div class="approval-panel-subtitle" data-i18n="approvals.subtitle">Requests waiting for an external user decision and recent results.</div>
|
||||
</div>
|
||||
<button class="refresh-btn approval-refresh-btn" id="approval-refresh-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 01.834.656 5.5 5.5 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834zM8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 01-1.49.178A5.501 5.501 0 008 2.5z"/></svg>
|
||||
<span data-i18n="approvals.refresh">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="approval-list" id="approval-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="log-toolbar">
|
||||
<div class="live-dot"></div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Secrets</title>
|
||||
<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/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -19,6 +19,11 @@
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
#secret-json-value {
|
||||
min-height: 132px;
|
||||
max-height: 220px;
|
||||
resize: vertical;
|
||||
}
|
||||
.secrets-card-list { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
#secrets-table-wrap { display: none; }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Settings</title>
|
||||
<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/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -172,11 +172,11 @@
|
||||
<label class="field-label" data-i18n="settings.lang.title">Language</label>
|
||||
<div class="field-hint" data-i18n="settings.lang.subtitle">Interface display language</div>
|
||||
<div class="lang-switcher">
|
||||
<button class="lang-btn" data-lang="en" type="button" onclick="setLang('en')">
|
||||
<button class="lang-btn" data-lang="en" type="button">
|
||||
<span class="lang-flag">🇬🇧</span>
|
||||
<span data-i18n="settings.lang.en">English</span>
|
||||
</button>
|
||||
<button class="lang-btn" data-lang="ru" type="button" onclick="setLang('ru')">
|
||||
<button class="lang-btn" data-lang="ru" type="button">
|
||||
<span class="lang-flag">🇷🇺</span>
|
||||
<span data-i18n="settings.lang.ru">Русский</span>
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Usage</title>
|
||||
<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/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — New Operation</title>
|
||||
<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/fonts.css">
|
||||
<link rel="stylesheet" href="../../css/variables.css">
|
||||
<link rel="stylesheet" href="../../css/layout.css">
|
||||
<link rel="stylesheet" href="../../css/wizard.css">
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- Searchable combobox -->
|
||||
<div class="upstream-combobox" id="upstream-combobox">
|
||||
<div class="upstream-combobox-trigger" id="upstream-combobox-trigger" onclick="toggleUpstreamDropdown(event)">
|
||||
<div class="upstream-combobox-trigger" id="upstream-combobox-trigger" data-wizard-action="toggle-upstream">
|
||||
<div class="upstream-combobox-value" id="upstream-combobox-value">
|
||||
<span class="upstream-combobox-placeholder" data-i18n="wizard.step2.upstream_placeholder">Выберите API-хост…</span>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round">
|
||||
<circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/>
|
||||
</svg>
|
||||
<input class="upstream-search-input" id="upstream-search" type="text" data-i18n-ph="wizard.step2.search_placeholder" placeholder="Поиск по имени или URL…" oninput="filterUpstreams(this.value)" autocomplete="off" spellcheck="false">
|
||||
<input class="upstream-search-input" id="upstream-search" type="text" data-i18n-ph="wizard.step2.search_placeholder" placeholder="Поиск по имени или URL…" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="upstream-dropdown-list" id="upstream-dropdown-list">
|
||||
<!-- populated by JS -->
|
||||
@@ -58,11 +58,11 @@
|
||||
<div class="upstream-preview-name" id="upstream-preview-name"></div>
|
||||
<div class="upstream-preview-url" id="upstream-preview-url"></div>
|
||||
<span class="upstream-auth-badge" id="upstream-preview-badge"></span>
|
||||
<button class="upstream-preview-change" onclick="beginEditSelectedUpstream(event)" data-i18n="wizard.step2.change">Изменить</button>
|
||||
<button class="upstream-preview-change" data-wizard-action="edit-upstream" data-i18n="wizard.step2.change">Изменить</button>
|
||||
</div>
|
||||
|
||||
<!-- Register new upstream trigger row -->
|
||||
<div class="upstream-new-trigger" id="upstream-new-trigger" onclick="startNewUpstream()">
|
||||
<div class="upstream-new-trigger" id="upstream-new-trigger" data-wizard-action="new-upstream">
|
||||
<div class="upstream-new-trigger-radio" id="upstream-new-trigger-radio">
|
||||
<div class="upstream-new-trigger-dot"></div>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.auth_selector">Авторизация API-хоста</label>
|
||||
<select class="form-select" id="new-upstream-auth-mode" data-testid="wizard-auth-mode-select" onchange="updateUpstreamAuthUi()">
|
||||
<select class="form-select" id="new-upstream-auth-mode" data-testid="wizard-auth-mode-select">
|
||||
<option value="none" data-i18n="wizard.step2.auth_mode.none">Без авторизации</option>
|
||||
<option value="existing" data-i18n="wizard.step2.auth_mode.existing">Использовать существующий профиль авторизации</option>
|
||||
<option value="create" data-i18n="wizard.step2.auth_mode.create">Создать профиль авторизации сейчас</option>
|
||||
@@ -118,7 +118,7 @@
|
||||
<div class="form-row" style="grid-template-columns: 1fr 1fr;">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><span data-i18n="wizard.step2.profile_kind">Тип авторизации</span> <span class="form-label-required" data-i18n="workspace_setup.required">обязательно</span></label>
|
||||
<select class="form-select" id="new-auth-profile-kind" data-testid="wizard-auth-profile-kind-select" onchange="updateAuthProfileCreateUi()">
|
||||
<select class="form-select" id="new-auth-profile-kind" data-testid="wizard-auth-profile-kind-select">
|
||||
<option value="bearer" data-i18n="wizard.step2.auth_kind.bearer">Bearer-токен</option>
|
||||
<option value="basic" data-i18n="wizard.step2.auth_kind.basic">Логин и пароль</option>
|
||||
<option value="api_key_header" data-i18n="wizard.step2.auth_kind.api_key_header">API-ключ в заголовке</option>
|
||||
@@ -149,7 +149,7 @@
|
||||
<select class="form-select" id="new-auth-profile-secret-id" data-testid="wizard-auth-secret-select"></select>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||
<button class="btn-ghost-sm" data-testid="wizard-open-quick-secret" onclick="openQuickSecretModal(event)" data-i18n="wizard.step2.quick_secret">Быстро создать секрет</button>
|
||||
<button class="btn-ghost-sm" data-testid="wizard-open-quick-secret" data-wizard-action="quick-secret" data-i18n="wizard.step2.quick_secret">Быстро создать секрет</button>
|
||||
<a class="btn-ghost-sm" href="/secrets" target="_blank" rel="noopener" data-i18n="wizard.step2.manage_secrets">Открыть страницу секретов</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,8 +168,8 @@
|
||||
<div class="form-hint" data-i18n="wizard.step2.static_headers_hint">Необязательные заголовки без секретных значений. Токены, пароли и ключи храните через профиль авторизации.</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:4px;">
|
||||
<button class="btn-primary-sm" onclick="saveNewUpstream(event)" data-i18n="wizard.step2.save_upstream">Сохранить API-хост</button>
|
||||
<button class="btn-ghost-sm" onclick="cancelNewUpstream(event)" data-i18n="btn.cancel">Отмена</button>
|
||||
<button class="btn-primary-sm" data-wizard-action="save-upstream" data-i18n="wizard.step2.save_upstream">Сохранить API-хост</button>
|
||||
<button class="btn-ghost-sm" data-wizard-action="cancel-upstream" data-i18n="btn.cancel">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,23 +25,23 @@
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<div class="method-grid">
|
||||
<button class="method-card" data-method="GET" onclick="selectMethod(this)">
|
||||
<button class="method-card" data-method="GET">
|
||||
<span class="method-name">GET</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.read">Чтение</span>
|
||||
</button>
|
||||
<button class="method-card active" data-method="POST" onclick="selectMethod(this)">
|
||||
<button class="method-card active" data-method="POST">
|
||||
<span class="method-name">POST</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.create">Создание</span>
|
||||
</button>
|
||||
<button class="method-card" data-method="PUT" onclick="selectMethod(this)">
|
||||
<button class="method-card" data-method="PUT">
|
||||
<span class="method-name">PUT</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.replace">Замена</span>
|
||||
</button>
|
||||
<button class="method-card" data-method="PATCH" onclick="selectMethod(this)">
|
||||
<button class="method-card" data-method="PATCH">
|
||||
<span class="method-name">PATCH</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.update">Обновление</span>
|
||||
</button>
|
||||
<button class="method-card" data-method="DELETE" onclick="selectMethod(this)">
|
||||
<button class="method-card" data-method="DELETE">
|
||||
<span class="method-name">DELETE</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.remove">Удаление</span>
|
||||
</button>
|
||||
@@ -90,4 +90,92 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card approval-gate-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 2l5 2v4c0 3-2 5-5 6-3-1-5-3-5-6V4l5-2z"/>
|
||||
<path d="M6 8l1.4 1.4L10.5 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.approval.title">Подтверждение человеком</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.approval.subtitle">Включайте для действий, которые нельзя выполнять без явного решения пользователя.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 16px;">
|
||||
<label class="toggle-row approval-toggle-row" for="approval-required">
|
||||
<span id="approval-required-toggle" class="toggle" aria-hidden="true"></span>
|
||||
<span class="toggle-text">
|
||||
<span class="toggle-label" data-i18n="wizard.approval.required_label">Требовать подтверждение перед выполнением</span>
|
||||
<span class="toggle-desc" data-i18n="wizard.approval.required_desc">MCP клиент получит ожидающий запрос, а действие выполнится только после подтверждения через отдельный эндпоинт подтверждения.</span>
|
||||
</span>
|
||||
<input id="approval-required" type="checkbox" class="approval-toggle-input">
|
||||
</label>
|
||||
|
||||
<div id="approval-config-fields" class="approval-config-fields" hidden>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="approval-mode" data-i18n="wizard.approval.mode">Механизм подтверждения</label>
|
||||
<select id="approval-mode" class="form-select">
|
||||
<option value="custom" data-i18n="wizard.approval.mode.custom">Custom MCP Approval</option>
|
||||
<option value="elicitation" data-i18n="wizard.approval.mode.elicitation">MCP Elicitation</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="info-callout" id="approval-custom-info">
|
||||
<svg class="info-callout-icon" width="15" height="15" 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="6.5"></circle>
|
||||
<path d="M8 11V8M8 5.5V5"></path>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.approval.custom_title">Custom MCP Approval</div>
|
||||
<div class="info-callout-text" data-i18n="wizard.approval.custom_body">Crank вернёт MCP-клиенту ответ о необходимости подтверждения, идентификатор заявки и адреса для подтверждения или отказа. Ваш MCP-клиент должен распознать такой ответ, показать пользователю окно подтверждения и отправить решение на адрес подтверждения. Для этого адреса нужен отдельный ключ подтверждения агента.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-callout" id="approval-elicitation-info" hidden>
|
||||
<svg class="info-callout-icon" width="15" height="15" 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="6.5"></circle>
|
||||
<path d="M8 11V8M8 5.5V5"></path>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.approval.elicitation_title">MCP Elicitation</div>
|
||||
<div class="info-callout-text" data-i18n="wizard.approval.elicitation_body">Crank запросит подтверждение стандартным способом MCP Elicitation. MCP-клиент должен поддерживать эту возможность. Отдельный ключ подтверждения не используется: решение пользователя возвращается по текущему MCP-подключению.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="approval-elicitation-message-group" hidden>
|
||||
<label class="form-label" for="approval-elicitation-message" data-i18n="wizard.approval.elicitation_message">Сообщение для MCP-клиента</label>
|
||||
<textarea id="approval-elicitation-message" class="form-textarea" rows="3" maxlength="240" data-i18n-ph="wizard.approval.elicitation_message_placeholder" placeholder="Подтвердите выполнение операции."></textarea>
|
||||
<div class="form-hint" data-i18n="wizard.approval.elicitation_message_hint">Короткое сообщение для MCP-клиента. Внешний вид окна подтверждения определяет сам клиент.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="approval-ttl-seconds" data-i18n="wizard.approval.ttl">Сколько ждать подтверждение</label>
|
||||
<select id="approval-ttl-seconds" class="form-select">
|
||||
<option value="60">1 минута</option>
|
||||
<option value="180">3 минуты</option>
|
||||
<option value="300" selected>5 минут</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-pill approval-preview-pill">
|
||||
<input id="approval-show-payload-preview" type="checkbox" checked>
|
||||
<span data-i18n="wizard.approval.show_payload">Передавать параметры вызова в подтверждение</span>
|
||||
</label>
|
||||
|
||||
<div class="info-callout" id="approval-payload-info">
|
||||
<svg class="info-callout-icon" width="15" height="15" 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="6.5"></circle>
|
||||
<path d="M8 11V8M8 5.5V5"></path>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.approval.payload_title">Параметры подтверждения</div>
|
||||
<div class="info-callout-text" data-i18n="wizard.approval.payload_body">Если включено, Crank передаст параметры вызова вместе с запросом подтверждения. Так внешний интерфейс или MCP-клиент сможет показать пользователю, какое действие он подтверждает. Если параметры содержат чувствительные данные, выключите эту опцию.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /step-pane-3-rest -->
|
||||
|
||||
@@ -13,20 +13,35 @@
|
||||
<div class="section-divider-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-body" style="gap: 0; padding: 0;">
|
||||
<div class="code-block" style="border-radius: 0; border: none;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / request-fields</span>
|
||||
</div>
|
||||
<textarea id="tool-input-mapping" class="form-textarea code-textarea" rows="12">first_name: "$.input.first_name"
|
||||
<div class="config-card mapping-builder-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div>
|
||||
<div class="config-card-title">Конструктор API-запроса</div>
|
||||
<div class="config-card-subtitle">Укажите, куда отправлять поля инструмента: в путь, query-параметры, заголовки или JSON-тело.</div>
|
||||
</div>
|
||||
<button id="wizard-add-request-mapping-row" class="btn-ghost-sm" type="button">Добавить поле</button>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 14px;">
|
||||
<div class="mapping-table" id="wizard-request-mapping-rows" data-testid="wizard-request-mapping-rows"></div>
|
||||
<div class="mapping-builder-actions">
|
||||
<button id="wizard-auto-request-mapping" class="btn-ghost-sm" type="button">Собрать из входного JSON</button>
|
||||
<button id="wizard-sync-request-mapping" class="btn-ghost-sm" type="button">Обновить YAML</button>
|
||||
</div>
|
||||
<details class="advanced-mapping-details">
|
||||
<summary>Дополнительно: YAML маппинга запроса</summary>
|
||||
<div class="code-block">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / request-fields</span>
|
||||
</div>
|
||||
<textarea id="tool-input-mapping" class="form-textarea code-textarea" rows="10">first_name: "$.input.first_name"
|
||||
last_name: "$.input.last_name"
|
||||
email: "$.input.email"
|
||||
company: "$.input.company"
|
||||
phone: "$.input.phone"
|
||||
source: "$.input.source"</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,18 +50,42 @@ source: "$.input.source"</textarea>
|
||||
<div class="section-divider-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-body" style="gap: 0; padding: 0;">
|
||||
<div class="code-block" style="border-radius: 0; border: none;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / response-fields</span>
|
||||
<div class="config-card mapping-builder-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div>
|
||||
<div class="config-card-title">Конструктор ответа инструмента</div>
|
||||
<div class="config-card-subtitle">Выберите поля из ответа API, которые нужно вернуть MCP клиенту.</div>
|
||||
</div>
|
||||
<button id="wizard-add-response-mapping-row" class="btn-ghost-sm" type="button">Добавить поле</button>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 14px;">
|
||||
<div class="mapping-response-layout">
|
||||
<div>
|
||||
<div class="form-label">Поля ответа API</div>
|
||||
<div id="wizard-response-json-tree" class="json-tree-picker" data-testid="wizard-response-json-tree"></div>
|
||||
</div>
|
||||
<textarea id="tool-output-mapping" class="form-textarea code-textarea" rows="10">lead_id: "$.response.data.id"
|
||||
<div>
|
||||
<div class="form-label">Поля результата инструмента</div>
|
||||
<div class="mapping-table" id="wizard-response-mapping-rows" data-testid="wizard-response-mapping-rows"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mapping-builder-actions">
|
||||
<button id="wizard-auto-response-mapping" class="btn-ghost-sm" type="button">Собрать из ответа JSON</button>
|
||||
<button id="wizard-sync-response-mapping" class="btn-ghost-sm" type="button">Обновить YAML</button>
|
||||
</div>
|
||||
<details class="advanced-mapping-details">
|
||||
<summary>Дополнительно: YAML маппинга ответа</summary>
|
||||
<div class="code-block">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / response-fields</span>
|
||||
</div>
|
||||
<textarea id="tool-output-mapping" class="form-textarea code-textarea" rows="10">lead_id: "$.response.data.id"
|
||||
status: "$.response.data.status"
|
||||
created_at: "$.response.data.created_at"
|
||||
owner_id: "$.response.data.owner.id"</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +142,8 @@ tls:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wizard-mapping-warnings" class="mapping-warnings" hidden data-testid="wizard-mapping-warnings"></div>
|
||||
|
||||
<div class="config-card" id="agent-facing-preview-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
@@ -196,6 +237,7 @@ tls:
|
||||
"last_name": "Lovelace",
|
||||
"email": "ada@example.com"
|
||||
}</textarea>
|
||||
<input id="wizard-input-sample-file" type="file" accept=".json,application/json" hidden>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step5.output_sample">Пример ответа</label>
|
||||
@@ -203,9 +245,13 @@ tls:
|
||||
"id": "lead_123",
|
||||
"status": "created"
|
||||
}</textarea>
|
||||
<input id="wizard-output-sample-file" type="file" accept=".json,application/json" hidden>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
|
||||
<button id="wizard-load-input-sample-file" class="btn-ghost-sm" type="button">Загрузить JSON запроса</button>
|
||||
<button id="wizard-load-output-sample-file" class="btn-ghost-sm" type="button">Загрузить JSON ответа</button>
|
||||
<button id="wizard-format-samples" class="btn-ghost-sm" type="button">Проверить и форматировать JSON</button>
|
||||
<button id="wizard-upload-input-sample" class="btn-primary-sm" type="button" data-i18n="wizard.step5.save_input_sample">Сохранить входной пример</button>
|
||||
<button id="wizard-upload-output-sample" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.save_output_sample">Сохранить выходной пример</button>
|
||||
<button id="wizard-generate-draft" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.generate_draft">Пересобрать по примерам</button>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Workspace</title>
|
||||
<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/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
@@ -24,10 +24,10 @@
|
||||
</div>
|
||||
Crank
|
||||
</a>
|
||||
<a href="javascript:history.back()" class="ws-setup-back">
|
||||
<button type="button" class="ws-setup-back" data-history-back>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
|
||||
<span data-i18n="workspace_setup.back">Back</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
@@ -48,27 +48,27 @@
|
||||
<div>
|
||||
<div class="ws-avatar-hint" data-i18n="workspace_setup.identity.avatar_hint">Avatar is derived from your workspace name.</div>
|
||||
<div class="ws-color-swatches">
|
||||
<div class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" onclick="pickColor(this)" title="Teal"></div>
|
||||
<div class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" onclick="pickColor(this)" title="Purple"></div>
|
||||
<div class="ws-color-swatch" style="background:#0891b2;" data-color="#0891b2" onclick="pickColor(this)" title="Cyan"></div>
|
||||
<div class="ws-color-swatch" style="background:#d29922;" data-color="#d29922" onclick="pickColor(this)" title="Amber"></div>
|
||||
<div class="ws-color-swatch" style="background:#1a7f37;" data-color="#1a7f37" onclick="pickColor(this)" title="Green"></div>
|
||||
<div class="ws-color-swatch" style="background:#cf222e;" data-color="#cf222e" onclick="pickColor(this)" title="Red"></div>
|
||||
<div class="ws-color-swatch" style="background:#5e6ad2;" data-color="#5e6ad2" onclick="pickColor(this)" title="Indigo"></div>
|
||||
<button class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" type="button" title="Teal"></button>
|
||||
<button class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" type="button" title="Purple"></button>
|
||||
<button class="ws-color-swatch" style="background:#0891b2;" data-color="#0891b2" type="button" title="Cyan"></button>
|
||||
<button class="ws-color-swatch" style="background:#d29922;" data-color="#d29922" type="button" title="Amber"></button>
|
||||
<button class="ws-color-swatch" style="background:#1a7f37;" data-color="#1a7f37" type="button" title="Green"></button>
|
||||
<button class="ws-color-swatch" style="background:#cf222e;" data-color="#cf222e" type="button" title="Red"></button>
|
||||
<button class="ws-color-swatch" style="background:#5e6ad2;" data-color="#5e6ad2" type="button" title="Indigo"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:14px;">
|
||||
<label class="form-label"><span data-i18n="workspace_setup.name">Workspace name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off" oninput="onWsNameInput(this.value)">
|
||||
<input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:14px;">
|
||||
<label class="form-label"><span data-i18n="workspace_setup.slug">Slug</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<div style="position:relative;">
|
||||
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:13px;color:var(--text-muted);font-family:monospace;pointer-events:none;">mcp.crank.io/</span>
|
||||
<input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;" oninput="onWsSlugInput(this.value)">
|
||||
<input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;">
|
||||
</div>
|
||||
<div class="form-hint" data-i18n="workspace_setup.slug_hint">Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.</div>
|
||||
</div>
|
||||
@@ -81,8 +81,8 @@
|
||||
|
||||
<!-- ── Actions ── -->
|
||||
<div class="ws-setup-actions">
|
||||
<a href="javascript:history.back()" class="btn-ghost-sm" style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</a>
|
||||
<button class="btn-primary ws-submit-btn" id="submit-btn" type="button" onclick="submitForm()" data-i18n="workspace_setup.actions.save">Save changes</button>
|
||||
<button type="button" class="btn-ghost-sm" data-history-back style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</button>
|
||||
<button class="btn-primary ws-submit-btn" id="submit-btn" type="button" data-i18n="workspace_setup.actions.save">Save changes</button>
|
||||
</div>
|
||||
|
||||
<div class="ws-setup-footer-note" id="footer-note" hidden>
|
||||
|
||||
+85
-5
@@ -4,11 +4,12 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Operations</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/catalog.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/openapi-import.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body x-data="catalog()">
|
||||
@@ -88,10 +89,16 @@
|
||||
<h1 class="page-heading" data-i18n="ops.title">Operations</h1>
|
||||
<p class="page-subheading" data-i18n="ops.subtitle">Catalog of tools. List of created MCP tools for API endpoints.</p>
|
||||
</div>
|
||||
<button class="btn-new" @click="handleNewOperation()">
|
||||
<svg width="13" height="13"><use href="icons/general/plus.svg#icon"/></svg>
|
||||
<span data-i18n="ops.new">New operation</span>
|
||||
</button>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-secondary openapi-import-trigger" @click="handleOpenApiImport()">
|
||||
<svg width="13" height="13"><use href="icons/wizard/upload.svg#icon"/></svg>
|
||||
<span>Импорт OpenAPI</span>
|
||||
</button>
|
||||
<button class="btn-new" @click="handleNewOperation()">
|
||||
<svg width="13" height="13"><use href="icons/general/plus.svg#icon"/></svg>
|
||||
<span data-i18n="ops.new">New operation</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
@@ -367,6 +374,79 @@
|
||||
|
||||
</div><!-- /page -->
|
||||
|
||||
<div class="openapi-import-modal" id="openapi-import-modal" hidden>
|
||||
<div class="openapi-import-backdrop" data-openapi-close></div>
|
||||
<section class="openapi-import-dialog" role="dialog" aria-modal="true" aria-labelledby="openapi-import-title">
|
||||
<div class="openapi-import-header">
|
||||
<div>
|
||||
<h2 id="openapi-import-title">Импорт OpenAPI</h2>
|
||||
<p>Загрузите OpenAPI/Swagger документ, выберите методы и создайте черновики MCP-инструментов.</p>
|
||||
</div>
|
||||
<button class="modal-close" type="button" data-openapi-close aria-label="Закрыть">
|
||||
<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><line x1="11" y1="1" x2="1" y2="11"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="openapi-import-body">
|
||||
<div class="openapi-import-upload">
|
||||
<label class="openapi-file-label">
|
||||
<span>Файл OpenAPI/Swagger</span>
|
||||
<span class="openapi-file-control">
|
||||
<span class="btn-secondary openapi-file-button">Выбрать файл</span>
|
||||
<span class="openapi-file-name" id="openapi-import-file-name">Файл не выбран</span>
|
||||
</span>
|
||||
<input id="openapi-import-file" type="file" accept=".yaml,.yml,.json,application/json,text/yaml">
|
||||
</label>
|
||||
<textarea id="openapi-import-document" spellcheck="false" placeholder="Вставьте openapi.yaml или swagger.json"></textarea>
|
||||
<div class="openapi-import-actions">
|
||||
<button class="btn-primary" id="openapi-import-preview" type="button">Разобрать документ</button>
|
||||
<button class="btn-secondary" id="openapi-import-reset" type="button">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="openapi-import-status" id="openapi-import-status"></div>
|
||||
<div class="openapi-import-preview" id="openapi-import-preview-panel" hidden>
|
||||
<div class="openapi-import-source" id="openapi-import-source"></div>
|
||||
<div class="openapi-import-server-row">
|
||||
<label for="openapi-import-server">Base URL</label>
|
||||
<select id="openapi-import-server"></select>
|
||||
<input id="openapi-import-server-custom" class="openapi-import-server-custom" type="url" placeholder="Или укажите свой base URL, например https://api.example.com">
|
||||
</div>
|
||||
<div class="openapi-import-server-row">
|
||||
<label for="openapi-import-conflict-mode">Если операция уже существует</label>
|
||||
<select id="openapi-import-conflict-mode">
|
||||
<option value="rename" selected>Создать копию с новым именем</option>
|
||||
<option value="skip">Пропустить</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="openapi-import-toolbar">
|
||||
<div class="openapi-import-filter">
|
||||
<label for="openapi-import-search">Поиск методов</label>
|
||||
<input id="openapi-import-search" type="search" placeholder="Название, operationId или путь">
|
||||
</div>
|
||||
<div class="openapi-import-filter">
|
||||
<label for="openapi-import-method-filter">HTTP-метод</label>
|
||||
<select id="openapi-import-method-filter">
|
||||
<option value="">Все методы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="openapi-import-bulk-actions">
|
||||
<button class="btn-secondary" id="openapi-import-select-visible" type="button">Выбрать видимые</button>
|
||||
<button class="btn-secondary" id="openapi-import-clear-visible" type="button">Снять видимые</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="openapi-import-groups" id="openapi-import-groups"></div>
|
||||
<div class="openapi-import-footer">
|
||||
<span id="openapi-import-selection">Выбрано: 0</span>
|
||||
<button class="btn-primary" id="openapi-import-create" type="button">Создать черновики</button>
|
||||
</div>
|
||||
<div class="openapi-import-result" id="openapi-import-result" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="%CRANK_BUNDLE_OPERATIONS%"></script>
|
||||
|
||||
</body>
|
||||
|
||||
+82
-8
@@ -2,14 +2,21 @@ var KEYS = [];
|
||||
var AGENTS = [];
|
||||
var currentWorkspaceId = null;
|
||||
var currentAgentId = null;
|
||||
var activeKeyKind = 'mcp_client';
|
||||
var selectedScopes = new Set(['read']);
|
||||
var search = '';
|
||||
|
||||
var SCOPES = ['read', 'write', 'deploy'];
|
||||
var SCOPES_BY_KIND = {
|
||||
mcp_client: ['read', 'write', 'deploy'],
|
||||
approval: ['read_pending', 'approve', 'deny'],
|
||||
};
|
||||
var SCOPE_DESC = {
|
||||
read: 'apikeys.scope.read',
|
||||
write: 'apikeys.scope.write',
|
||||
deploy: 'apikeys.scope.deploy',
|
||||
approve: 'apikeys.scope.approve',
|
||||
deny: 'apikeys.scope.deny',
|
||||
read_pending: 'apikeys.scope.read_pending',
|
||||
};
|
||||
|
||||
function tKey(key) {
|
||||
@@ -35,6 +42,7 @@ function mapKeyRecord(record) {
|
||||
return {
|
||||
id: apiKey.id,
|
||||
agentId: apiKey.agent_id || null,
|
||||
keyKind: apiKey.key_kind || 'mcp_client',
|
||||
name: apiKey.name,
|
||||
prefix: apiKey.prefix || '',
|
||||
scopes: apiKey.scopes || [],
|
||||
@@ -173,6 +181,7 @@ async function deleteKey(id) {
|
||||
async function createKey(name, scopes) {
|
||||
var created = await window.CrankApi.createAgentPlatformApiKey(currentWorkspaceId, currentAgentId, {
|
||||
name: name,
|
||||
key_kind: activeKeyKind,
|
||||
scopes: scopes,
|
||||
});
|
||||
return {
|
||||
@@ -221,13 +230,19 @@ function setCreateButtonState() {
|
||||
var button = document.getElementById('btn-create-key');
|
||||
if (!button) return;
|
||||
button.disabled = !currentAgentId;
|
||||
var label = document.getElementById('btn-create-key-label');
|
||||
if (label) {
|
||||
label.textContent = activeKeyKind === 'approval'
|
||||
? tKey('apikeys.new_approval')
|
||||
: tKey('apikeys.new_mcp');
|
||||
}
|
||||
}
|
||||
|
||||
function renderScopes() {
|
||||
var el = document.getElementById('scope-checkboxes');
|
||||
var tmpl = document.getElementById('tmpl-scope-checkbox');
|
||||
el.innerHTML = '';
|
||||
SCOPES.forEach(function(scope) {
|
||||
(SCOPES_BY_KIND[activeKeyKind] || []).forEach(function(scope) {
|
||||
var node = tmpl.content.cloneNode(true);
|
||||
var input = node.querySelector('input');
|
||||
input.dataset.scope = scope;
|
||||
@@ -242,11 +257,49 @@ function renderScopes() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderKeyKindTabs() {
|
||||
document.querySelectorAll('[data-key-kind]').forEach(function(button) {
|
||||
var kind = button.dataset.keyKind;
|
||||
button.classList.toggle('active', kind === activeKeyKind);
|
||||
button.setAttribute('aria-selected', kind === activeKeyKind ? 'true' : 'false');
|
||||
});
|
||||
var hint = document.getElementById('key-kind-hint');
|
||||
if (hint) {
|
||||
hint.textContent = activeKeyKind === 'approval'
|
||||
? tKey('apikeys.kind.approval_hint')
|
||||
: tKey('apikeys.kind.mcp_hint');
|
||||
}
|
||||
renderScopeReference();
|
||||
setCreateButtonState();
|
||||
}
|
||||
|
||||
function renderScopeReference() {
|
||||
var grid = document.getElementById('scope-reference-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
(SCOPES_BY_KIND[activeKeyKind] || []).forEach(function(scope) {
|
||||
var item = document.createElement('div');
|
||||
var title = document.createElement('div');
|
||||
title.style.cssText = 'font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;';
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge badge-scope';
|
||||
badge.textContent = scope;
|
||||
title.appendChild(badge);
|
||||
var description = document.createElement('div');
|
||||
description.style.cssText = 'font-size:12px;color:var(--text-muted);line-height:1.55;';
|
||||
description.textContent = tKey(SCOPE_DESC[scope]);
|
||||
item.appendChild(title);
|
||||
item.appendChild(description);
|
||||
grid.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(errorMessage) {
|
||||
var q = search.toLowerCase();
|
||||
var tbody = document.getElementById('keys-tbody');
|
||||
var cardList = document.getElementById('keys-card-list');
|
||||
var rows = KEYS.filter(function(key) {
|
||||
var visibleKeys = KEYS.filter(function(key) { return key.keyKind === activeKeyKind; });
|
||||
var rows = visibleKeys.filter(function(key) {
|
||||
return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q);
|
||||
});
|
||||
var subtitle = document.getElementById('keys-summary-subtitle');
|
||||
@@ -257,8 +310,8 @@ function renderTable(errorMessage) {
|
||||
}
|
||||
|
||||
if (subtitle) {
|
||||
var active = KEYS.filter(function(key) { return key.status === 'active'; }).length;
|
||||
var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).length;
|
||||
var active = visibleKeys.filter(function(key) { return key.status === 'active'; }).length;
|
||||
var revoked = visibleKeys.filter(function(key) { return key.status === 'revoked'; }).length;
|
||||
subtitle.textContent = currentAgentId
|
||||
? tfKey('apikeys.active.subtitle', { active: active, revoked: revoked })
|
||||
: tKey('apikeys.agent.empty_hint');
|
||||
@@ -282,7 +335,7 @@ function renderTable(errorMessage) {
|
||||
td.colSpan = 7;
|
||||
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
|
||||
td.textContent = currentAgentId
|
||||
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
|
||||
? (visibleKeys.length ? tKey('apikeys.empty.search') : emptyTextForKind())
|
||||
: tKey('apikeys.agent.empty_hint');
|
||||
empty.appendChild(td);
|
||||
tbody.appendChild(empty);
|
||||
@@ -349,7 +402,7 @@ function renderKeyCards(rows, errorMessage) {
|
||||
cardList.appendChild(
|
||||
buildKeyCardMessage(
|
||||
currentAgentId
|
||||
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
|
||||
? (visibleKeys.length ? tKey('apikeys.empty.search') : emptyTextForKind())
|
||||
: tKey('apikeys.agent.empty_hint'),
|
||||
false
|
||||
)
|
||||
@@ -424,6 +477,12 @@ function buildKeyCardMessage(text, isError) {
|
||||
return card;
|
||||
}
|
||||
|
||||
function emptyTextForKind() {
|
||||
return activeKeyKind === 'approval'
|
||||
? tKey('apikeys.empty.approval')
|
||||
: tKey('apikeys.empty.none');
|
||||
}
|
||||
|
||||
function buildMetaItem(labelKey, valueText) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'resource-meta-item';
|
||||
@@ -456,7 +515,11 @@ function openModal() {
|
||||
document.getElementById('modal-footer-create').hidden = false;
|
||||
document.getElementById('modal-footer-done').hidden = true;
|
||||
document.getElementById('new-key-name').value = '';
|
||||
selectedScopes = new Set(['read']);
|
||||
selectedScopes = new Set([activeKeyKind === 'approval' ? 'approve' : 'read']);
|
||||
document.getElementById('modal-create-title').textContent = activeKeyKind === 'approval'
|
||||
? tKey('apikeys.modal.title_approval')
|
||||
: tKey('apikeys.modal.title_mcp');
|
||||
document.getElementById('approval-key-warning').hidden = activeKeyKind !== 'approval';
|
||||
renderScopes();
|
||||
modal.classList.add('open');
|
||||
setTimeout(function() {
|
||||
@@ -554,6 +617,16 @@ document.getElementById('agent-select').addEventListener('change', async functio
|
||||
await loadKeys();
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-key-kind]').forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
activeKeyKind = this.dataset.keyKind || 'mcp_client';
|
||||
search = '';
|
||||
document.getElementById('key-search').value = '';
|
||||
renderKeyKindTabs();
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
|
||||
function copyPrefix(prefix) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(prefix).catch(function() {});
|
||||
@@ -564,6 +637,7 @@ function copyPrefix(prefix) {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
renderKeyKindTabs();
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
await loadKeys();
|
||||
window.addEventListener('crank:workspacechange', function() {
|
||||
|
||||
+14
-8
@@ -124,14 +124,6 @@
|
||||
return encoded ? ('?' + encoded) : '';
|
||||
}
|
||||
|
||||
function postBytes(path, bytes, fileName) {
|
||||
return request(API_BASE + path, {
|
||||
method: 'POST',
|
||||
headers: headers(fileName ? { 'X-File-Name': fileName } : {}),
|
||||
body: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
window.CrankApi = {
|
||||
login: function(payload) {
|
||||
return request(AUTH_BASE + '/login', {
|
||||
@@ -244,6 +236,14 @@
|
||||
}
|
||||
);
|
||||
},
|
||||
previewOpenApiImport: function(workspaceId, documentText) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/preview', {
|
||||
document: documentText,
|
||||
});
|
||||
},
|
||||
createOpenApiImport: function(workspaceId, jobId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/' + encodeURIComponent(jobId) + '/create', payload);
|
||||
},
|
||||
listAgents: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents');
|
||||
},
|
||||
@@ -319,6 +319,12 @@
|
||||
getLog: function(workspaceId, logId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId));
|
||||
},
|
||||
listApprovals: function(workspaceId, params) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/approvals' + query(params));
|
||||
},
|
||||
getApproval: function(workspaceId, approvalId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/approvals/' + encodeURIComponent(approvalId));
|
||||
},
|
||||
getUsageOverview: function(workspaceId, params) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
|
||||
},
|
||||
|
||||
@@ -506,6 +506,15 @@ document.addEventListener('alpine:init', function() {
|
||||
window.location.href = (window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/';
|
||||
},
|
||||
|
||||
handleOpenApiImport() {
|
||||
if (window.CrankOpenApiImport) {
|
||||
window.CrankOpenApiImport.open({
|
||||
workspaceId: this.workspaceId,
|
||||
onImported: this.reload.bind(this),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleLogout() {
|
||||
window.CrankAuth.logout();
|
||||
},
|
||||
|
||||
+110
-14
@@ -95,6 +95,12 @@ var TRANSLATIONS = {
|
||||
'apikeys.title': 'Agent Keys',
|
||||
'apikeys.subtitle': 'These keys connect an MCP client to the MCP server and are issued for a specific agent.',
|
||||
'apikeys.new': 'Create key',
|
||||
'apikeys.new_mcp': 'Create MCP client key',
|
||||
'apikeys.new_approval': 'Create approval key',
|
||||
'apikeys.kind.mcp': 'MCP clients',
|
||||
'apikeys.kind.approval': 'Approvals',
|
||||
'apikeys.kind.mcp_hint': 'MCP client keys connect an agent to tools/list and tools/call.',
|
||||
'apikeys.kind.approval_hint': 'Approval keys are used only by an external human confirmation interface.',
|
||||
'apikeys.agent.title': 'Agent selection',
|
||||
'apikeys.agent.subtitle': 'Select the AI agent this key is issued for.',
|
||||
'apikeys.agent.label': 'AI agent',
|
||||
@@ -118,7 +124,12 @@ var TRANSLATIONS = {
|
||||
'apikeys.scope.read': 'Initialize MCP sessions, ping the server, and list tools for the selected AI agent.',
|
||||
'apikeys.scope.write': 'Execute `tools/call` requests against published agent toolsets.',
|
||||
'apikeys.scope.deploy': 'Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.',
|
||||
'apikeys.scope.approve': 'Confirm a pending human approval request for this agent.',
|
||||
'apikeys.scope.deny': 'Reject a pending human approval request for this agent.',
|
||||
'apikeys.scope.read_pending': 'Read pending human approval requests for this agent.',
|
||||
'apikeys.modal.title': 'Create agent key',
|
||||
'apikeys.modal.title_mcp': 'Create MCP client key',
|
||||
'apikeys.modal.title_approval': 'Create approval key',
|
||||
'apikeys.modal.name': 'Key name',
|
||||
'apikeys.modal.name_hint': 'A descriptive label to identify the key. Only visible to admins.',
|
||||
'apikeys.modal.name_placeholder': 'e.g. Production, CI pipeline',
|
||||
@@ -133,6 +144,7 @@ var TRANSLATIONS = {
|
||||
'apikeys.status.revoked': 'Revoked',
|
||||
'apikeys.last_used.never': 'Never',
|
||||
'apikeys.empty.none': 'No API keys yet',
|
||||
'apikeys.empty.approval': 'No approval keys yet. Create one only if this agent has tools that require human confirmation.',
|
||||
'apikeys.empty.search': 'No keys match your search',
|
||||
'apikeys.loading': 'Loading…',
|
||||
'apikeys.error.api': 'Workspace or API is unavailable',
|
||||
@@ -160,6 +172,7 @@ var TRANSLATIONS = {
|
||||
'apikeys.action.revoke': 'Revoke key',
|
||||
'apikeys.action.delete': 'Delete',
|
||||
'apikeys.creating': 'Creating…',
|
||||
'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.',
|
||||
|
||||
// Secrets page
|
||||
'secrets.title': 'Secrets',
|
||||
@@ -268,6 +281,27 @@ var TRANSLATIONS = {
|
||||
'logs.live.off.body': 'Automatic polling is paused.',
|
||||
'logs.refresh.title': 'Logs refreshed',
|
||||
'logs.refresh.body': 'The latest invocation records were loaded for the current workspace.',
|
||||
'approvals.title': 'Human confirmations',
|
||||
'approvals.subtitle': 'Requests waiting for an external user decision and recent results.',
|
||||
'approvals.refresh': 'Refresh',
|
||||
'approvals.refresh.title': 'Confirmations refreshed',
|
||||
'approvals.refresh.body': 'The latest confirmation requests were loaded.',
|
||||
'approvals.loading': 'Loading confirmation requests…',
|
||||
'approvals.empty': 'There are no confirmation requests yet.',
|
||||
'approvals.error.load': 'Failed to load confirmation requests',
|
||||
'approvals.untitled': 'Confirmation request',
|
||||
'approvals.operation': 'Operation',
|
||||
'approvals.agent': 'Agent',
|
||||
'approvals.expires_at': 'Expires',
|
||||
'approvals.updated_at': 'Updated',
|
||||
'approvals.request': 'Request',
|
||||
'approvals.response': 'Result',
|
||||
'approvals.status.pending': 'Pending',
|
||||
'approvals.status.approved': 'Approved',
|
||||
'approvals.status.denied': 'Denied',
|
||||
'approvals.status.expired': 'Expired',
|
||||
'approvals.status.completed': 'Completed',
|
||||
'approvals.status.failed': 'Failed',
|
||||
|
||||
// Usage page
|
||||
'usage.title': 'Usage',
|
||||
@@ -560,6 +594,24 @@ var TRANSLATIONS = {
|
||||
'wizard.step5.execution': 'Execution settings',
|
||||
'wizard.step5.exec_title': 'Request execution',
|
||||
'wizard.step5.exec_subtitle': 'Timeout, retry count and authorization profile',
|
||||
'wizard.approval.title': 'Human confirmation',
|
||||
'wizard.approval.subtitle': 'Enable this for actions that must not run without an explicit user decision.',
|
||||
'wizard.approval.required_label': 'Require confirmation before execution',
|
||||
'wizard.approval.required_desc': 'The MCP client receives a pending request, and the action runs only after confirmation through a separate approval endpoint.',
|
||||
'wizard.approval.mode': 'Confirmation mechanism',
|
||||
'wizard.approval.mode.custom': 'Custom MCP Approval',
|
||||
'wizard.approval.mode.elicitation': 'MCP Elicitation',
|
||||
'wizard.approval.custom_title': 'Custom MCP Approval',
|
||||
'wizard.approval.custom_body': 'Crank returns approval_required to the MCP client with approval_id, approval_url and approve/deny links. Your MCP client must handle this response, show confirmation to the user and send the decision to the approval endpoint. The approval endpoint requires a separate agent approval key.',
|
||||
'wizard.approval.elicitation_title': 'MCP Elicitation',
|
||||
'wizard.approval.elicitation_body': 'Crank asks for confirmation through standard MCP Elicitation. The MCP client must support the elicitation capability. No separate approval key is used: the user decision returns through the current MCP session.',
|
||||
'wizard.approval.elicitation_message': 'Message for the MCP client',
|
||||
'wizard.approval.elicitation_message_placeholder': 'Confirm operation execution.',
|
||||
'wizard.approval.elicitation_message_hint': 'Short protocol message. The MCP client still controls the confirmation UI.',
|
||||
'wizard.approval.ttl': 'How long to wait for confirmation',
|
||||
'wizard.approval.show_payload': 'Send call parameters to the confirmation flow',
|
||||
'wizard.approval.payload_title': 'Confirmation parameters',
|
||||
'wizard.approval.payload_body': 'When enabled, Crank sends call parameters into the approval flow so the external UI or MCP client can show the user what action is being confirmed. Disable this option if parameters contain sensitive data.',
|
||||
'wizard.step5.security_level_title': 'Operation security',
|
||||
'wizard.step5.community_security_note': '',
|
||||
'wizard.step5.live_title': 'Check and publish',
|
||||
@@ -796,13 +848,9 @@ var TRANSLATIONS = {
|
||||
'agents.toast.endpoint_title': 'MCP endpoint copied',
|
||||
|
||||
// Demo content
|
||||
'demo.agent.revops-copilot.display_name': 'RevOps Copilot',
|
||||
'demo.agent.support-triage.display_name': 'Support Triage',
|
||||
'demo.operation.crm_create_lead.display_name': 'Create CRM Lead',
|
||||
'demo.operation.crm_create_lead.description': 'Create a lead record in the CRM system.',
|
||||
'demo.operation.support_lookup_ticket.display_name': 'Lookup Support Ticket',
|
||||
'demo.operation.marketing_archive_contact.display_name': 'Archive Marketing Contact',
|
||||
'demo.operation.marketing_archive_contact.description': 'Legacy archived flow kept for audit only.',
|
||||
'demo.agent.currency-rates.display_name': 'Currency rates',
|
||||
'demo.operation.frankfurter_latest_rate.display_name': 'Latest currency rate',
|
||||
'demo.operation.frankfurter_latest_rate.description': 'Returns the latest available exchange rate through Frankfurter.',
|
||||
|
||||
// Login
|
||||
'login.title': 'Sign in',
|
||||
@@ -909,6 +957,12 @@ var TRANSLATIONS = {
|
||||
'apikeys.title': 'Ключи агентов',
|
||||
'apikeys.subtitle': 'Эти ключи используются для подключения MCP клиента к MCP серверу и выдаются на конкретного агента.',
|
||||
'apikeys.new': 'Создать ключ',
|
||||
'apikeys.new_mcp': 'Создать ключ MCP-клиента',
|
||||
'apikeys.new_approval': 'Создать ключ подтверждения',
|
||||
'apikeys.kind.mcp': 'MCP-клиенты',
|
||||
'apikeys.kind.approval': 'Подтверждения',
|
||||
'apikeys.kind.mcp_hint': 'Ключи MCP-клиентов используются для подключения к tools/list и tools/call агента.',
|
||||
'apikeys.kind.approval_hint': 'Ключи подтверждения используются только внешним интерфейсом, где человек подтверждает действие.',
|
||||
'apikeys.agent.title': 'Выбор агента',
|
||||
'apikeys.agent.subtitle': 'Выберите AI-агента для которого выпускается ключ.',
|
||||
'apikeys.agent.label': 'AI-агент',
|
||||
@@ -932,7 +986,12 @@ var TRANSLATIONS = {
|
||||
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.',
|
||||
'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.',
|
||||
'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.',
|
||||
'apikeys.scope.approve': 'Подтверждение ожидающего запроса для этого агента.',
|
||||
'apikeys.scope.deny': 'Отклонение ожидающего запроса для этого агента.',
|
||||
'apikeys.scope.read_pending': 'Получение списка запросов, ожидающих подтверждения для этого агента.',
|
||||
'apikeys.modal.title': 'Создать ключ агента',
|
||||
'apikeys.modal.title_mcp': 'Создать ключ MCP-клиента',
|
||||
'apikeys.modal.title_approval': 'Создать ключ подтверждения',
|
||||
'apikeys.modal.name': 'Имя ключа',
|
||||
'apikeys.modal.name_hint': 'Понятная метка для идентификации ключа. Видна только администраторам.',
|
||||
'apikeys.modal.name_placeholder': 'например, Production, CI pipeline',
|
||||
@@ -947,6 +1006,7 @@ var TRANSLATIONS = {
|
||||
'apikeys.status.revoked': 'Отозван',
|
||||
'apikeys.last_used.never': 'Никогда',
|
||||
'apikeys.empty.none': 'API-ключей пока нет',
|
||||
'apikeys.empty.approval': 'Ключей подтверждения пока нет. Они нужны только агентам с инструментами, требующими подтверждения человеком.',
|
||||
'apikeys.empty.search': 'Нет ключей по текущему поиску',
|
||||
'apikeys.loading': 'Загрузка…',
|
||||
'apikeys.error.api': 'Воркспейс или API недоступен',
|
||||
@@ -974,6 +1034,7 @@ var TRANSLATIONS = {
|
||||
'apikeys.action.revoke': 'Отозвать ключ',
|
||||
'apikeys.action.delete': 'Удалить',
|
||||
'apikeys.creating': 'Создание…',
|
||||
'apikeys.approval.warning': 'Не передавайте этот ключ LLM или MCP-клиенту. Он нужен только внешнему интерфейсу, где человек подтверждает действие.',
|
||||
|
||||
// Secrets page
|
||||
'secrets.title': 'Секреты',
|
||||
@@ -1084,6 +1145,27 @@ var TRANSLATIONS = {
|
||||
'logs.live.off.body': 'Автоматический опрос остановлен.',
|
||||
'logs.refresh.title': 'Логи обновлены',
|
||||
'logs.refresh.body': 'Получены последние записи вызовов для текущего воркспейса.',
|
||||
'approvals.title': 'Подтверждения человеком',
|
||||
'approvals.subtitle': 'Заявки, которые ожидают решения пользователя, и последние результаты.',
|
||||
'approvals.refresh': 'Обновить',
|
||||
'approvals.refresh.title': 'Подтверждения обновлены',
|
||||
'approvals.refresh.body': 'Получены последние заявки на подтверждение.',
|
||||
'approvals.loading': 'Загрузка заявок на подтверждение…',
|
||||
'approvals.empty': 'Заявок на подтверждение пока нет.',
|
||||
'approvals.error.load': 'Не удалось загрузить заявки на подтверждение',
|
||||
'approvals.untitled': 'Заявка на подтверждение',
|
||||
'approvals.operation': 'Операция',
|
||||
'approvals.agent': 'Агент',
|
||||
'approvals.expires_at': 'Истекает',
|
||||
'approvals.updated_at': 'Обновлено',
|
||||
'approvals.request': 'Запрос',
|
||||
'approvals.response': 'Результат',
|
||||
'approvals.status.pending': 'Ожидает',
|
||||
'approvals.status.approved': 'Подтверждено',
|
||||
'approvals.status.denied': 'Отклонено',
|
||||
'approvals.status.expired': 'Истекло',
|
||||
'approvals.status.completed': 'Выполнено',
|
||||
'approvals.status.failed': 'Ошибка',
|
||||
|
||||
// Usage page
|
||||
'usage.title': 'Использование',
|
||||
@@ -1376,6 +1458,24 @@ var TRANSLATIONS = {
|
||||
'wizard.step5.execution': 'Параметры выполнения',
|
||||
'wizard.step5.exec_title': 'Выполнение запроса',
|
||||
'wizard.step5.exec_subtitle': 'Время ожидания, повторные попытки и профиль авторизации',
|
||||
'wizard.approval.title': 'Подтверждение человеком',
|
||||
'wizard.approval.subtitle': 'Включайте для действий, которые нельзя выполнять без явного решения пользователя.',
|
||||
'wizard.approval.required_label': 'Требовать подтверждение перед выполнением',
|
||||
'wizard.approval.required_desc': 'Инструмент не выполнится сразу. Crank сначала запросит подтверждение выбранным способом.',
|
||||
'wizard.approval.mode': 'Механизм подтверждения',
|
||||
'wizard.approval.mode.custom': 'Custom MCP Approval',
|
||||
'wizard.approval.mode.elicitation': 'MCP Elicitation',
|
||||
'wizard.approval.custom_title': 'Custom MCP Approval',
|
||||
'wizard.approval.custom_body': 'Crank вернёт MCP-клиенту ответ о необходимости подтверждения, идентификатор заявки и адреса для подтверждения или отказа. Ваш MCP-клиент должен распознать такой ответ, показать пользователю окно подтверждения и отправить решение на адрес подтверждения. Для этого адреса нужен отдельный ключ подтверждения агента.',
|
||||
'wizard.approval.elicitation_title': 'MCP Elicitation',
|
||||
'wizard.approval.elicitation_body': 'Crank запросит подтверждение стандартным способом MCP Elicitation. MCP-клиент должен поддерживать эту возможность. Отдельный ключ подтверждения не используется: решение пользователя возвращается по текущему MCP-подключению.',
|
||||
'wizard.approval.elicitation_message': 'Сообщение для MCP-клиента',
|
||||
'wizard.approval.elicitation_message_placeholder': 'Подтвердите выполнение операции.',
|
||||
'wizard.approval.elicitation_message_hint': 'Короткое сообщение для MCP-клиента. Внешний вид окна подтверждения определяет сам клиент.',
|
||||
'wizard.approval.ttl': 'Сколько ждать подтверждение',
|
||||
'wizard.approval.show_payload': 'Передавать параметры вызова в подтверждение',
|
||||
'wizard.approval.payload_title': 'Параметры подтверждения',
|
||||
'wizard.approval.payload_body': 'Если включено, Crank передаст параметры вызова вместе с запросом подтверждения. Так внешний интерфейс или MCP-клиент сможет показать пользователю, какое действие он подтверждает. Если параметры содержат чувствительные данные, выключите эту опцию.',
|
||||
'wizard.step5.security_level_title': 'Защита операции',
|
||||
'wizard.step5.community_security_note': '',
|
||||
'wizard.step5.live_title': 'Проверка и публикация',
|
||||
@@ -1612,13 +1712,9 @@ var TRANSLATIONS = {
|
||||
'agents.toast.endpoint_title': 'MCP endpoint скопирован',
|
||||
|
||||
// Demo content
|
||||
'demo.agent.revops-copilot.display_name': 'Помощник RevOps',
|
||||
'demo.agent.support-triage.display_name': 'Триаж поддержки',
|
||||
'demo.operation.crm_create_lead.display_name': 'Создать CRM-лид',
|
||||
'demo.operation.crm_create_lead.description': 'Создает запись лида в CRM-системе.',
|
||||
'demo.operation.support_lookup_ticket.display_name': 'Найти тикет поддержки',
|
||||
'demo.operation.marketing_archive_contact.display_name': 'Архивировать маркетинговый контакт',
|
||||
'demo.operation.marketing_archive_contact.description': 'Устаревший архивный сценарий, оставленный только для аудита.',
|
||||
'demo.agent.currency-rates.display_name': 'Курсы валют',
|
||||
'demo.operation.frankfurter_latest_rate.display_name': 'Последний курс валюты',
|
||||
'demo.operation.frankfurter_latest_rate.description': 'Возвращает последний доступный курс валюты через Frankfurter.',
|
||||
|
||||
// Login
|
||||
'login.title': 'Войти',
|
||||
|
||||
+168
-4
@@ -11,9 +11,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
workspaceId: null,
|
||||
loading: false,
|
||||
loadError: '',
|
||||
approvals: [],
|
||||
approvalsLoading: false,
|
||||
approvalsError: '',
|
||||
};
|
||||
|
||||
var logList = document.getElementById('log-list');
|
||||
var approvalList = document.getElementById('approval-list');
|
||||
var approvalRefreshBtn = document.getElementById('approval-refresh-btn');
|
||||
var logSearch = document.getElementById('log-search');
|
||||
var refreshBtn = document.getElementById('refresh-btn');
|
||||
var timeRangeSel = document.getElementById('time-range');
|
||||
@@ -54,6 +59,19 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
return date.toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
function formatDateTime(timestamp) {
|
||||
if (!timestamp) {
|
||||
return '';
|
||||
}
|
||||
var date = new Date(timestamp);
|
||||
return date.toLocaleString(window.CrankLocale || undefined, {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
@@ -111,6 +129,109 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApproval(record) {
|
||||
var approval = record.approval || record;
|
||||
return {
|
||||
id: approval.id,
|
||||
agentId: approval.agent_id,
|
||||
operationId: approval.operation_id,
|
||||
operationVersion: approval.operation_version,
|
||||
status: approval.status,
|
||||
riskLevel: approval.risk_level,
|
||||
requestPayload: approval.request_payload,
|
||||
responsePayload: approval.response_payload,
|
||||
createdAt: approval.created_at,
|
||||
expiresAt: approval.expires_at,
|
||||
decidedAt: approval.decided_at,
|
||||
note: approval.decision_note,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalStatusLabel(status) {
|
||||
var key = 'approvals.status.' + status;
|
||||
var translated = tKey(key);
|
||||
return translated === key ? status : translated;
|
||||
}
|
||||
|
||||
function renderApprovals() {
|
||||
if (!approvalList) {
|
||||
return;
|
||||
}
|
||||
|
||||
approvalList.innerHTML = '';
|
||||
|
||||
if (state.approvalsLoading && state.approvals.length === 0) {
|
||||
var loading = element('div', 'approval-empty', tKey('approvals.loading'));
|
||||
approvalList.appendChild(loading);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.approvalsError) {
|
||||
var error = element('div', 'approval-empty approval-empty-error', state.approvalsError);
|
||||
approvalList.appendChild(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.approvals.length) {
|
||||
var empty = element('div', 'approval-empty', tKey('approvals.empty'));
|
||||
approvalList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
var fragment = document.createDocumentFragment();
|
||||
state.approvals.forEach(function (item) {
|
||||
var card = element('article', 'approval-item approval-' + item.status);
|
||||
|
||||
var header = element('div', 'approval-item-header');
|
||||
var titleWrap = element('div', 'approval-item-title-wrap');
|
||||
titleWrap.appendChild(element('div', 'approval-item-title', tKey('approvals.untitled') + ' ' + item.id));
|
||||
|
||||
var meta = element('div', 'approval-item-meta');
|
||||
meta.textContent = [
|
||||
tKey('approvals.operation') + ': ' + item.operationId + ' v' + item.operationVersion,
|
||||
tKey('approvals.agent') + ': ' + item.agentId,
|
||||
].join(' · ');
|
||||
titleWrap.appendChild(meta);
|
||||
header.appendChild(titleWrap);
|
||||
|
||||
var badge = element('span', 'approval-status approval-status-' + item.status, approvalStatusLabel(item.status));
|
||||
header.appendChild(badge);
|
||||
card.appendChild(header);
|
||||
|
||||
var timing = element('div', 'approval-timing');
|
||||
timing.textContent = item.status === 'pending'
|
||||
? tKey('approvals.expires_at') + ': ' + formatDateTime(item.expiresAt)
|
||||
: tKey('approvals.updated_at') + ': ' + formatDateTime(item.decidedAt || item.createdAt);
|
||||
card.appendChild(timing);
|
||||
|
||||
var payloadGrid = element('div', 'approval-payload-grid');
|
||||
var requestBlock = element('div', 'approval-payload');
|
||||
requestBlock.appendChild(element('div', 'approval-payload-label', tKey('approvals.request')));
|
||||
var requestPre = element('pre', 'approval-payload-code');
|
||||
requestPre.textContent = formatJson(item.requestPayload);
|
||||
requestBlock.appendChild(requestPre);
|
||||
payloadGrid.appendChild(requestBlock);
|
||||
|
||||
if (item.responsePayload !== null && item.responsePayload !== undefined) {
|
||||
var responseBlock = element('div', 'approval-payload');
|
||||
responseBlock.appendChild(element('div', 'approval-payload-label', tKey('approvals.response')));
|
||||
var responsePre = element('pre', 'approval-payload-code');
|
||||
responsePre.textContent = formatJson(item.responsePayload);
|
||||
responseBlock.appendChild(responsePre);
|
||||
payloadGrid.appendChild(responseBlock);
|
||||
}
|
||||
card.appendChild(payloadGrid);
|
||||
|
||||
if (item.note) {
|
||||
card.appendChild(element('div', 'approval-note', item.note));
|
||||
}
|
||||
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
|
||||
approvalList.appendChild(fragment);
|
||||
}
|
||||
|
||||
function renderEmpty(title, message) {
|
||||
logList.innerHTML = '';
|
||||
var empty = element('div', 'empty-state');
|
||||
@@ -306,6 +427,39 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApprovals() {
|
||||
if (!window.CrankApi) {
|
||||
state.approvalsError = tKey('logs.error.api');
|
||||
renderApprovals();
|
||||
return;
|
||||
}
|
||||
|
||||
state.workspaceId = currentWorkspaceId();
|
||||
if (!state.workspaceId) {
|
||||
state.approvalsError = tKey('logs.error.workspace');
|
||||
renderApprovals();
|
||||
return;
|
||||
}
|
||||
|
||||
state.approvalsLoading = true;
|
||||
state.approvalsError = '';
|
||||
renderApprovals();
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.listApprovals(state.workspaceId, { limit: 20 });
|
||||
state.approvals = (response && response.items ? response.items : []).map(normalizeApproval);
|
||||
} catch (error) {
|
||||
state.approvalsError = error.message || tKey('approvals.error.load');
|
||||
} finally {
|
||||
state.approvalsLoading = false;
|
||||
renderApprovals();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOperationalData() {
|
||||
await Promise.all([loadLogs(), loadApprovals()]);
|
||||
}
|
||||
|
||||
async function loadLogDetail(logId) {
|
||||
if (!window.CrankApi || !state.workspaceId || state.details[logId]) {
|
||||
return;
|
||||
@@ -343,7 +497,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (!state.liveMode) {
|
||||
return;
|
||||
}
|
||||
state.timer = setInterval(loadLogs, 4000);
|
||||
state.timer = setInterval(refreshOperationalData, 4000);
|
||||
}
|
||||
|
||||
function toggleLive() {
|
||||
@@ -386,6 +540,16 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
}
|
||||
|
||||
if (approvalRefreshBtn) {
|
||||
approvalRefreshBtn.addEventListener('click', function () {
|
||||
loadApprovals().then(function () {
|
||||
if (!state.approvalsError && window.CrankUi) {
|
||||
window.CrankUi.info(tKey('approvals.refresh.body'), tKey('approvals.refresh.title'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (timeRangeSel) {
|
||||
timeRangeSel.value = state.period;
|
||||
timeRangeSel.addEventListener('change', function () {
|
||||
@@ -405,15 +569,15 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
window.addEventListener('crank:workspacechange', function () {
|
||||
state.details = {};
|
||||
state.openId = null;
|
||||
loadLogs();
|
||||
refreshOperationalData();
|
||||
});
|
||||
|
||||
setLiveState();
|
||||
startPolling();
|
||||
|
||||
if (window.whenWorkspacesReady) {
|
||||
window.whenWorkspacesReady().finally(loadLogs);
|
||||
window.whenWorkspacesReady().finally(refreshOperationalData);
|
||||
} else {
|
||||
loadLogs();
|
||||
refreshOperationalData();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
(function() {
|
||||
var state = {
|
||||
workspaceId: null,
|
||||
onImported: null,
|
||||
jobId: null,
|
||||
preview: null,
|
||||
filterQuery: '',
|
||||
filterMethod: '',
|
||||
};
|
||||
|
||||
function qs(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function setStatus(message, isError) {
|
||||
var node = qs('openapi-import-status');
|
||||
if (!node) return;
|
||||
node.textContent = message || '';
|
||||
node.classList.toggle('error', !!isError);
|
||||
}
|
||||
|
||||
function selectedKeys() {
|
||||
return Array.from(document.querySelectorAll('[data-openapi-operation]:checked'))
|
||||
.map(function(input) { return input.value; });
|
||||
}
|
||||
|
||||
function operationRows() {
|
||||
return Array.from(document.querySelectorAll('.openapi-import-operation'));
|
||||
}
|
||||
|
||||
function visibleOperationRows() {
|
||||
return operationRows().filter(function(row) { return !row.hidden; });
|
||||
}
|
||||
|
||||
function updateSelection() {
|
||||
var node = qs('openapi-import-selection');
|
||||
var selectedCount = selectedKeys().length;
|
||||
var totalCount = document.querySelectorAll('[data-openapi-operation]').length;
|
||||
var visibleCount = visibleOperationRows().length;
|
||||
if (node) node.textContent = 'Выбрано: ' + selectedCount + ' из ' + totalCount + ', показано: ' + visibleCount;
|
||||
updateGroupSelectionState();
|
||||
}
|
||||
|
||||
function updateGroupSelectionState() {
|
||||
document.querySelectorAll('.openapi-import-group').forEach(function(groupNode) {
|
||||
var rows = Array.from(groupNode.querySelectorAll('.openapi-import-operation'));
|
||||
var visibleRows = rows.filter(function(row) { return !row.hidden; });
|
||||
var visibleInputs = visibleRows
|
||||
.map(function(row) { return row.querySelector('[data-openapi-operation]'); })
|
||||
.filter(Boolean);
|
||||
var selectedVisible = visibleInputs.filter(function(input) { return input.checked; }).length;
|
||||
var selectedTotal = rows
|
||||
.map(function(row) { return row.querySelector('[data-openapi-operation]'); })
|
||||
.filter(function(input) { return input && input.checked; }).length;
|
||||
var checkbox = groupNode.querySelector('[data-openapi-group]');
|
||||
if (checkbox) {
|
||||
checkbox.indeterminate = selectedVisible > 0 && selectedVisible < visibleInputs.length;
|
||||
checkbox.checked = visibleInputs.length > 0 && selectedVisible === visibleInputs.length;
|
||||
}
|
||||
var counter = groupNode.querySelector('[data-openapi-group-count]');
|
||||
if (counter) {
|
||||
counter.textContent = 'выбрано ' + selectedTotal + ' из ' + rows.length + ', показано ' + visibleRows.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
var query = state.filterQuery.toLowerCase();
|
||||
var method = state.filterMethod.toLowerCase();
|
||||
document.querySelectorAll('.openapi-import-group').forEach(function(groupNode) {
|
||||
var visibleInGroup = 0;
|
||||
groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) {
|
||||
var rowMethod = String(row.dataset.openapiMethod || '').toLowerCase();
|
||||
var searchText = String(row.dataset.openapiSearch || '').toLowerCase();
|
||||
var visible = (!method || rowMethod === method) && (!query || searchText.indexOf(query) >= 0);
|
||||
row.hidden = !visible;
|
||||
if (visible) visibleInGroup += 1;
|
||||
});
|
||||
groupNode.hidden = visibleInGroup === 0;
|
||||
});
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function setVisibleSelection(checked) {
|
||||
visibleOperationRows().forEach(function(row) {
|
||||
var input = row.querySelector('[data-openapi-operation]');
|
||||
if (input) input.checked = checked;
|
||||
});
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function renderPreview(response) {
|
||||
state.jobId = response.job_id;
|
||||
state.preview = response.preview;
|
||||
state.filterQuery = '';
|
||||
state.filterMethod = '';
|
||||
var preview = response.preview;
|
||||
qs('openapi-import-preview-panel').hidden = false;
|
||||
qs('openapi-import-result').hidden = true;
|
||||
qs('openapi-import-result').innerHTML = '';
|
||||
qs('openapi-import-source').textContent = [
|
||||
preview.source.title || 'Imported API',
|
||||
preview.source.format + (preview.source.version ? ' ' + preview.source.version : ''),
|
||||
'методов: ' + preview.groups.reduce(function(sum, group) { return sum + group.operations.length; }, 0),
|
||||
].join(' · ');
|
||||
|
||||
var serverSelect = qs('openapi-import-server');
|
||||
serverSelect.innerHTML = '';
|
||||
var servers = preview.source.servers && preview.source.servers.length ? preview.source.servers : [''];
|
||||
servers.forEach(function(server) {
|
||||
var option = document.createElement('option');
|
||||
option.value = server;
|
||||
option.textContent = server || 'Указать позже';
|
||||
serverSelect.appendChild(option);
|
||||
});
|
||||
|
||||
var searchInput = qs('openapi-import-search');
|
||||
var methodFilter = qs('openapi-import-method-filter');
|
||||
if (searchInput) searchInput.value = '';
|
||||
if (methodFilter) {
|
||||
methodFilter.innerHTML = '<option value="">Все методы</option>';
|
||||
Array.from(new Set(preview.groups.flatMap(function(group) {
|
||||
return group.operations.map(function(operation) { return operation.method; });
|
||||
}))).sort().forEach(function(method) {
|
||||
var option = document.createElement('option');
|
||||
option.value = method;
|
||||
option.textContent = method;
|
||||
methodFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
var groupsNode = qs('openapi-import-groups');
|
||||
groupsNode.innerHTML = '';
|
||||
if (preview.findings && preview.findings.length) {
|
||||
var documentFindings = document.createElement('div');
|
||||
documentFindings.className = 'openapi-import-document-findings';
|
||||
documentFindings.innerHTML = preview.findings.map(function(finding) {
|
||||
return renderFinding(finding);
|
||||
}).join('');
|
||||
groupsNode.appendChild(documentFindings);
|
||||
}
|
||||
preview.groups.forEach(function(group) {
|
||||
var groupNode = document.createElement('section');
|
||||
groupNode.className = 'openapi-import-group';
|
||||
var header = document.createElement('div');
|
||||
header.className = 'openapi-import-group-header';
|
||||
header.innerHTML = '<span>' + escapeHtml(group.title) + ' · ' + group.operations.length + '</span>'
|
||||
+ '<span class="openapi-import-group-count" data-openapi-group-count></span>'
|
||||
+ '<label class="openapi-import-group-toggle"><input type="checkbox" data-openapi-group checked> Выбрать группу</label>';
|
||||
groupNode.appendChild(header);
|
||||
|
||||
group.operations.forEach(function(operation) {
|
||||
var row = document.createElement('label');
|
||||
row.className = 'openapi-import-operation';
|
||||
row.dataset.openapiMethod = operation.method;
|
||||
row.dataset.openapiSearch = [
|
||||
group.title,
|
||||
operation.key,
|
||||
operation.suggested_name,
|
||||
operation.suggested_display_name,
|
||||
operation.path,
|
||||
operation.method,
|
||||
].join(' ');
|
||||
row.innerHTML = [
|
||||
'<input type="checkbox" data-openapi-operation value="' + escapeHtml(operation.key) + '" checked>',
|
||||
'<div><div class="openapi-import-operation-title">' + escapeHtml(operation.suggested_display_name) + '</div>',
|
||||
'<div class="openapi-import-operation-meta"><code>' + escapeHtml(operation.suggested_name) + '</code> · ' + escapeHtml(operation.path) + ' · входов: ' + operation.input_fields + ' · выходов: ' + operation.output_fields + '</div></div>',
|
||||
'<span class="openapi-import-method">' + escapeHtml(operation.method) + '</span>',
|
||||
].join('');
|
||||
row.appendChild(renderOperationMappingPreview(operation));
|
||||
if (operation.findings && operation.findings.length) {
|
||||
var findings = document.createElement('div');
|
||||
findings.className = 'openapi-import-findings';
|
||||
findings.innerHTML = operation.findings.map(function(finding) {
|
||||
return renderFinding(finding);
|
||||
}).join('');
|
||||
row.appendChild(findings);
|
||||
}
|
||||
groupNode.appendChild(row);
|
||||
});
|
||||
var groupCheckbox = groupNode.querySelector('[data-openapi-group]');
|
||||
groupCheckbox.addEventListener('change', function() {
|
||||
groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) {
|
||||
if (row.hidden) return;
|
||||
var input = row.querySelector('[data-openapi-operation]');
|
||||
if (input) input.checked = groupCheckbox.checked;
|
||||
});
|
||||
updateSelection();
|
||||
});
|
||||
groupsNode.appendChild(groupNode);
|
||||
});
|
||||
groupsNode.querySelectorAll('[data-openapi-operation]').forEach(function(input) {
|
||||
input.addEventListener('change', updateSelection);
|
||||
});
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function renderOperationMappingPreview(operation) {
|
||||
var details = document.createElement('div');
|
||||
details.className = 'openapi-import-mapping-preview';
|
||||
|
||||
var request = summarizeRequestMapping(operation);
|
||||
[
|
||||
['Path', request.path],
|
||||
['Query', request.query],
|
||||
['Header', request.headers],
|
||||
['Body', request.body],
|
||||
].forEach(function(entry) {
|
||||
appendMappingGroup(details, entry[0], entry[1]);
|
||||
});
|
||||
appendMappingGroup(details, 'Ответ', summarizeResponseMapping(operation));
|
||||
return details;
|
||||
}
|
||||
|
||||
function summarizeRequestMapping(operation) {
|
||||
var result = {
|
||||
path: [],
|
||||
query: [],
|
||||
headers: [],
|
||||
body: [],
|
||||
};
|
||||
var rules = operation
|
||||
&& operation.draft
|
||||
&& operation.draft.input_mapping
|
||||
&& Array.isArray(operation.draft.input_mapping.rules)
|
||||
? operation.draft.input_mapping.rules
|
||||
: [];
|
||||
|
||||
rules.forEach(function(rule) {
|
||||
var target = String(rule.target || '');
|
||||
if (target.indexOf('$.request.path.') === 0) {
|
||||
result.path.push(target.replace('$.request.path.', ''));
|
||||
} else if (target.indexOf('$.request.query.') === 0) {
|
||||
result.query.push(target.replace('$.request.query.', ''));
|
||||
} else if (target.indexOf('$.request.headers.') === 0) {
|
||||
result.headers.push(target.replace('$.request.headers.', ''));
|
||||
} else if (target.indexOf('$.request.body.') === 0) {
|
||||
result.body.push(target.replace('$.request.body.', ''));
|
||||
} else if (target === '$.request.body') {
|
||||
result.body.push('body');
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function summarizeResponseMapping(operation) {
|
||||
var rules = operation
|
||||
&& operation.draft
|
||||
&& operation.draft.output_mapping
|
||||
&& Array.isArray(operation.draft.output_mapping.rules)
|
||||
? operation.draft.output_mapping.rules
|
||||
: [];
|
||||
|
||||
return rules.map(function(rule) {
|
||||
var target = String(rule.target || '').replace('$.output.', '').replace('$.output', 'result');
|
||||
var source = String(rule.source || '').replace('$.response.body.', '').replace('$.response.body', 'body');
|
||||
return target + ' ← ' + source;
|
||||
});
|
||||
}
|
||||
|
||||
function appendMappingGroup(root, label, values) {
|
||||
if (!values || !values.length) return;
|
||||
var group = document.createElement('div');
|
||||
group.className = 'openapi-import-mapping-group';
|
||||
|
||||
var title = document.createElement('span');
|
||||
title.className = 'openapi-import-mapping-label';
|
||||
title.textContent = label;
|
||||
group.appendChild(title);
|
||||
|
||||
values.slice(0, 8).forEach(function(value) {
|
||||
var chip = document.createElement('code');
|
||||
chip.className = 'openapi-import-mapping-chip';
|
||||
chip.textContent = value;
|
||||
group.appendChild(chip);
|
||||
});
|
||||
if (values.length > 8) {
|
||||
var more = document.createElement('span');
|
||||
more.className = 'openapi-import-mapping-more';
|
||||
more.textContent = '+' + (values.length - 8);
|
||||
group.appendChild(more);
|
||||
}
|
||||
root.appendChild(group);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderFinding(finding) {
|
||||
var severity = String(finding && finding.severity || 'warning').toLowerCase();
|
||||
var label = severity === 'info' ? 'i' : severity === 'error' ? '×' : '!';
|
||||
return '<span class="openapi-import-finding openapi-import-finding-' + escapeHtml(severity) + '">'
|
||||
+ '<strong>' + label + '</strong> '
|
||||
+ escapeHtml(finding.message)
|
||||
+ '</span>';
|
||||
}
|
||||
|
||||
function previewOperationByName(name) {
|
||||
if (!state.preview || !state.preview.groups) return null;
|
||||
for (var groupIndex = 0; groupIndex < state.preview.groups.length; groupIndex += 1) {
|
||||
var operations = state.preview.groups[groupIndex].operations || [];
|
||||
for (var operationIndex = 0; operationIndex < operations.length; operationIndex += 1) {
|
||||
if (operations[operationIndex].suggested_name === name) {
|
||||
return operations[operationIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wizardHref(operationId) {
|
||||
return ((window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/')
|
||||
+ '?mode=edit&operationId=' + encodeURIComponent(operationId);
|
||||
}
|
||||
|
||||
function appendFindingNodes(root, findings) {
|
||||
if (!findings || !findings.length) {
|
||||
var empty = document.createElement('span');
|
||||
empty.className = 'openapi-import-result-ok';
|
||||
empty.textContent = 'Критичных замечаний нет';
|
||||
root.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
findings.slice(0, 4).forEach(function(finding) {
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = renderFinding(finding);
|
||||
root.appendChild(wrapper.firstElementChild);
|
||||
});
|
||||
if (findings.length > 4) {
|
||||
var more = document.createElement('span');
|
||||
more.className = 'openapi-import-result-more';
|
||||
more.textContent = '+' + (findings.length - 4) + ' еще';
|
||||
root.appendChild(more);
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
if (!state.workspaceId) {
|
||||
setStatus('Не выбран workspace.', true);
|
||||
return;
|
||||
}
|
||||
var documentText = qs('openapi-import-document').value.trim();
|
||||
if (!documentText) {
|
||||
setStatus('Вставьте документ OpenAPI/Swagger или выберите файл.', true);
|
||||
return;
|
||||
}
|
||||
setStatus('Разбираю документ...');
|
||||
qs('openapi-import-preview').disabled = true;
|
||||
try {
|
||||
var response = await window.CrankApi.previewOpenApiImport(state.workspaceId, documentText);
|
||||
renderPreview(response);
|
||||
setStatus('Документ разобран. Выберите методы для импорта.');
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось разобрать документ.', true);
|
||||
} finally {
|
||||
qs('openapi-import-preview').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDrafts() {
|
||||
var keys = selectedKeys();
|
||||
if (!keys.length) {
|
||||
setStatus('Выберите хотя бы один метод.', true);
|
||||
return;
|
||||
}
|
||||
if (!state.jobId) {
|
||||
setStatus('Сначала разберите OpenAPI документ.', true);
|
||||
return;
|
||||
}
|
||||
var serverUrl = (qs('openapi-import-server-custom').value || qs('openapi-import-server').value || '').trim();
|
||||
if (!serverUrl) {
|
||||
setStatus('Укажите base URL для создаваемых операций.', true);
|
||||
return;
|
||||
}
|
||||
setStatus('Создаю черновики...');
|
||||
qs('openapi-import-create').disabled = true;
|
||||
try {
|
||||
var response = await window.CrankApi.createOpenApiImport(state.workspaceId, state.jobId, {
|
||||
selected_operation_keys: keys,
|
||||
server_url: serverUrl,
|
||||
conflict_mode: qs('openapi-import-conflict-mode').value || 'rename',
|
||||
});
|
||||
var message = 'Создано: ' + response.created.length;
|
||||
if (response.skipped.length) message += ', пропущено: ' + response.skipped.length;
|
||||
setStatus(message);
|
||||
renderResult(response);
|
||||
if (typeof state.onImported === 'function') {
|
||||
await state.onImported();
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось создать черновики.', true);
|
||||
} finally {
|
||||
qs('openapi-import-create').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(response) {
|
||||
var node = qs('openapi-import-result');
|
||||
if (!node) return;
|
||||
node.replaceChildren();
|
||||
|
||||
var title = document.createElement('strong');
|
||||
title.textContent = 'Результат импорта';
|
||||
node.appendChild(title);
|
||||
|
||||
if (response.created && response.created.length) {
|
||||
var firstHref = wizardHref(response.created[0].operation_id);
|
||||
var primary = document.createElement('div');
|
||||
primary.className = 'openapi-import-primary-result';
|
||||
primary.innerHTML = '<a class="btn-primary" href="' + firstHref + '">Открыть первый черновик в мастере</a>';
|
||||
node.appendChild(primary);
|
||||
|
||||
var table = document.createElement('div');
|
||||
table.className = 'openapi-import-result-table';
|
||||
table.setAttribute('role', 'table');
|
||||
table.innerHTML = [
|
||||
'<div class="openapi-import-result-row openapi-import-result-head" role="row">',
|
||||
'<div role="columnheader">Черновик</div>',
|
||||
'<div role="columnheader">Замечания</div>',
|
||||
'<div role="columnheader">Действие</div>',
|
||||
'</div>',
|
||||
].join('');
|
||||
|
||||
response.created.forEach(function(operation) {
|
||||
var previewOperation = previewOperationByName(operation.name);
|
||||
var row = document.createElement('div');
|
||||
row.className = 'openapi-import-result-row';
|
||||
row.setAttribute('role', 'row');
|
||||
|
||||
var nameCell = document.createElement('div');
|
||||
nameCell.setAttribute('role', 'cell');
|
||||
var name = document.createElement('div');
|
||||
name.className = 'openapi-import-result-name';
|
||||
name.textContent = operation.name;
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'openapi-import-result-meta';
|
||||
meta.textContent = previewOperation
|
||||
? previewOperation.method + ' ' + previewOperation.path + ' · v' + operation.version
|
||||
: 'v' + operation.version;
|
||||
nameCell.appendChild(name);
|
||||
nameCell.appendChild(meta);
|
||||
|
||||
var findingsCell = document.createElement('div');
|
||||
findingsCell.className = 'openapi-import-result-findings';
|
||||
findingsCell.setAttribute('role', 'cell');
|
||||
appendFindingNodes(findingsCell, previewOperation ? previewOperation.findings : []);
|
||||
|
||||
var actionCell = document.createElement('div');
|
||||
actionCell.setAttribute('role', 'cell');
|
||||
var action = document.createElement('a');
|
||||
action.className = 'btn-secondary openapi-import-result-action';
|
||||
action.href = wizardHref(operation.operation_id);
|
||||
action.textContent = previewOperation && previewOperation.findings && previewOperation.findings.length
|
||||
? 'Исправить в мастере'
|
||||
: 'Открыть в мастере';
|
||||
actionCell.appendChild(action);
|
||||
|
||||
row.appendChild(nameCell);
|
||||
row.appendChild(findingsCell);
|
||||
row.appendChild(actionCell);
|
||||
table.appendChild(row);
|
||||
});
|
||||
node.appendChild(table);
|
||||
}
|
||||
if (response.skipped && response.skipped.length) {
|
||||
var skipped = document.createElement('div');
|
||||
skipped.className = 'openapi-import-result-skipped';
|
||||
skipped.textContent = 'Пропущено: ' + response.skipped.map(function(item) {
|
||||
return (item.name || item.operation_key) + ' — ' + item.reason;
|
||||
}).join('; ');
|
||||
node.appendChild(skipped);
|
||||
}
|
||||
if (response.findings && response.findings.length) {
|
||||
var responseFindings = document.createElement('div');
|
||||
responseFindings.className = 'openapi-import-result-findings';
|
||||
response.findings.forEach(function(finding) {
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = renderFinding(finding);
|
||||
responseFindings.appendChild(wrapper.firstElementChild);
|
||||
});
|
||||
node.appendChild(responseFindings);
|
||||
}
|
||||
node.hidden = false;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state.jobId = null;
|
||||
state.preview = null;
|
||||
state.filterQuery = '';
|
||||
state.filterMethod = '';
|
||||
qs('openapi-import-document').value = '';
|
||||
qs('openapi-import-file').value = '';
|
||||
qs('openapi-import-file-name').textContent = 'Файл не выбран';
|
||||
qs('openapi-import-server-custom').value = '';
|
||||
qs('openapi-import-conflict-mode').value = 'rename';
|
||||
if (qs('openapi-import-search')) qs('openapi-import-search').value = '';
|
||||
if (qs('openapi-import-method-filter')) qs('openapi-import-method-filter').innerHTML = '<option value="">Все методы</option>';
|
||||
qs('openapi-import-preview-panel').hidden = true;
|
||||
qs('openapi-import-groups').innerHTML = '';
|
||||
qs('openapi-import-result').hidden = true;
|
||||
qs('openapi-import-result').innerHTML = '';
|
||||
setStatus('');
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function open(options) {
|
||||
state.workspaceId = options.workspaceId;
|
||||
state.onImported = options.onImported;
|
||||
qs('openapi-import-modal').hidden = false;
|
||||
}
|
||||
|
||||
function close() {
|
||||
qs('openapi-import-modal').hidden = true;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!qs('openapi-import-modal')) return;
|
||||
qs('openapi-import-preview').addEventListener('click', preview);
|
||||
qs('openapi-import-create').addEventListener('click', createDrafts);
|
||||
qs('openapi-import-reset').addEventListener('click', reset);
|
||||
qs('openapi-import-search').addEventListener('input', function(event) {
|
||||
state.filterQuery = event.target.value.trim();
|
||||
applyFilters();
|
||||
});
|
||||
qs('openapi-import-method-filter').addEventListener('change', function(event) {
|
||||
state.filterMethod = event.target.value;
|
||||
applyFilters();
|
||||
});
|
||||
qs('openapi-import-select-visible').addEventListener('click', function() {
|
||||
setVisibleSelection(true);
|
||||
});
|
||||
qs('openapi-import-clear-visible').addEventListener('click', function() {
|
||||
setVisibleSelection(false);
|
||||
});
|
||||
qs('openapi-import-file').addEventListener('change', async function(event) {
|
||||
var file = event.target.files && event.target.files[0];
|
||||
if (!file) return;
|
||||
qs('openapi-import-file-name').textContent = file.name;
|
||||
qs('openapi-import-document').value = await file.text();
|
||||
});
|
||||
document.querySelectorAll('[data-openapi-close]').forEach(function(node) {
|
||||
node.addEventListener('click', close);
|
||||
});
|
||||
});
|
||||
|
||||
window.CrankOpenApiImport = {
|
||||
open: open,
|
||||
close: close,
|
||||
reset: reset,
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,54 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var transitionMs = 120;
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
function isInternalNavigation(link) {
|
||||
if (!link || !link.href || link.target || link.hasAttribute('download')) return false;
|
||||
|
||||
var targetUrl;
|
||||
try {
|
||||
targetUrl = new URL(link.href, window.location.href);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (targetUrl.origin !== window.location.origin) return false;
|
||||
if (targetUrl.pathname === window.location.pathname && targetUrl.search === window.location.search) {
|
||||
return targetUrl.hash && targetUrl.hash !== window.location.hash;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initPageTransitions() {
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
var link = event.target.closest ? event.target.closest('a[href]') : null;
|
||||
if (!isInternalNavigation(link)) return;
|
||||
|
||||
var targetUrl = new URL(link.href, window.location.href);
|
||||
if (targetUrl.pathname === window.location.pathname && targetUrl.search === window.location.search) return;
|
||||
|
||||
event.preventDefault();
|
||||
document.body.classList.add('page-leaving');
|
||||
window.setTimeout(function() {
|
||||
window.location.href = targetUrl.href;
|
||||
}, transitionMs);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPageTransitions, { once: true });
|
||||
} else {
|
||||
initPageTransitions();
|
||||
}
|
||||
})();
|
||||
@@ -538,12 +538,15 @@ function initSecretsPage() {
|
||||
state.search = event.target.value || '';
|
||||
renderSecrets();
|
||||
});
|
||||
document.addEventListener('workspace:changed', function () {
|
||||
window.addEventListener('crank:workspacechange', function () {
|
||||
void load();
|
||||
});
|
||||
|
||||
updateKindFields();
|
||||
void load();
|
||||
void (async function bootSecretsPage() {
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
await load();
|
||||
}());
|
||||
}
|
||||
|
||||
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.bootstrap === 'function') {
|
||||
|
||||
@@ -330,6 +330,9 @@ async function loadWorkspaceSettings() {
|
||||
}
|
||||
|
||||
async function initSettingsPage() {
|
||||
document.querySelectorAll('.lang-btn[data-lang]').forEach(function(button) {
|
||||
button.addEventListener('click', function() { setLang(button.dataset.lang); });
|
||||
});
|
||||
bindSectionNavigation();
|
||||
await loadProfile();
|
||||
await loadCapabilities();
|
||||
|
||||
Vendored
-5
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+182
-1
@@ -13,17 +13,66 @@ function buildToolDescription() {
|
||||
}
|
||||
|
||||
function buildWizardState() {
|
||||
var snapshot = wizardCurrentVersion && wizardCurrentVersion.snapshot
|
||||
? wizardCurrentVersion.snapshot
|
||||
: wizardCurrentVersion;
|
||||
var existingState = snapshot && snapshot.wizard_state ? snapshot.wizard_state : {};
|
||||
return {
|
||||
input_sample: parseStructuredText(textValue('wizard-input-sample')),
|
||||
output_sample: parseStructuredText(textValue('wizard-output-sample')),
|
||||
test_input: parseStructuredText(textValue('wizard-test-input')),
|
||||
import_findings: Array.isArray(existingState.import_findings)
|
||||
? existingState.import_findings.slice()
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function checkedValue(id) {
|
||||
var element = document.getElementById(id);
|
||||
return !!(element && element.checked);
|
||||
}
|
||||
|
||||
function normalizeApprovalTtlSeconds(value) {
|
||||
var ttl = Number(value || 300);
|
||||
if (!Number.isFinite(ttl)) return 300;
|
||||
return Math.max(1, Math.min(300, Math.round(ttl)));
|
||||
}
|
||||
|
||||
function buildApprovalPolicy() {
|
||||
if (!checkedValue('approval-required')) return null;
|
||||
|
||||
return {
|
||||
required: true,
|
||||
mode: textValue('approval-mode') || 'custom',
|
||||
risk_level: 'normal',
|
||||
ttl_seconds: normalizeApprovalTtlSeconds(textValue('approval-ttl-seconds')),
|
||||
show_payload_preview: checkedValue('approval-show-payload-preview'),
|
||||
payload_preview_mode: 'summary',
|
||||
elicitation_message: textValue('approval-mode') === 'elicitation'
|
||||
? (textValue('approval-elicitation-message') || null)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyApprovalPolicyToExecutionConfig(config) {
|
||||
var next = config || {};
|
||||
var policy = buildApprovalPolicy();
|
||||
if (policy) {
|
||||
next.approval_policy = policy;
|
||||
} else {
|
||||
next.approval_policy = null;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function collectWizardPayload() {
|
||||
var name = textValue('tool-name');
|
||||
if (!name) throw new Error(tKey('wizard.error.tool_name'));
|
||||
|
||||
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.sync === 'function') {
|
||||
window.CrankWizardMapping.sync();
|
||||
}
|
||||
|
||||
var inputSchemaValue = parseStructuredText(textValue('tool-input-schema'));
|
||||
var outputSchemaValue = parseStructuredText(textValue('tool-output-schema'));
|
||||
var inputMappingValue = parseStructuredText(textValue('tool-input-mapping'));
|
||||
@@ -39,7 +88,7 @@ function collectWizardPayload() {
|
||||
output_schema: convertJsonSchemaToCrankSchema(outputSchemaValue, []),
|
||||
input_mapping: buildMappingSet(inputMappingValue, 'input'),
|
||||
output_mapping: buildMappingSet(outputMappingValue, 'output'),
|
||||
execution_config: parseExecutionConfig(textValue('tool-exec-config')),
|
||||
execution_config: applyApprovalPolicyToExecutionConfig(parseExecutionConfig(textValue('tool-exec-config'))),
|
||||
tool_description: buildToolDescription(),
|
||||
wizard_state: buildWizardState(),
|
||||
};
|
||||
@@ -69,6 +118,7 @@ async function loadOperationForEdit() {
|
||||
wizardCurrentVersion = draftVersion;
|
||||
prefillWizardFromEdit(detail, draftVersion);
|
||||
renderAgentFacingPreview();
|
||||
renderImportQualityFindings(draftVersion);
|
||||
}
|
||||
|
||||
function setEditModePresentation() {
|
||||
@@ -111,6 +161,10 @@ function bindWizardLiveActions() {
|
||||
|
||||
var yamlInput = document.getElementById('wizard-import-yaml-file');
|
||||
if (yamlInput) yamlInput.addEventListener('change', handleImportYamlFileSelection);
|
||||
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.initialize === 'function') {
|
||||
window.CrankWizardMapping.initialize();
|
||||
}
|
||||
bindApprovalPolicyControls();
|
||||
bindAgentFacingPreview();
|
||||
}
|
||||
|
||||
@@ -132,6 +186,52 @@ function bindLiveAction(id, busyLabel, handler) {
|
||||
});
|
||||
}
|
||||
|
||||
function setApprovalPolicyEditor(policy) {
|
||||
var enabled = !!(policy && policy.required);
|
||||
var required = document.getElementById('approval-required');
|
||||
if (required) required.checked = enabled;
|
||||
setValue('approval-mode', policy && policy.mode ? policy.mode : 'custom');
|
||||
setValue('approval-elicitation-message', policy && policy.elicitation_message ? policy.elicitation_message : '');
|
||||
setValue('approval-ttl-seconds', policy && policy.ttl_seconds ? String(policy.ttl_seconds) : '300');
|
||||
var showPayload = document.getElementById('approval-show-payload-preview');
|
||||
if (showPayload) {
|
||||
showPayload.checked = !policy || policy.show_payload_preview !== false;
|
||||
}
|
||||
updateApprovalPolicyUi();
|
||||
}
|
||||
|
||||
function updateApprovalPolicyUi() {
|
||||
var enabled = checkedValue('approval-required');
|
||||
var toggle = document.getElementById('approval-required-toggle');
|
||||
var fields = document.getElementById('approval-config-fields');
|
||||
var mode = textValue('approval-mode') || 'custom';
|
||||
var customInfo = document.getElementById('approval-custom-info');
|
||||
var elicitationInfo = document.getElementById('approval-elicitation-info');
|
||||
var elicitationMessage = document.getElementById('approval-elicitation-message-group');
|
||||
if (toggle) toggle.classList.toggle('on', enabled);
|
||||
if (fields) fields.hidden = !enabled;
|
||||
if (customInfo) customInfo.hidden = mode !== 'custom';
|
||||
if (elicitationInfo) elicitationInfo.hidden = mode !== 'elicitation';
|
||||
if (elicitationMessage) elicitationMessage.hidden = mode !== 'elicitation';
|
||||
}
|
||||
|
||||
function bindApprovalPolicyControls() {
|
||||
[
|
||||
'approval-required',
|
||||
'approval-mode',
|
||||
'approval-elicitation-message',
|
||||
'approval-ttl-seconds',
|
||||
'approval-show-payload-preview',
|
||||
].forEach(function(id) {
|
||||
var element = document.getElementById(id);
|
||||
if (!element || element.dataset.approvalBound === 'true') return;
|
||||
element.dataset.approvalBound = 'true';
|
||||
element.addEventListener('input', updateApprovalPolicyUi);
|
||||
element.addEventListener('change', updateApprovalPolicyUi);
|
||||
});
|
||||
updateApprovalPolicyUi();
|
||||
}
|
||||
|
||||
async function runWizardLiveAction(button, busyLabel, handler) {
|
||||
if (!button || button.dataset.busy === 'true') {
|
||||
return;
|
||||
@@ -421,6 +521,50 @@ function severityLabel(severity) {
|
||||
return tKey('wizard.quality.severity_info');
|
||||
}
|
||||
|
||||
function qualityTargetForFinding(finding) {
|
||||
var code = String(finding && finding.code || '');
|
||||
var path = String(finding && finding.field_path || '');
|
||||
|
||||
if (code.indexOf('tool_name') >= 0 || code.indexOf('weak_tool_name') >= 0) {
|
||||
return { step: 4, selector: '#tool-name', label: 'Перейти к имени инструмента' };
|
||||
}
|
||||
if (code.indexOf('description') >= 0 || path.indexOf('tool_description') >= 0) {
|
||||
return { step: 4, selector: '#tool-description', label: 'Перейти к описанию' };
|
||||
}
|
||||
if (code.indexOf('input') >= 0 || code.indexOf('parameter') >= 0 || path.indexOf('input_schema') >= 0) {
|
||||
return { step: 4, selector: '#tool-input-schema', label: 'Перейти к входной схеме' };
|
||||
}
|
||||
if (code.indexOf('output') >= 0 || code.indexOf('response_schema') >= 0 || path.indexOf('output_schema') >= 0) {
|
||||
return { step: 4, selector: '#tool-output-schema', label: 'Перейти к схеме ответа' };
|
||||
}
|
||||
if (code.indexOf('response_projection') >= 0 || code.indexOf('mapping') >= 0 || path.indexOf('output_mapping') >= 0) {
|
||||
return { step: 5, selector: '#tool-output-mapping', label: 'Перейти к маппингу ответа' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function focusWizardQualityTarget(target) {
|
||||
if (!target || !window.CrankWizardShell) return;
|
||||
var load = typeof window.CrankWizardShell.loadWizardPanels === 'function'
|
||||
? window.CrankWizardShell.loadWizardPanels([target.step])
|
||||
: Promise.resolve();
|
||||
load.then(function() {
|
||||
if (typeof window.CrankWizardShell.doGoToStep === 'function') {
|
||||
window.CrankWizardShell.doGoToStep(target.step);
|
||||
}
|
||||
window.setTimeout(function() {
|
||||
var element = document.querySelector(target.selector);
|
||||
if (!element) return;
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (typeof element.focus === 'function') element.focus();
|
||||
element.classList.add('quality-focus-target');
|
||||
window.setTimeout(function() {
|
||||
element.classList.remove('quality-focus-target');
|
||||
}, 1600);
|
||||
}, 80);
|
||||
});
|
||||
}
|
||||
|
||||
function renderQualityFindings(report) {
|
||||
var empty = document.getElementById('wizard-quality-empty');
|
||||
var list = document.getElementById('wizard-quality-findings');
|
||||
@@ -454,6 +598,19 @@ function renderQualityFindings(report) {
|
||||
item.appendChild(action);
|
||||
}
|
||||
|
||||
var target = qualityTargetForFinding(finding);
|
||||
if (target) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'btn-ghost-sm quality-finding-jump';
|
||||
button.textContent = target.label;
|
||||
button.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
focusWizardQualityTarget(target);
|
||||
});
|
||||
item.appendChild(button);
|
||||
}
|
||||
|
||||
if (finding.field_path) {
|
||||
var path = document.createElement('div');
|
||||
path.className = 'quality-finding-path';
|
||||
@@ -465,6 +622,26 @@ function renderQualityFindings(report) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderImportQualityFindings(versionDocument) {
|
||||
var snapshot = versionDocument && versionDocument.snapshot
|
||||
? versionDocument.snapshot
|
||||
: versionDocument;
|
||||
var state = snapshot && snapshot.wizard_state ? snapshot.wizard_state : {};
|
||||
var findings = Array.isArray(state.import_findings) ? state.import_findings : [];
|
||||
if (!findings.length) return;
|
||||
|
||||
renderQualityFindings({
|
||||
blocking: findings.some(function(finding) { return finding.severity === 'error'; }),
|
||||
findings: findings,
|
||||
});
|
||||
|
||||
var empty = document.getElementById('wizard-quality-empty');
|
||||
if (empty) {
|
||||
empty.textContent = 'Рекомендации из OpenAPI import. Запустите проверку качества, чтобы пересчитать их по текущему черновику.';
|
||||
empty.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeWizardQuality() {
|
||||
if (!wizardWorkspaceId) {
|
||||
throw new Error(tKey('wizard.error.no_workspace'));
|
||||
@@ -574,6 +751,9 @@ async function generateDraftFromWizard() {
|
||||
setValue('tool-output-schema', JSON.stringify(crankSchemaToJsonSchema(generated.output_schema), null, 2));
|
||||
setValue('tool-input-mapping', mappingSetToEditorValue(generated.input_mapping, 'input', wizardProtocol));
|
||||
setValue('tool-output-mapping', mappingSetToEditorValue(generated.output_mapping, 'output', wizardProtocol));
|
||||
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') {
|
||||
window.CrankWizardMapping.renderFromEditors();
|
||||
}
|
||||
renderAgentFacingPreview();
|
||||
showWizardLiveStatus(tKey('wizard.sample.generated'), tKey('wizard.sample.generated_body'));
|
||||
}
|
||||
@@ -613,4 +793,5 @@ function copyTestResponseToOutputSample() {
|
||||
bindWizardLiveActions: bindWizardLiveActions,
|
||||
updateWizardProtocolVisibility: updateWizardProtocolVisibility,
|
||||
renderAgentFacingPreview: renderAgentFacingPreview,
|
||||
setApprovalPolicyEditor: setApprovalPolicyEditor,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
(function() {
|
||||
var REQUEST_TARGETS = ['path', 'query', 'headers', 'body'];
|
||||
var SCALAR_TYPES = ['string', 'number', 'integer', 'boolean', 'null'];
|
||||
var suspendSync = false;
|
||||
|
||||
function field(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function readText(id) {
|
||||
var element = field(id);
|
||||
return element ? element.value.trim() : '';
|
||||
}
|
||||
|
||||
function writeText(id, value) {
|
||||
var element = field(id);
|
||||
if (element) element.value = value || '';
|
||||
}
|
||||
|
||||
function parseText(id) {
|
||||
if (typeof parseStructuredText === 'function') {
|
||||
return parseStructuredText(readText(id));
|
||||
}
|
||||
return JSON.parse(readText(id));
|
||||
}
|
||||
|
||||
function dumpYaml(object) {
|
||||
return window.jsyaml
|
||||
? window.jsyaml.dump(object, { lineWidth: -1, noRefs: true })
|
||||
: JSON.stringify(object, null, 2);
|
||||
}
|
||||
|
||||
function safeJson(id) {
|
||||
try {
|
||||
return parseText(id);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function scalarType(value) {
|
||||
if (value === null) return 'null';
|
||||
if (Array.isArray(value)) return 'array';
|
||||
var typeName = typeof value;
|
||||
if (typeName === 'number') return Number.isInteger(value) ? 'integer' : 'number';
|
||||
if (typeName === 'boolean') return 'boolean';
|
||||
if (typeName === 'object') return 'object';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function inferJsonSchema(value) {
|
||||
var typeName = scalarType(value);
|
||||
if (typeName === 'object') {
|
||||
var properties = {};
|
||||
var required = [];
|
||||
Object.keys(value || {}).forEach(function(name) {
|
||||
properties[name] = inferJsonSchema(value[name]);
|
||||
if (value[name] !== null && value[name] !== undefined) required.push(name);
|
||||
});
|
||||
var schema = {
|
||||
type: 'object',
|
||||
properties: properties,
|
||||
additionalProperties: false,
|
||||
};
|
||||
if (required.length) schema.required = required;
|
||||
return schema;
|
||||
}
|
||||
if (typeName === 'array') {
|
||||
return {
|
||||
type: 'array',
|
||||
items: value.length ? inferJsonSchema(value[0]) : { type: 'string' },
|
||||
};
|
||||
}
|
||||
return { type: typeName };
|
||||
}
|
||||
|
||||
function collectSchemaFields(schema, prefix) {
|
||||
var result = [];
|
||||
var node = schema || {};
|
||||
if (node.type === 'object' && node.properties) {
|
||||
Object.keys(node.properties).forEach(function(name) {
|
||||
var child = node.properties[name] || {};
|
||||
var path = prefix ? prefix + '.' + name : name;
|
||||
if (child.type === 'object' && child.properties) {
|
||||
result = result.concat(collectSchemaFields(child, path));
|
||||
} else {
|
||||
result.push({
|
||||
name: path,
|
||||
required: Array.isArray(node.required) && node.required.indexOf(name) >= 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function requiredSchemaFields(schema) {
|
||||
return collectSchemaFields(schema, '').filter(function(item) {
|
||||
return item.required;
|
||||
}).map(function(item) {
|
||||
return item.name;
|
||||
});
|
||||
}
|
||||
|
||||
function collectJsonLeaves(value, prefix) {
|
||||
var result = [];
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) {
|
||||
if (prefix) result.push({ name: prefix, required: true, array: true });
|
||||
return result;
|
||||
}
|
||||
var samplePath = prefix ? prefix + '[0]' : '[0]';
|
||||
return collectJsonLeaves(value[0], samplePath).map(function(item) {
|
||||
item.array = true;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
Object.keys(value).forEach(function(name) {
|
||||
var path = prefix ? prefix + '.' + name : name;
|
||||
var child = value[name];
|
||||
if (child && typeof child === 'object') {
|
||||
result = result.concat(collectJsonLeaves(child, path));
|
||||
} else {
|
||||
result.push({ name: path, required: child !== null && child !== undefined });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (prefix) result.push({ name: prefix, required: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
function pathParams() {
|
||||
var path = readText('endpoint-path') || '';
|
||||
var params = [];
|
||||
path.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, function(_match, name) {
|
||||
params.push(name);
|
||||
return _match;
|
||||
});
|
||||
path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, function(_match, name) {
|
||||
params.push(name);
|
||||
return _match;
|
||||
});
|
||||
return Array.from(new Set(params));
|
||||
}
|
||||
|
||||
function currentMethod() {
|
||||
var active = document.querySelector('.method-card.active');
|
||||
return active ? active.dataset.method || 'GET' : 'GET';
|
||||
}
|
||||
|
||||
function defaultTargetKind() {
|
||||
var method = currentMethod().toUpperCase();
|
||||
return method === 'GET' || method === 'DELETE' ? 'query' : 'body';
|
||||
}
|
||||
|
||||
function splitRequestTarget(target) {
|
||||
var value = String(target || '').replace(/^\$\.request\./, '').replace(/^request\./, '');
|
||||
var parts = value.split('.');
|
||||
var kind = parts.shift() || defaultTargetKind();
|
||||
if (REQUEST_TARGETS.indexOf(kind) < 0) kind = defaultTargetKind();
|
||||
return { kind: kind, name: parts.join('.') };
|
||||
}
|
||||
|
||||
function sourceToField(source) {
|
||||
return String(source || '')
|
||||
.replace(/^\$\.mcp\.?/, '')
|
||||
.replace(/^\$\.input\.?/, '');
|
||||
}
|
||||
|
||||
function responseSourceToPath(source) {
|
||||
return String(source || '')
|
||||
.replace(/^\$\.response\.body\.?/, '')
|
||||
.replace(/^\$\.response\.data\.?/, '');
|
||||
}
|
||||
|
||||
function outputTargetToField(target) {
|
||||
return String(target || '').replace(/^\$\.output\.?/, '');
|
||||
}
|
||||
|
||||
function mappingEntrySource(entry) {
|
||||
return entry && typeof entry === 'object' && !Array.isArray(entry)
|
||||
? entry.source
|
||||
: entry;
|
||||
}
|
||||
|
||||
function mappingEntryDefault(entry) {
|
||||
return entry && typeof entry === 'object' && !Array.isArray(entry) && Object.prototype.hasOwnProperty.call(entry, 'default_value')
|
||||
? entry.default_value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function mappingEntryTransform(entry) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || entry.transform === undefined || entry.transform === null) {
|
||||
return '';
|
||||
}
|
||||
var value = typeof entry.transform === 'object' ? entry.transform.kind : entry.transform;
|
||||
value = String(value || '').trim();
|
||||
return transformKinds().indexOf(value) >= 0 ? value : '';
|
||||
}
|
||||
|
||||
function defaultValueToText(value) {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseDefaultValue(text) {
|
||||
var value = String(text || '').trim();
|
||||
if (!value) return undefined;
|
||||
if (/^(true|false|null)$/i.test(value) || /^-?(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(value) || /^[\[{]/.test(value)) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function transformKinds() {
|
||||
return [
|
||||
'to_string',
|
||||
'to_number',
|
||||
'to_boolean',
|
||||
'join',
|
||||
'split',
|
||||
'wrap_array',
|
||||
'unwrap_singleton',
|
||||
];
|
||||
}
|
||||
|
||||
function requestRowsFromEditor() {
|
||||
var parsed = safeJson('tool-input-mapping') || {};
|
||||
return Object.keys(parsed).map(function(target) {
|
||||
var entry = parsed[target];
|
||||
var source = mappingEntrySource(entry);
|
||||
var split = splitRequestTarget(target);
|
||||
return {
|
||||
input: sourceToField(source) || split.name,
|
||||
target: split.kind,
|
||||
apiName: split.name || sourceToField(source),
|
||||
defaultValue: defaultValueToText(mappingEntryDefault(entry)),
|
||||
transform: mappingEntryTransform(entry),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function responseRowsFromEditor() {
|
||||
var parsed = safeJson('tool-output-mapping') || {};
|
||||
return Object.keys(parsed).map(function(outputField) {
|
||||
var source = mappingEntrySource(parsed[outputField]);
|
||||
return {
|
||||
output: outputField,
|
||||
responsePath: responseSourceToPath(source) || outputField,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function suggestedRequestRows() {
|
||||
var rows = [];
|
||||
pathParams().forEach(function(name) {
|
||||
rows.push({ input: name, target: 'path', apiName: name });
|
||||
});
|
||||
|
||||
var sample = safeJson('wizard-input-sample');
|
||||
var fields = [];
|
||||
if (sample && typeof sample === 'object') {
|
||||
fields = collectJsonLeaves(sample, '');
|
||||
}
|
||||
if (!fields.length) {
|
||||
fields = collectSchemaFields(safeJson('tool-input-schema'), '');
|
||||
}
|
||||
|
||||
var pathSet = new Set(rows.map(function(row) { return row.input; }));
|
||||
fields.forEach(function(item) {
|
||||
if (pathSet.has(item.name)) return;
|
||||
rows.push({
|
||||
input: item.name,
|
||||
target: defaultTargetKind(),
|
||||
apiName: item.name,
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function suggestedResponseRows() {
|
||||
var sample = safeJson('wizard-output-sample');
|
||||
var fields = sample && typeof sample === 'object'
|
||||
? collectJsonLeaves(sample, '')
|
||||
: collectSchemaFields(safeJson('tool-output-schema'), '');
|
||||
return fields.map(function(item) {
|
||||
return {
|
||||
output: outputNameFromPath(item.name),
|
||||
responsePath: item.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function outputNameFromPath(path) {
|
||||
return String(path || 'value')
|
||||
.replace(/^\[0\]\.?/, 'item_')
|
||||
.replace(/\[([0-9]+)\]/g, '_$1')
|
||||
.replace(/[^A-Za-z0-9_]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|| 'value';
|
||||
}
|
||||
|
||||
function joinJsonPath(root, relativePath) {
|
||||
var path = String(relativePath || '').trim();
|
||||
if (!path) return root;
|
||||
return path.charAt(0) === '[' ? root + path : root + '.' + path;
|
||||
}
|
||||
|
||||
function makeOption(value, label, selected) {
|
||||
var option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
option.selected = selected;
|
||||
return option;
|
||||
}
|
||||
|
||||
function makeInput(value, className, placeholder) {
|
||||
var input = document.createElement('input');
|
||||
input.className = className || 'form-input input-mono';
|
||||
input.value = value === undefined || value === null ? '' : String(value);
|
||||
input.placeholder = placeholder || '';
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
input.addEventListener('input', syncVisualMappingsToYaml);
|
||||
input.addEventListener('change', syncVisualMappingsToYaml);
|
||||
return input;
|
||||
}
|
||||
|
||||
function makeRemoveButton(row) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'row-btn row-btn-delete mapping-row-remove';
|
||||
button.title = 'Удалить строку';
|
||||
button.textContent = '×';
|
||||
button.addEventListener('click', function() {
|
||||
row.remove();
|
||||
syncVisualMappingsToYaml();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function wrapMappingControl(labelText, control) {
|
||||
var wrapper = document.createElement('label');
|
||||
wrapper.className = 'mapping-field';
|
||||
var label = document.createElement('span');
|
||||
label.className = 'mapping-field-label';
|
||||
label.textContent = labelText;
|
||||
wrapper.appendChild(label);
|
||||
wrapper.appendChild(control);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function makeMappingArrow() {
|
||||
var arrow = document.createElement('span');
|
||||
arrow.className = 'mapping-arrow';
|
||||
arrow.setAttribute('aria-hidden', 'true');
|
||||
arrow.textContent = '→';
|
||||
return arrow;
|
||||
}
|
||||
|
||||
function renderRequestRows(rows) {
|
||||
var root = field('wizard-request-mapping-rows');
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
if (!rows.length) rows = suggestedRequestRows();
|
||||
rows.forEach(function(row) {
|
||||
root.appendChild(createRequestRow(row));
|
||||
});
|
||||
if (!suspendSync) syncVisualMappingsToYaml();
|
||||
}
|
||||
|
||||
function createRequestRow(row) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'mapping-row request-mapping-row';
|
||||
item.dataset.mappingRow = 'request';
|
||||
|
||||
var input = makeInput(row.input, 'form-input input-mono mapping-source', 'base');
|
||||
input.dataset.role = 'input';
|
||||
input.title = 'Поле, которое MCP клиент передает инструменту';
|
||||
item.appendChild(wrapMappingControl('Из инструмента', input));
|
||||
|
||||
item.appendChild(makeMappingArrow());
|
||||
|
||||
var select = document.createElement('select');
|
||||
select.className = 'form-select mapping-target';
|
||||
select.dataset.role = 'target';
|
||||
[
|
||||
['path', 'Path'],
|
||||
['query', 'Query'],
|
||||
['headers', 'Header'],
|
||||
['body', 'Body'],
|
||||
].forEach(function(entry) {
|
||||
select.appendChild(makeOption(entry[0], entry[1], entry[0] === row.target));
|
||||
});
|
||||
select.addEventListener('change', syncVisualMappingsToYaml);
|
||||
item.appendChild(wrapMappingControl('Куда в API', select));
|
||||
|
||||
var apiName = makeInput(row.apiName || row.input, 'form-input input-mono mapping-api-name', 'base');
|
||||
apiName.dataset.role = 'apiName';
|
||||
apiName.title = 'Имя path, query, header или body-поля в API-запросе';
|
||||
item.appendChild(wrapMappingControl('Имя в API', apiName));
|
||||
|
||||
var defaultValue = makeInput(row.defaultValue, 'form-input input-mono mapping-default-value', 'если не передано');
|
||||
defaultValue.dataset.role = 'defaultValue';
|
||||
defaultValue.title = 'Необязательно. Это значение уйдет в API, если агент не передал поле инструмента.';
|
||||
item.appendChild(wrapMappingControl('Если пусто', defaultValue));
|
||||
|
||||
var transform = document.createElement('select');
|
||||
transform.className = 'form-select mapping-transform';
|
||||
transform.dataset.role = 'transform';
|
||||
transform.appendChild(makeOption('', 'Без преобразования', !row.transform));
|
||||
transformKinds().forEach(function(kind) {
|
||||
transform.appendChild(makeOption(kind, kind, kind === row.transform));
|
||||
});
|
||||
transform.title = 'Простое преобразование перед отправкой в API';
|
||||
transform.addEventListener('change', syncVisualMappingsToYaml);
|
||||
item.appendChild(wrapMappingControl('Преобразование', transform));
|
||||
|
||||
item.appendChild(makeRemoveButton(item));
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderResponseRows(rows) {
|
||||
var root = field('wizard-response-mapping-rows');
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
if (!rows.length) rows = suggestedResponseRows();
|
||||
rows.forEach(function(row) {
|
||||
root.appendChild(createResponseRow(row));
|
||||
});
|
||||
if (!suspendSync) syncVisualMappingsToYaml();
|
||||
}
|
||||
|
||||
function createResponseRow(row) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'mapping-row response-mapping-row';
|
||||
item.dataset.mappingRow = 'response';
|
||||
|
||||
var responsePath = makeInput(row.responsePath, 'form-input input-mono mapping-response-path', 'rates.EUR');
|
||||
responsePath.dataset.role = 'responsePath';
|
||||
responsePath.title = 'Поле из ответа API';
|
||||
item.appendChild(wrapMappingControl('Из ответа API', responsePath));
|
||||
|
||||
item.appendChild(makeMappingArrow());
|
||||
|
||||
var output = makeInput(row.output, 'form-input input-mono mapping-output-field', 'rate');
|
||||
output.dataset.role = 'output';
|
||||
output.title = 'Поле результата, которое получит MCP клиент';
|
||||
item.appendChild(wrapMappingControl('В результат инструмента', output));
|
||||
item.appendChild(makeRemoveButton(item));
|
||||
return item;
|
||||
}
|
||||
|
||||
function valueIn(row, role) {
|
||||
var input = row.querySelector('[data-role="' + role + '"]');
|
||||
return input ? input.value.trim() : '';
|
||||
}
|
||||
|
||||
function syncVisualMappingsToYaml() {
|
||||
var requestRows = document.querySelectorAll('[data-mapping-row="request"]');
|
||||
if (requestRows.length) {
|
||||
var requestMapping = {};
|
||||
requestRows.forEach(function(row) {
|
||||
var input = valueIn(row, 'input');
|
||||
var target = valueIn(row, 'target');
|
||||
var apiName = valueIn(row, 'apiName') || input;
|
||||
var defaultText = valueIn(row, 'defaultValue');
|
||||
var transform = valueIn(row, 'transform');
|
||||
if (!input || !target || !apiName) return;
|
||||
if (defaultText || transform) {
|
||||
var entry = { source: '$.input.' + input };
|
||||
if (defaultText) entry.default_value = parseDefaultValue(defaultText);
|
||||
if (transform) entry.transform = transform;
|
||||
requestMapping[target + '.' + apiName] = entry;
|
||||
return;
|
||||
}
|
||||
requestMapping[target + '.' + apiName] = '$.input.' + input;
|
||||
});
|
||||
writeText('tool-input-mapping', dumpYaml(requestMapping));
|
||||
}
|
||||
|
||||
var responseRows = document.querySelectorAll('[data-mapping-row="response"]');
|
||||
if (responseRows.length) {
|
||||
var responseMapping = {};
|
||||
responseRows.forEach(function(row) {
|
||||
var responsePath = valueIn(row, 'responsePath');
|
||||
var output = valueIn(row, 'output') || outputNameFromPath(responsePath);
|
||||
if (!responsePath || !output) return;
|
||||
responseMapping[output] = joinJsonPath('$.response.body', responsePath);
|
||||
});
|
||||
writeText('tool-output-mapping', dumpYaml(responseMapping));
|
||||
}
|
||||
renderMappingWarnings();
|
||||
}
|
||||
|
||||
function currentRequestMappedInputs() {
|
||||
return Array.from(document.querySelectorAll('[data-mapping-row="request"]')).map(function(row) {
|
||||
return valueIn(row, 'input');
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function currentResponseMappedOutputs() {
|
||||
return Array.from(document.querySelectorAll('[data-mapping-row="response"]')).map(function(row) {
|
||||
return valueIn(row, 'output');
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function jsonParseWarning(id, label) {
|
||||
var raw = readText(id);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
parseText(id);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return label + ': JSON не разобран. ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMappingWarnings() {
|
||||
var warnings = [];
|
||||
var inputJsonWarning = jsonParseWarning('wizard-input-sample', 'Пример входных данных');
|
||||
var outputJsonWarning = jsonParseWarning('wizard-output-sample', 'Пример ответа');
|
||||
if (inputJsonWarning) warnings.push(inputJsonWarning);
|
||||
if (outputJsonWarning) warnings.push(outputJsonWarning);
|
||||
|
||||
var required = requiredSchemaFields(safeJson('tool-input-schema'));
|
||||
var mapped = new Set(currentRequestMappedInputs());
|
||||
var missing = required.filter(function(name) { return !mapped.has(name); });
|
||||
if (missing.length) {
|
||||
warnings.push('Обязательные поля входной схемы не отправляются в API: ' + missing.join(', ') + '.');
|
||||
}
|
||||
|
||||
var outputRows = currentResponseMappedOutputs();
|
||||
if (!outputRows.length) {
|
||||
warnings.push('Ответ инструмента пустой. Выберите хотя бы одно поле из ответа API.');
|
||||
}
|
||||
if (outputRows.length > 12) {
|
||||
warnings.push('В ответ инструмента выбрано много полей: ' + outputRows.length + '. Лучше вернуть только данные, нужные агенту.');
|
||||
}
|
||||
var hasArrayIndex = Array.from(document.querySelectorAll('[data-mapping-row="response"]')).some(function(row) {
|
||||
return /\[[0-9]+\]/.test(valueIn(row, 'responsePath'));
|
||||
});
|
||||
if (hasArrayIndex) {
|
||||
warnings.push('В ответе выбрано поле из одного элемента массива. Если агенту нужен весь список, верните массив целиком через расширенный YAML-маппинг.');
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function renderMappingWarnings() {
|
||||
var root = field('wizard-mapping-warnings');
|
||||
if (!root) return;
|
||||
var warnings = buildMappingWarnings();
|
||||
root.replaceChildren();
|
||||
root.hidden = warnings.length === 0;
|
||||
warnings.forEach(function(message) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'mapping-warning';
|
||||
var title = document.createElement('strong');
|
||||
title.textContent = 'Проверьте маппинг. ';
|
||||
item.appendChild(title);
|
||||
item.appendChild(document.createTextNode(message));
|
||||
root.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderResponseTree() {
|
||||
var root = field('wizard-response-json-tree');
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
var sample = safeJson('wizard-output-sample');
|
||||
var leaves = sample && typeof sample === 'object' ? collectJsonLeaves(sample, '') : [];
|
||||
if (!leaves.length) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'form-hint';
|
||||
empty.textContent = 'Добавьте пример ответа JSON, чтобы выбрать поля кликом.';
|
||||
root.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
leaves.forEach(function(leaf) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'json-tree-node';
|
||||
button.textContent = leaf.name;
|
||||
button.addEventListener('click', function() {
|
||||
var output = outputNameFromPath(leaf.name);
|
||||
field('wizard-response-mapping-rows').appendChild(createResponseRow({
|
||||
responsePath: leaf.name,
|
||||
output: output,
|
||||
}));
|
||||
syncVisualMappingsToYaml();
|
||||
});
|
||||
root.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function formatSamplesAndInferSchemas() {
|
||||
var input = parseText('wizard-input-sample');
|
||||
var output = parseText('wizard-output-sample');
|
||||
writeText('wizard-input-sample', JSON.stringify(input, null, 2));
|
||||
writeText('wizard-test-input', JSON.stringify(input, null, 2));
|
||||
writeText('wizard-output-sample', JSON.stringify(output, null, 2));
|
||||
writeText('tool-input-schema', JSON.stringify(inferJsonSchema(input), null, 2));
|
||||
writeText('tool-output-schema', JSON.stringify(inferJsonSchema(output), null, 2));
|
||||
renderRequestRows(suggestedRequestRows());
|
||||
renderResponseRows(suggestedResponseRows());
|
||||
renderResponseTree();
|
||||
if (window.CrankWizardLive && typeof window.CrankWizardLive.renderAgentFacingPreview === 'function') {
|
||||
window.CrankWizardLive.renderAgentFacingPreview();
|
||||
}
|
||||
}
|
||||
|
||||
function loadJsonFile(inputId, targetTextareaId, afterLoad) {
|
||||
var input = field(inputId);
|
||||
if (!input) return;
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(event) {
|
||||
try {
|
||||
var parsed = JSON.parse(event.target.result || '{}');
|
||||
writeText(targetTextareaId, JSON.stringify(parsed, null, 2));
|
||||
if (afterLoad) afterLoad();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message, 'JSON не прочитан');
|
||||
}
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function bindFileButton(buttonId, inputId) {
|
||||
var button = field(buttonId);
|
||||
var input = field(inputId);
|
||||
if (!button || !input || button.dataset.mappingBound === 'true') return;
|
||||
button.dataset.mappingBound = 'true';
|
||||
button.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
function bindOnce(id, eventName, handler) {
|
||||
var element = field(id);
|
||||
if (!element || element.dataset.mappingBound === 'true') return;
|
||||
element.dataset.mappingBound = 'true';
|
||||
element.addEventListener(eventName, handler);
|
||||
}
|
||||
|
||||
function initializeMappingBuilder() {
|
||||
bindOnce('wizard-add-request-mapping-row', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
field('wizard-request-mapping-rows').appendChild(createRequestRow({
|
||||
input: '',
|
||||
target: defaultTargetKind(),
|
||||
apiName: '',
|
||||
}));
|
||||
});
|
||||
bindOnce('wizard-add-response-mapping-row', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
field('wizard-response-mapping-rows').appendChild(createResponseRow({
|
||||
responsePath: '',
|
||||
output: '',
|
||||
}));
|
||||
});
|
||||
bindOnce('wizard-auto-request-mapping', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
renderRequestRows(suggestedRequestRows());
|
||||
});
|
||||
bindOnce('wizard-auto-response-mapping', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
renderResponseRows(suggestedResponseRows());
|
||||
renderResponseTree();
|
||||
});
|
||||
bindOnce('wizard-sync-request-mapping', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
syncVisualMappingsToYaml();
|
||||
});
|
||||
bindOnce('wizard-sync-response-mapping', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
syncVisualMappingsToYaml();
|
||||
});
|
||||
bindOnce('wizard-format-samples', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
formatSamplesAndInferSchemas();
|
||||
});
|
||||
|
||||
bindFileButton('wizard-load-input-sample-file', 'wizard-input-sample-file');
|
||||
bindFileButton('wizard-load-output-sample-file', 'wizard-output-sample-file');
|
||||
bindOnce('wizard-input-sample-file', 'change', function() {
|
||||
loadJsonFile('wizard-input-sample-file', 'wizard-input-sample', function() {
|
||||
renderRequestRows(suggestedRequestRows());
|
||||
});
|
||||
});
|
||||
bindOnce('wizard-output-sample-file', 'change', function() {
|
||||
loadJsonFile('wizard-output-sample-file', 'wizard-output-sample', function() {
|
||||
renderResponseRows(suggestedResponseRows());
|
||||
renderResponseTree();
|
||||
});
|
||||
});
|
||||
|
||||
['wizard-input-sample', 'tool-input-schema', 'endpoint-path'].forEach(function(id) {
|
||||
var element = field(id);
|
||||
if (!element || element.dataset.mappingRefreshBound === 'true') return;
|
||||
element.dataset.mappingRefreshBound = 'true';
|
||||
element.addEventListener('change', function() {
|
||||
renderRequestRows(requestRowsFromEditor());
|
||||
renderMappingWarnings();
|
||||
});
|
||||
});
|
||||
['wizard-output-sample', 'tool-output-schema'].forEach(function(id) {
|
||||
var element = field(id);
|
||||
if (!element || element.dataset.mappingRefreshBound === 'true') return;
|
||||
element.dataset.mappingRefreshBound = 'true';
|
||||
element.addEventListener('change', function() {
|
||||
renderResponseRows(responseRowsFromEditor());
|
||||
renderResponseTree();
|
||||
renderMappingWarnings();
|
||||
});
|
||||
});
|
||||
|
||||
renderRequestRows(requestRowsFromEditor());
|
||||
renderResponseRows(responseRowsFromEditor());
|
||||
renderResponseTree();
|
||||
renderMappingWarnings();
|
||||
}
|
||||
|
||||
window.CrankWizardMapping = {
|
||||
initialize: initializeMappingBuilder,
|
||||
sync: syncVisualMappingsToYaml,
|
||||
renderFromEditors: function() {
|
||||
suspendSync = true;
|
||||
renderRequestRows(requestRowsFromEditor());
|
||||
renderResponseRows(responseRowsFromEditor());
|
||||
suspendSync = false;
|
||||
syncVisualMappingsToYaml();
|
||||
renderResponseTree();
|
||||
renderMappingWarnings();
|
||||
},
|
||||
formatSamplesAndInferSchemas: formatSamplesAndInferSchemas,
|
||||
renderWarnings: renderMappingWarnings,
|
||||
};
|
||||
})();
|
||||
+109
-7
@@ -122,26 +122,76 @@ function preserveMappingRuleMetadata(rule, existingRule) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
function mappingEntrySource(entry) {
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
return entry.source;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function normalizeTransformKind(value) {
|
||||
if (!value) return null;
|
||||
var kind = value && typeof value === 'object' ? value.kind : value;
|
||||
kind = String(kind || '').trim();
|
||||
var allowed = [
|
||||
'identity',
|
||||
'to_string',
|
||||
'to_number',
|
||||
'to_boolean',
|
||||
'join',
|
||||
'split',
|
||||
'wrap_array',
|
||||
'unwrap_singleton',
|
||||
];
|
||||
return allowed.indexOf(kind) >= 0 ? kind : null;
|
||||
}
|
||||
|
||||
function applyMappingEntryMetadata(rule, entry, existingRule) {
|
||||
preserveMappingRuleMetadata(rule, existingRule);
|
||||
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(entry, 'required')) {
|
||||
rule.required = entry.required !== false;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(entry, 'default_value')) {
|
||||
rule.default_value = entry.default_value;
|
||||
}
|
||||
if (entry.transform !== undefined && entry.transform !== null) {
|
||||
var transformKind = normalizeTransformKind(entry.transform);
|
||||
if (transformKind) rule.transform = { kind: transformKind };
|
||||
}
|
||||
['condition', 'notes'].forEach(function(field) {
|
||||
if (entry[field] !== undefined && entry[field] !== null) {
|
||||
rule[field] = entry[field];
|
||||
}
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
function buildMappingSet(rawValue, mode) {
|
||||
var value = rawValue || {};
|
||||
var rules = [];
|
||||
|
||||
Object.keys(value).forEach(function(target) {
|
||||
var source = value[target];
|
||||
var entry = value[target];
|
||||
var source = mappingEntrySource(entry);
|
||||
if (mode === 'input') {
|
||||
var inputTarget = normalizeInputTarget(target);
|
||||
rules.push(preserveMappingRuleMetadata({
|
||||
rules.push(applyMappingEntryMetadata({
|
||||
source: normalizeInputSource(source),
|
||||
target: inputTarget,
|
||||
}, existingMappingRuleByTarget(mode, inputTarget)));
|
||||
}, entry, existingMappingRuleByTarget(mode, inputTarget)));
|
||||
return;
|
||||
}
|
||||
|
||||
var outputTarget = normalizeOutputTarget(target);
|
||||
rules.push(preserveMappingRuleMetadata({
|
||||
rules.push(applyMappingEntryMetadata({
|
||||
source: normalizeOutputSource(source),
|
||||
target: outputTarget,
|
||||
}, existingMappingRuleByTarget(mode, outputTarget)));
|
||||
}, entry, existingMappingRuleByTarget(mode, outputTarget)));
|
||||
});
|
||||
|
||||
return { rules: rules };
|
||||
@@ -313,11 +363,52 @@ function mappingSetToEditorValue(mappingSet, mode, protocol) {
|
||||
var key = rule.target.indexOf(mappingRootForProtocol(protocol) + '.') === 0
|
||||
? rule.target.replace(mappingRootForProtocol(protocol) + '.', '')
|
||||
: rule.target.replace('$.request.', '');
|
||||
object[key] = rule.source.replace('$.mcp.', '$.input.');
|
||||
var inputSource = rule.source.replace('$.mcp.', '$.input.');
|
||||
var inputEntry = { source: inputSource };
|
||||
var hasMetadata = false;
|
||||
if (rule.required === false) {
|
||||
inputEntry.required = false;
|
||||
hasMetadata = true;
|
||||
}
|
||||
if (rule.default_value !== undefined && rule.default_value !== null) {
|
||||
inputEntry.default_value = rule.default_value;
|
||||
hasMetadata = true;
|
||||
}
|
||||
if (rule.transform && rule.transform.kind) {
|
||||
inputEntry.transform = rule.transform.kind;
|
||||
hasMetadata = true;
|
||||
}
|
||||
['condition', 'notes'].forEach(function(field) {
|
||||
if (rule[field] !== undefined && rule[field] !== null) {
|
||||
inputEntry[field] = rule[field];
|
||||
hasMetadata = true;
|
||||
}
|
||||
});
|
||||
object[key] = hasMetadata ? inputEntry : inputSource;
|
||||
return;
|
||||
}
|
||||
var outputKey = rule.target.replace('$.output.', '');
|
||||
object[outputKey] = rule.source;
|
||||
var outputEntry = { source: rule.source };
|
||||
var outputHasMetadata = false;
|
||||
if (rule.required === false) {
|
||||
outputEntry.required = false;
|
||||
outputHasMetadata = true;
|
||||
}
|
||||
if (rule.default_value !== undefined && rule.default_value !== null) {
|
||||
outputEntry.default_value = rule.default_value;
|
||||
outputHasMetadata = true;
|
||||
}
|
||||
if (rule.transform && rule.transform.kind) {
|
||||
outputEntry.transform = rule.transform.kind;
|
||||
outputHasMetadata = true;
|
||||
}
|
||||
['condition', 'notes'].forEach(function(field) {
|
||||
if (rule[field] !== undefined && rule[field] !== null) {
|
||||
outputEntry[field] = rule[field];
|
||||
outputHasMetadata = true;
|
||||
}
|
||||
});
|
||||
object[outputKey] = outputHasMetadata ? outputEntry : rule.source;
|
||||
});
|
||||
return window.jsyaml ? window.jsyaml.dump(object, { lineWidth: -1 }) : JSON.stringify(object, null, 2);
|
||||
}
|
||||
@@ -338,6 +429,13 @@ function executionConfigToEditorValue(config) {
|
||||
return window.jsyaml ? window.jsyaml.dump(value, { lineWidth: -1 }) : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function setApprovalPolicyFromSnapshot(config) {
|
||||
if (!window.CrankWizardLive || typeof window.CrankWizardLive.setApprovalPolicyEditor !== 'function') {
|
||||
return;
|
||||
}
|
||||
window.CrankWizardLive.setApprovalPolicyEditor(config && config.approval_policy ? config.approval_policy : null);
|
||||
}
|
||||
|
||||
function operationSnapshot(versionDocument) {
|
||||
if (!versionDocument) return {};
|
||||
return versionDocument.snapshot || versionDocument;
|
||||
@@ -396,7 +494,11 @@ function prefillWizardFromEdit(detail, versionDocument) {
|
||||
setValue('tool-input-mapping', mappingSetToEditorValue(snapshot.input_mapping, 'input', snapshot.protocol || detail.protocol));
|
||||
setValue('tool-output-mapping', mappingSetToEditorValue(snapshot.output_mapping, 'output', snapshot.protocol || detail.protocol));
|
||||
setValue('tool-exec-config', executionConfigToEditorValue(snapshot.execution_config || {}));
|
||||
setApprovalPolicyFromSnapshot(snapshot.execution_config || {});
|
||||
prefillWizardSamples(snapshot);
|
||||
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') {
|
||||
window.CrankWizardMapping.renderFromEditors();
|
||||
}
|
||||
|
||||
var toolNameInput = document.getElementById('tool-name');
|
||||
if (toolNameInput) toolNameInput.disabled = true;
|
||||
|
||||
@@ -83,6 +83,7 @@ async function initWizardPage() {
|
||||
await loadProtocolCapabilities();
|
||||
|
||||
await loadWizardPanels([1, 2, 3, 4, 5]);
|
||||
bindWizardPanelActions();
|
||||
if (window.CrankOverlay && typeof window.CrankOverlay.render === 'function') {
|
||||
await window.CrankOverlay.render(document, {
|
||||
workspace: workspace,
|
||||
@@ -248,6 +249,36 @@ function selectMethod(btn) {
|
||||
}
|
||||
}
|
||||
|
||||
function bindWizardPanelActions() {
|
||||
var upstreamSearch = document.getElementById('upstream-search');
|
||||
var authMode = document.getElementById('new-upstream-auth-mode');
|
||||
var authKind = document.getElementById('new-auth-profile-kind');
|
||||
|
||||
if (upstreamSearch) {
|
||||
upstreamSearch.addEventListener('input', function(event) {
|
||||
filterUpstreams(event.target.value);
|
||||
});
|
||||
}
|
||||
if (authMode) authMode.addEventListener('change', updateUpstreamAuthUi);
|
||||
if (authKind) authKind.addEventListener('change', updateAuthProfileCreateUi);
|
||||
|
||||
document.querySelectorAll('.method-card[data-method]').forEach(function(button) {
|
||||
button.addEventListener('click', function() { selectMethod(button); });
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-wizard-action]').forEach(function(element) {
|
||||
element.addEventListener('click', function(event) {
|
||||
var action = element.dataset.wizardAction;
|
||||
if (action === 'toggle-upstream') toggleUpstreamDropdown(event);
|
||||
if (action === 'edit-upstream') beginEditSelectedUpstream(event);
|
||||
if (action === 'new-upstream') startNewUpstream();
|
||||
if (action === 'quick-secret') openQuickSecretModal(event);
|
||||
if (action === 'save-upstream') void saveNewUpstream(event);
|
||||
if (action === 'cancel-upstream') cancelNewUpstream(event);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
@@ -207,6 +207,17 @@ async function exportWorkspaceSnapshot() {
|
||||
async function initPage() {
|
||||
updatePageMode();
|
||||
|
||||
document.querySelectorAll('.ws-color-swatch').forEach(function(swatch) {
|
||||
swatch.addEventListener('click', function() { pickColor(swatch); });
|
||||
});
|
||||
document.querySelectorAll('[data-history-back]').forEach(function(button) {
|
||||
button.addEventListener('click', function() { window.history.back(); });
|
||||
});
|
||||
var elements = formElements();
|
||||
elements.name.addEventListener('input', function(event) { onWsNameInput(event.target.value); });
|
||||
elements.slug.addEventListener('input', function(event) { onWsSlugInput(event.target.value); });
|
||||
elements.submit.addEventListener('click', submitForm);
|
||||
|
||||
var exportButton = document.getElementById('export-workspace-btn');
|
||||
if (exportButton) {
|
||||
exportButton.addEventListener('click', exportWorkspaceSnapshot);
|
||||
|
||||
+30
-5
@@ -1,6 +1,4 @@
|
||||
var WS_LIST = [
|
||||
{ id: 'ws_default', slug: 'default', name: 'Default workspace', role: 'Owner', letter: 'D', color: '#0d9488' }
|
||||
];
|
||||
var WS_LIST = [];
|
||||
|
||||
var workspaceLoadPromise = null;
|
||||
var workspaceLoadFailed = false;
|
||||
@@ -41,8 +39,35 @@ function cacheCurrentWorkspace(workspace) {
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
function storedWorkspaceId() {
|
||||
try {
|
||||
return localStorage.getItem('crank_workspace_id') || '';
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function sessionWorkspaceId() {
|
||||
if (!(window.CrankAuth && typeof window.CrankAuth.getCachedSession === 'function')) {
|
||||
return '';
|
||||
}
|
||||
var session = window.CrankAuth.getCachedSession();
|
||||
return session && session.current_workspace_id ? session.current_workspace_id : '';
|
||||
}
|
||||
|
||||
function resolveCurrentWorkspace() {
|
||||
return WS_LIST[0] || null;
|
||||
var preferredId = sessionWorkspaceId() || storedWorkspaceId();
|
||||
if (preferredId) {
|
||||
var preferred = WS_LIST.find(function(workspace) {
|
||||
return workspace.id === preferredId;
|
||||
});
|
||||
if (preferred) {
|
||||
return preferred;
|
||||
}
|
||||
}
|
||||
return WS_LIST.find(function(workspace) {
|
||||
return workspace.id === 'ws_default';
|
||||
}) || WS_LIST[0] || null;
|
||||
}
|
||||
|
||||
function updateWorkspaceHeader(workspace) {
|
||||
@@ -88,7 +113,7 @@ async function loadWorkspaces() {
|
||||
var response = await window.CrankApi.listWorkspaces();
|
||||
var items = (response && response.items ? response.items : []).map(mapWorkspace);
|
||||
if (items.length > 0) {
|
||||
WS_LIST = [items[0]];
|
||||
WS_LIST = items;
|
||||
}
|
||||
workspaceLoadFailed = false;
|
||||
} catch (_error) {
|
||||
|
||||
@@ -6,6 +6,13 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" always;
|
||||
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
Generated
+159
-129
@@ -6,18 +6,20 @@
|
||||
"": {
|
||||
"name": "crank-ui",
|
||||
"dependencies": {
|
||||
"alpinejs": "3.15.9",
|
||||
"js-yaml": "4.1.1"
|
||||
"@fontsource/inter": "5.2.8",
|
||||
"@fontsource/jetbrains-mono": "5.2.8",
|
||||
"alpinejs": "3.15.12",
|
||||
"js-yaml": "5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"esbuild": "^0.28.0"
|
||||
"@playwright/test": "1.61.1",
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -32,9 +34,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -49,9 +51,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -66,9 +68,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -83,9 +85,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -100,9 +102,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -117,9 +119,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -134,9 +136,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -151,9 +153,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -168,9 +170,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -185,9 +187,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -202,9 +204,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -219,9 +221,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -236,9 +238,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -253,9 +255,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -270,9 +272,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -287,9 +289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -304,9 +306,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -321,9 +323,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -338,9 +340,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -355,9 +357,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -372,9 +374,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -389,9 +391,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -406,9 +408,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -423,9 +425,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -440,9 +442,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -456,14 +458,32 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -488,9 +508,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.15.9",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.9.tgz",
|
||||
"integrity": "sha512-O30m8Tw/aARbLXmeTnISAFgrNm0K71PT7bZy/1NgRqFD36QGb34VJ4a6WBL1iIO/bofN+LkIkKLikUTkfPL2wQ==",
|
||||
"version": "3.15.12",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
|
||||
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
@@ -503,9 +523,9 @@
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -516,32 +536,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
@@ -560,25 +580,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz",
|
||||
"integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
"js-yaml": "bin/js-yaml.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -591,9 +621,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
"e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"alpinejs": "3.15.9",
|
||||
"js-yaml": "4.1.1"
|
||||
"@fontsource/inter": "5.2.8",
|
||||
"@fontsource/jetbrains-mono": "5.2.8",
|
||||
"alpinejs": "3.15.12",
|
||||
"js-yaml": "5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"esbuild": "^0.28.0"
|
||||
"@playwright/test": "1.61.1",
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ const BRAND_IMAGE_PATHS = [
|
||||
path.join(ROOT_DIR, 'crank-community.png'),
|
||||
path.resolve(ROOT_DIR, '..', '..', 'crank-community.png'),
|
||||
];
|
||||
const FONT_FILES = [
|
||||
['@fontsource/inter', 'inter', ['400', '500', '600', '700']],
|
||||
['@fontsource/jetbrains-mono', 'jetbrains-mono', ['400', '500']],
|
||||
];
|
||||
|
||||
const BUNDLES = {
|
||||
'protected-core': {
|
||||
@@ -20,6 +24,7 @@ const BUNDLES = {
|
||||
'js/overlay-loader.js',
|
||||
'js/dom.js',
|
||||
'js/diagnostics.js',
|
||||
'js/page-transitions.js',
|
||||
'js/workspace.js',
|
||||
'js/api.js',
|
||||
'js/ui-feedback.js',
|
||||
@@ -40,6 +45,7 @@ const BUNDLES = {
|
||||
operations: {
|
||||
files: [
|
||||
'node_modules/alpinejs/dist/cdn.min.js',
|
||||
'js/openapi-import.js',
|
||||
'js/catalog.js',
|
||||
],
|
||||
},
|
||||
@@ -87,11 +93,12 @@ const BUNDLES = {
|
||||
},
|
||||
wizard: {
|
||||
files: [
|
||||
'node_modules/js-yaml/dist/js-yaml.min.js',
|
||||
'node_modules/js-yaml/dist/browser/js-yaml.umd.min.js',
|
||||
'js/wizard-state.js',
|
||||
'js/wizard-shell.js',
|
||||
'js/wizard-upstreams.js',
|
||||
'js/wizard-model.js',
|
||||
'js/wizard-mapping.js',
|
||||
'js/wizard-live.js',
|
||||
'js/wizard.js',
|
||||
'js/nav.js',
|
||||
@@ -133,6 +140,20 @@ function copyDirectory(source, destination) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyFonts() {
|
||||
FONT_FILES.forEach(function([packageName, family, weights]) {
|
||||
weights.forEach(function(weight) {
|
||||
['latin', 'cyrillic'].forEach(function(subset) {
|
||||
var fileName = `${family}-${subset}-${weight}-normal.woff2`;
|
||||
copyFile(
|
||||
path.join(ROOT_DIR, 'node_modules', packageName, 'files', fileName),
|
||||
path.join(DIST_DIR, 'fonts', fileName)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readSource(relativePath) {
|
||||
return fs.readFileSync(sourcePath(relativePath), 'utf8');
|
||||
}
|
||||
@@ -188,6 +209,7 @@ async function main() {
|
||||
}
|
||||
|
||||
copyDirectory(path.join(ROOT_DIR, 'css'), path.join(DIST_DIR, 'css'));
|
||||
copyFonts();
|
||||
copyDirectory(path.join(ROOT_DIR, 'data'), path.join(DIST_DIR, 'data'));
|
||||
ensureDirectory(path.join(DIST_DIR, 'html', 'wizard'));
|
||||
[
|
||||
|
||||
@@ -119,8 +119,18 @@ function proxyRequest(request, response) {
|
||||
},
|
||||
},
|
||||
(proxyResponse) => {
|
||||
if (response.destroyed || response.writableEnded) {
|
||||
proxyResponse.resume();
|
||||
return;
|
||||
}
|
||||
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
|
||||
pipeline(proxyResponse, response, () => {});
|
||||
proxyResponse.on('error', function() {
|
||||
if (!response.destroyed) response.destroy();
|
||||
});
|
||||
response.on('close', function() {
|
||||
if (!proxyResponse.destroyed) proxyResponse.destroy();
|
||||
});
|
||||
proxyResponse.pipe(response);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -22,8 +22,25 @@ test('api keys page opens create key flow', async ({ page }) => {
|
||||
await expect(page.locator('#btn-create-key')).toBeEnabled();
|
||||
await page.locator('#btn-create-key').click();
|
||||
await expect(page.locator('#modal-create')).toHaveClass(/open/);
|
||||
await expect(page.locator('.modal-title')).toHaveText(localized('Create agent key', 'Создать ключ агента'));
|
||||
await expect(page.locator('.modal-title')).toHaveText(localized('Create MCP client key', 'Создать ключ MCP-клиента'));
|
||||
await page.locator('#new-key-name').fill(`playwright-${Date.now()}`);
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
|
||||
await page.locator('#modal-done-btn').click();
|
||||
|
||||
await page.locator('#key-kind-approval').click();
|
||||
await expect(page.locator('#key-kind-hint')).toContainText(
|
||||
localized('human confirmation interface', 'человек подтверждает действие')
|
||||
);
|
||||
await expect(page.locator('#btn-create-key')).toContainText(
|
||||
localized('Create approval key', 'Создать ключ подтверждения')
|
||||
);
|
||||
await page.locator('#btn-create-key').click();
|
||||
await expect(page.locator('.modal-title')).toHaveText(localized('Create approval key', 'Создать ключ подтверждения'));
|
||||
await expect(page.locator('#approval-key-warning')).toContainText(
|
||||
localized('Do not pass this key to an LLM', 'Не передавайте этот ключ LLM')
|
||||
);
|
||||
await page.locator('#new-key-name').fill(`playwright-approval-${Date.now()}`);
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
await expect(page.locator('#reveal-key-value')).toContainText('crk_appr_');
|
||||
});
|
||||
|
||||
@@ -1,13 +1,119 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { login, localized } = require('./helpers');
|
||||
const { login, localized, uniqueName } = require('./helpers');
|
||||
|
||||
test('operations page shows demo catalog and filter works', async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page.locator('.page-heading')).toHaveText(localized('Operations', 'Операции'));
|
||||
await expect(page.locator('.ws-switcher-trigger')).toBeVisible();
|
||||
await expect(page.locator('#ws-dropdown')).toHaveCount(0);
|
||||
await expect(page.locator('tbody tr')).toHaveCount(2);
|
||||
await page.getByPlaceholder(localized('Search operations', 'Поиск операций')).fill('crm');
|
||||
await expect(page.locator('tbody tr')).toHaveCount(1);
|
||||
await expect(page.locator('tbody tr').first()).toContainText(/crm_create_lead/i);
|
||||
await page.getByPlaceholder(localized('Search operations', 'Поиск операций')).fill('frankfurter');
|
||||
await expect(page.locator('tbody tr')).toHaveCount(1);
|
||||
await expect(page.locator('tbody tr').first()).toContainText(/frankfurter_latest_rate/i);
|
||||
});
|
||||
|
||||
test('operations page imports OpenAPI methods as drafts', async ({ page }) => {
|
||||
await login(page);
|
||||
const operationId = uniqueName('get_rates');
|
||||
const statusOperationId = `${operationId}_status`;
|
||||
await page.getByRole('button', { name: /Импорт OpenAPI/i }).click();
|
||||
await expect(page.locator('#openapi-import-modal')).toBeVisible();
|
||||
await page.locator('#openapi-import-document').fill(`
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Demo Import API
|
||||
servers:
|
||||
paths:
|
||||
/v1/rates/{date}:
|
||||
post:
|
||||
operationId: ${operationId}
|
||||
summary: Получить курсы валют
|
||||
description: Курсы.
|
||||
tags: [currency]
|
||||
parameters:
|
||||
- name: date
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: base
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: X-API-Version
|
||||
in: header
|
||||
required: false
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [symbols]
|
||||
properties:
|
||||
symbols: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
base: { type: string }
|
||||
/v1/status:
|
||||
get:
|
||||
operationId: ${statusOperationId}
|
||||
summary: Проверить статус
|
||||
tags: [service]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string }
|
||||
`);
|
||||
await page.locator('#openapi-import-preview').click();
|
||||
await expect(page.locator('#openapi-import-preview-panel')).toBeVisible();
|
||||
await expect(page.locator('.openapi-import-document-findings')).toContainText(/base URL/i);
|
||||
await expect(page.locator('.openapi-import-group').filter({ hasText: 'currency' })).toBeVisible();
|
||||
const importedOperation = page.locator('.openapi-import-operation').filter({ hasText: 'Получить курсы валют' });
|
||||
await expect(importedOperation).toBeVisible();
|
||||
await expect(page.locator('.openapi-import-operation').filter({ hasText: 'Проверить статус' })).toBeVisible();
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Path');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('date');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Query');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('base');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Header');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('X-API-Version');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Body');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('symbols');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Ответ');
|
||||
await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 2');
|
||||
await page.locator('#openapi-import-search').fill('status');
|
||||
await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1);
|
||||
await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Проверить статус' })).toBeVisible();
|
||||
await page.locator('#openapi-import-clear-visible').click();
|
||||
await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 1');
|
||||
await page.locator('#openapi-import-search').fill('');
|
||||
await page.locator('#openapi-import-method-filter').selectOption('POST');
|
||||
await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1);
|
||||
await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Получить курсы валют' })).toBeVisible();
|
||||
await page.locator('#openapi-import-method-filter').selectOption('');
|
||||
await page.locator('#openapi-import-server-custom').fill('https://api.example.test');
|
||||
await page.locator('#openapi-import-create').click();
|
||||
await expect(page.locator('#openapi-import-result')).toContainText('Результат импорта');
|
||||
await expect(page.locator('.openapi-import-result-table')).toBeVisible();
|
||||
await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('POST /v1/rates/{date}');
|
||||
await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText(/Описание инструмента слишком короткое/);
|
||||
await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('Исправить в мастере');
|
||||
await expect(page.locator('.openapi-import-primary-result a')).toHaveAttribute(
|
||||
'href',
|
||||
/\/wizard\/\?mode=edit&operationId=op_/
|
||||
);
|
||||
await expect(page.locator('tbody')).toContainText(new RegExp(operationId, 'i'));
|
||||
await expect(page.locator('tbody')).not.toContainText(new RegExp(statusOperationId, 'i'));
|
||||
});
|
||||
|
||||
@@ -65,3 +65,22 @@ test('secret modal shows fields for selected secret kind only', async ({ page })
|
||||
await expect(basicField).toBeHidden();
|
||||
await expect(jsonField).toBeHidden();
|
||||
});
|
||||
|
||||
test('generic json secret modal stays inside compact viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 900, height: 520 });
|
||||
await login(page);
|
||||
await page.goto('/secrets');
|
||||
|
||||
await page.locator('[data-testid="secret-create-button"]').click();
|
||||
await page.locator('[data-testid="secret-kind-select"]').selectOption('generic');
|
||||
|
||||
const modal = page.locator('[data-testid="secret-create-modal"] .modal');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
const box = await modal.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box.y).toBeGreaterThanOrEqual(0);
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(520);
|
||||
await expect(page.locator('[data-testid="secret-name-input"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="secret-submit-button"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { getCurrentWorkspace, login, localized } = require('./helpers');
|
||||
|
||||
test('mobile wizard progress connector crosses the indicator centers', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 720, height: 900 });
|
||||
await login(page);
|
||||
await page.goto('/wizard/');
|
||||
|
||||
const geometry = await page.locator('.steps-list').evaluate((list) => {
|
||||
const indicators = [...list.querySelectorAll('.step-indicator')];
|
||||
const first = indicators[0].getBoundingClientRect();
|
||||
const last = indicators.at(-1).getBoundingClientRect();
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const line = getComputedStyle(list, '::before');
|
||||
return {
|
||||
leftDelta: Math.abs(listRect.left + Number.parseFloat(line.left) - (first.left + first.width / 2)),
|
||||
rightDelta: Math.abs(listRect.right - Number.parseFloat(line.right) - (last.left + last.width / 2)),
|
||||
topDelta: Math.abs(listRect.top + Number.parseFloat(line.top) - (first.top + first.height / 2)),
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.leftDelta).toBeLessThan(1);
|
||||
expect(geometry.rightDelta).toBeLessThan(1);
|
||||
expect(geometry.topDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test('wizard loads and protocol selection updates flow', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/wizard/');
|
||||
@@ -122,6 +145,7 @@ test('wizard loads reusable upstreams from backend', async ({ page }) => {
|
||||
);
|
||||
|
||||
await page.goto('/wizard/');
|
||||
await expect(page.locator('#step-panel-1')).toBeVisible();
|
||||
await page.locator('[data-testid="wizard-protocol-rest"]').click();
|
||||
await page.locator('.btn-continue').click();
|
||||
await expect(page.locator('#step-panel-2')).toBeVisible();
|
||||
@@ -132,6 +156,69 @@ test('wizard loads reusable upstreams from backend', async ({ page }) => {
|
||||
await expect(page.locator('#upstream-dropdown-list')).not.toContainText('Open Meteo');
|
||||
});
|
||||
|
||||
test('wizard builds visual request mappings from JSON sample and path params', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
await page.goto('/wizard/');
|
||||
await page.locator('[data-testid="wizard-protocol-rest"]').click();
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(2));
|
||||
await expect(page.locator('#step-panel-2')).toBeVisible();
|
||||
await page.locator('#endpoint-path').fill('/rates/{date}');
|
||||
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(3));
|
||||
await expect(page.locator('#step-panel-3-rest')).toBeVisible();
|
||||
await page.locator('.method-card[data-method="GET"]').click();
|
||||
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(5));
|
||||
await expect(page.locator('#step-panel-5')).toBeVisible();
|
||||
|
||||
await page.locator('#wizard-input-sample').fill(JSON.stringify({
|
||||
date: '2024-01-01',
|
||||
base: 'USD',
|
||||
symbols: 'EUR',
|
||||
}, null, 2));
|
||||
await page.locator('#wizard-output-sample').fill(JSON.stringify({
|
||||
base: 'USD',
|
||||
rates: {
|
||||
EUR: 0.91,
|
||||
},
|
||||
providers: [
|
||||
{
|
||||
name: 'ecb',
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
}, null, 2));
|
||||
|
||||
await page.locator('#wizard-format-samples').click();
|
||||
|
||||
await expect(page.locator('[data-testid="wizard-request-mapping-rows"] [data-role="input"]').first()).toHaveValue('date');
|
||||
await expect(page.locator('[data-testid="wizard-request-mapping-rows"] [data-role="input"]').nth(1)).toHaveValue('base');
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/path\.date/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.base/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.symbols/);
|
||||
await expect(page.locator('#tool-input-schema')).toHaveValue(/"date"/);
|
||||
await expect(page.locator('#tool-output-schema')).toHaveValue(/"rates"/);
|
||||
await expect(page.locator('[data-testid="wizard-response-json-tree"]')).toContainText('rates.EUR');
|
||||
await expect(page.locator('[data-testid="wizard-response-json-tree"]')).toContainText('providers[0].name');
|
||||
await expect(page.locator('#tool-output-mapping')).toHaveValue(/providers_0_name: \$\.response\.body\.providers\[0\]\.name/);
|
||||
await expect(page.locator('[data-testid="wizard-mapping-warnings"]')).toContainText(/одного элемента массива/);
|
||||
|
||||
await page.locator('#wizard-add-request-mapping-row').click();
|
||||
const defaultRow = page.locator('[data-testid="wizard-request-mapping-rows"] [data-mapping-row="request"]').last();
|
||||
await defaultRow.locator('[data-role="input"]').fill('group');
|
||||
await defaultRow.locator('[data-role="target"]').selectOption('query');
|
||||
await defaultRow.locator('[data-role="apiName"]').fill('group');
|
||||
await defaultRow.locator('[data-role="defaultValue"]').fill('month');
|
||||
await defaultRow.locator('[data-role="transform"]').selectOption('to_string');
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.group:/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/default_value: month/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/transform: to_string/);
|
||||
|
||||
await page.locator('[data-testid="wizard-request-mapping-rows"] .mapping-row-remove').first().click();
|
||||
await expect(page.locator('[data-testid="wizard-mapping-warnings"]')).toContainText(/date/);
|
||||
});
|
||||
|
||||
test('wizard edit mode hydrates fields from operation version snapshot', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
@@ -301,6 +388,15 @@ test('wizard edit mode hydrates fields from operation version snapshot', async (
|
||||
from: 'GBP',
|
||||
to: 'USD',
|
||||
},
|
||||
import_findings: [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'openapi_import.weak_tool_description',
|
||||
message: 'Описание инструмента слишком короткое или техническое.',
|
||||
suggested_action: 'Уточните, когда агент должен вызывать инструмент.',
|
||||
field_path: 'GET /rates',
|
||||
},
|
||||
],
|
||||
},
|
||||
samples: [],
|
||||
generated_draft: null,
|
||||
@@ -338,6 +434,13 @@ test('wizard edit mode hydrates fields from operation version snapshot', async (
|
||||
await expect(page.locator('#wizard-test-input')).toHaveValue(/"from": "GBP"/);
|
||||
await expect(page.locator('#wizard-test-input')).toHaveValue(/"to": "USD"/);
|
||||
await expect(page.locator('#wizard-test-input')).not.toHaveValue(/Ada/);
|
||||
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(5));
|
||||
await expect(page.locator('#wizard-quality-findings')).toContainText('Описание инструмента слишком короткое');
|
||||
await expect(page.locator('#wizard-quality-findings')).toContainText('GET /rates');
|
||||
await page.getByRole('button', { name: 'Перейти к описанию' }).click();
|
||||
await expect(page.locator('#step-panel-4')).toBeVisible();
|
||||
await expect(page.locator('#tool-description')).toBeFocused();
|
||||
});
|
||||
|
||||
test('wizard shows agent-facing MCP preview from current draft fields', async ({ page }) => {
|
||||
@@ -470,6 +573,13 @@ test('wizard shows agent-facing MCP preview from current draft fields', async ({
|
||||
headers: {},
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
approval_policy: {
|
||||
required: true,
|
||||
risk_level: 'financial',
|
||||
ttl_seconds: 180,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: 'masked_json',
|
||||
},
|
||||
},
|
||||
tool_description: {
|
||||
title: 'Получить историю курсов за месяц',
|
||||
@@ -669,6 +779,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
target: '$.request.query.group',
|
||||
required: false,
|
||||
default_value: 'month',
|
||||
transform: { kind: 'to_string' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -684,6 +795,13 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
headers: {},
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
approval_policy: {
|
||||
required: true,
|
||||
risk_level: 'financial',
|
||||
ttl_seconds: 180,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: 'masked_json',
|
||||
},
|
||||
},
|
||||
tool_description: {
|
||||
title: 'Получить последний курс',
|
||||
@@ -706,6 +824,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
base: 'CHF',
|
||||
symbols: 'JPY',
|
||||
},
|
||||
import_findings: [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'openapi_import.weak_tool_description',
|
||||
message: 'Описание инструмента слишком короткое или техническое.',
|
||||
suggested_action: 'Уточните описание.',
|
||||
field_path: 'GET /latest',
|
||||
},
|
||||
],
|
||||
},
|
||||
samples: [],
|
||||
generated_draft: null,
|
||||
@@ -719,6 +846,14 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
await page.goto(`/wizard/?mode=edit&operationId=${operationId}`);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.base/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/path\.date/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/transform: to_string/);
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(3));
|
||||
await expect(page.locator('#approval-required')).toBeChecked();
|
||||
await expect(page.locator('#approval-config-fields')).toBeVisible();
|
||||
await expect(page.locator('#approval-mode')).toHaveValue('custom');
|
||||
await expect(page.locator('#approval-risk-level')).toHaveCount(0);
|
||||
await expect(page.locator('#approval-ttl-seconds')).toHaveValue('180');
|
||||
await expect(page.locator('#approval-payload-preview-mode')).toHaveCount(0);
|
||||
await page.locator('.btn-save-draft').click();
|
||||
await expect.poll(() => updatePayload).not.toBeNull();
|
||||
|
||||
@@ -744,6 +879,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
headers: {},
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
approval_policy: {
|
||||
required: true,
|
||||
mode: 'custom',
|
||||
risk_level: 'normal',
|
||||
ttl_seconds: 180,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: 'summary',
|
||||
elicitation_message: null,
|
||||
},
|
||||
});
|
||||
expect(updatePayload.tool_description).toEqual({
|
||||
title: 'Получить последний курс',
|
||||
@@ -766,6 +910,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
base: 'CHF',
|
||||
symbols: 'JPY',
|
||||
},
|
||||
import_findings: [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'openapi_import.weak_tool_description',
|
||||
message: 'Описание инструмента слишком короткое или техническое.',
|
||||
suggested_action: 'Уточните описание.',
|
||||
field_path: 'GET /latest',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(updatePayload.input_mapping.rules).toEqual([
|
||||
{ source: '$.mcp.base', target: '$.request.query.base', required: true },
|
||||
@@ -776,6 +929,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
target: '$.request.query.group',
|
||||
required: false,
|
||||
default_value: 'month',
|
||||
transform: { kind: 'to_string' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
use std::{collections::BTreeMap, time::Duration};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, io,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crank_core::{HttpMethod, RestTarget};
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::{
|
||||
Client,
|
||||
dns::{Addrs, Name, Resolve, Resolving},
|
||||
header::{HeaderMap, HeaderName, HeaderValue},
|
||||
redirect,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -11,9 +20,19 @@ use crate::{RestAdapterError, RestRequest, RestResponse};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RestAdapter {
|
||||
client: Client,
|
||||
client: Result<Client, Arc<str>>,
|
||||
policy: OutboundHttpPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OutboundHttpPolicy {
|
||||
allowed_hosts: Vec<String>,
|
||||
denied_hosts: Vec<String>,
|
||||
max_response_bytes: usize,
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
impl Default for RestAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -22,9 +41,25 @@ impl Default for RestAdapter {
|
||||
|
||||
impl RestAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
}
|
||||
Self::with_policy(OutboundHttpPolicy::default())
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self, RestAdapterError> {
|
||||
Ok(Self::with_policy(OutboundHttpPolicy::from_env()?))
|
||||
}
|
||||
|
||||
pub fn with_policy(policy: OutboundHttpPolicy) -> Self {
|
||||
let resolver = Arc::new(PolicyDnsResolver {
|
||||
policy: policy.clone(),
|
||||
});
|
||||
let client = Client::builder()
|
||||
.redirect(redirect::Policy::none())
|
||||
.no_proxy()
|
||||
.dns_resolver(resolver)
|
||||
.build()
|
||||
.map_err(|error| Arc::<str>::from(error.to_string()));
|
||||
|
||||
Self { client, policy }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
@@ -33,9 +68,15 @@ impl RestAdapter {
|
||||
request: &RestRequest,
|
||||
) -> Result<RestResponse, RestAdapterError> {
|
||||
let url = build_url(target, request)?;
|
||||
self.policy.validate_url(&url)?;
|
||||
let headers = build_headers(target, request)?;
|
||||
let mut builder = self
|
||||
.client
|
||||
let client =
|
||||
self.client
|
||||
.as_ref()
|
||||
.map_err(|details| RestAdapterError::InvalidConfiguration {
|
||||
details: details.to_string(),
|
||||
})?;
|
||||
let mut builder = client
|
||||
.request(to_reqwest_method(target.method), url)
|
||||
.headers(headers)
|
||||
.timeout(Duration::from_millis(request.timeout_ms));
|
||||
@@ -47,7 +88,7 @@ impl RestAdapter {
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
let headers = normalize_headers(response.headers());
|
||||
let body = decode_body(response).await?;
|
||||
let body = decode_body(response, self.policy.max_response_bytes).await?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(RestAdapterError::UnexpectedStatus {
|
||||
@@ -64,6 +105,254 @@ impl RestAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OutboundHttpPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowed_hosts: Vec::new(),
|
||||
denied_hosts: Vec::new(),
|
||||
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutboundHttpPolicy {
|
||||
pub fn from_env() -> Result<Self, RestAdapterError> {
|
||||
let max_response_bytes = match env::var("CRANK_OUTBOUND_MAX_RESPONSE_BYTES") {
|
||||
Ok(value) => {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| RestAdapterError::InvalidConfiguration {
|
||||
details: "CRANK_OUTBOUND_MAX_RESPONSE_BYTES must be a positive integer"
|
||||
.to_owned(),
|
||||
})?
|
||||
}
|
||||
Err(env::VarError::NotPresent) => DEFAULT_MAX_RESPONSE_BYTES,
|
||||
Err(error) => {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: error.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
if max_response_bytes == 0 {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: "CRANK_OUTBOUND_MAX_RESPONSE_BYTES must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
allowed_hosts: host_patterns_from_env("CRANK_OUTBOUND_ALLOWED_HOSTS")?,
|
||||
denied_hosts: host_patterns_from_env("CRANK_OUTBOUND_DENIED_HOSTS")?,
|
||||
max_response_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn allowing_hosts(hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
Self {
|
||||
allowed_hosts: hosts.into_iter().map(Into::into).collect(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_max_response_bytes(mut self, max_response_bytes: usize) -> Self {
|
||||
self.max_response_bytes = max_response_bytes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn validate_base_url(&self, base_url: &str) -> Result<(), RestAdapterError> {
|
||||
let url = reqwest::Url::parse(base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
||||
url: base_url.to_owned(),
|
||||
})?;
|
||||
self.validate_url(&url)
|
||||
}
|
||||
|
||||
fn validate_url(&self, url: &reqwest::Url) -> Result<(), RestAdapterError> {
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: url.to_string(),
|
||||
});
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| RestAdapterError::TargetNotAllowed {
|
||||
target: url.to_string(),
|
||||
})?;
|
||||
self.validate_host(host)?;
|
||||
if !self.is_explicitly_allowed(host) && is_local_hostname(host) {
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: host.to_owned(),
|
||||
});
|
||||
}
|
||||
if let Ok(address) = host.parse::<IpAddr>()
|
||||
&& !self.is_explicitly_allowed(host)
|
||||
&& !is_public_ip(address)
|
||||
{
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: host.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_host(&self, host: &str) -> Result<(), RestAdapterError> {
|
||||
let host = normalize_host(host);
|
||||
let denied = self
|
||||
.denied_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host));
|
||||
if denied {
|
||||
return Err(RestAdapterError::TargetNotAllowed { target: host });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_explicitly_allowed(&self, host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
self.allowed_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PolicyDnsResolver {
|
||||
policy: OutboundHttpPolicy,
|
||||
}
|
||||
|
||||
impl Resolve for PolicyDnsResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let host = normalize_host(name.as_str());
|
||||
let policy = self.policy.clone();
|
||||
Box::pin(async move {
|
||||
policy
|
||||
.validate_host(&host)
|
||||
.map_err(|error| boxed_io_error(error.to_string()))?;
|
||||
let explicitly_allowed = policy.is_explicitly_allowed(&host);
|
||||
let resolved = tokio::net::lookup_host((host.as_str(), 0))
|
||||
.await
|
||||
.map_err(|error| Box::new(error) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||
let addresses = resolved
|
||||
.filter(|address| explicitly_allowed || is_public_ip(address.ip()))
|
||||
.collect::<Vec<SocketAddr>>();
|
||||
if addresses.is_empty() {
|
||||
return Err(boxed_io_error(format!(
|
||||
"outbound target {host} did not resolve to an allowed address"
|
||||
)));
|
||||
}
|
||||
Ok(Box::new(addresses.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed_io_error(message: String) -> Box<dyn std::error::Error + Send + Sync> {
|
||||
Box::new(io::Error::new(io::ErrorKind::PermissionDenied, message))
|
||||
}
|
||||
|
||||
fn host_patterns_from_env(name: &str) -> Result<Vec<String>, RestAdapterError> {
|
||||
let value = match env::var(name) {
|
||||
Ok(value) => value,
|
||||
Err(env::VarError::NotPresent) => return Ok(Vec::new()),
|
||||
Err(error) => {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: error.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
let wildcard = value.starts_with("*.");
|
||||
let normalized = normalize_host(value.trim_start_matches("*."));
|
||||
let valid_ip = !wildcard && normalized.parse::<IpAddr>().is_ok();
|
||||
if normalized.is_empty()
|
||||
|| normalized.contains('/')
|
||||
|| (!valid_ip && normalized.contains(':'))
|
||||
|| (wildcard && normalized.parse::<IpAddr>().is_ok())
|
||||
{
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: format!("{name} contains an invalid host pattern: {value}"),
|
||||
});
|
||||
}
|
||||
Ok(if wildcard {
|
||||
format!("*.{normalized}")
|
||||
} else {
|
||||
normalized
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_host(host: &str) -> String {
|
||||
host.trim()
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.trim_end_matches('.')
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn is_local_hostname(host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
host == "localhost" || host.ends_with(".localhost")
|
||||
}
|
||||
|
||||
fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
pattern.strip_prefix("*.").map_or_else(
|
||||
|| pattern == host,
|
||||
|suffix| host != suffix && host.ends_with(&format!(".{suffix}")),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_public_ip(address: IpAddr) -> bool {
|
||||
match address {
|
||||
IpAddr::V4(address) => is_public_ipv4(address),
|
||||
IpAddr::V6(address) => is_public_ipv6(address),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_public_ipv4(address: Ipv4Addr) -> bool {
|
||||
let octets = address.octets();
|
||||
!(address.is_private()
|
||||
|| address.is_loopback()
|
||||
|| address.is_link_local()
|
||||
|| address.is_broadcast()
|
||||
|| address.is_documentation()
|
||||
|| address.is_unspecified()
|
||||
|| address.is_multicast()
|
||||
|| octets[0] == 0
|
||||
|| (octets[0] == 100 && (64..=127).contains(&octets[1]))
|
||||
|| (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
|
||||
|| (octets[0] == 198 && (18..=19).contains(&octets[1]))
|
||||
|| octets[0] >= 240)
|
||||
}
|
||||
|
||||
fn is_public_ipv6(address: Ipv6Addr) -> bool {
|
||||
let segments = address.segments();
|
||||
if let Some(address) = address.to_ipv4_mapped() {
|
||||
return is_public_ipv4(address);
|
||||
}
|
||||
if segments[..6].iter().all(|segment| *segment == 0) {
|
||||
let [a, b] = segments[6].to_be_bytes();
|
||||
let [c, d] = segments[7].to_be_bytes();
|
||||
return is_public_ipv4(Ipv4Addr::new(a, b, c, d));
|
||||
}
|
||||
!(address.is_unspecified()
|
||||
|| address.is_loopback()
|
||||
|| address.is_multicast()
|
||||
|| (segments[0] & 0xfe00) == 0xfc00
|
||||
|| (segments[0] & 0xffc0) == 0xfe80
|
||||
|| (segments[0] & 0xffc0) == 0xfec0
|
||||
|| (segments[0] == 0x0064
|
||||
&& segments[1] == 0xff9b
|
||||
&& segments[2..6].iter().all(|segment| *segment == 0))
|
||||
|| (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1)
|
||||
|| segments[0] == 0x2002
|
||||
|| (segments[0] == 0x2001 && matches!(segments[1], 0 | 0x0db8)))
|
||||
}
|
||||
|
||||
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
|
||||
let base_url =
|
||||
reqwest::Url::parse(&target.base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
||||
@@ -127,8 +416,29 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn decode_body(response: reqwest::Response) -> Result<Value, RestAdapterError> {
|
||||
let bytes = response.bytes().await?;
|
||||
async fn decode_body(
|
||||
response: reqwest::Response,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<Value, RestAdapterError> {
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > max_response_bytes as u64)
|
||||
{
|
||||
return Err(RestAdapterError::ResponseTooLarge {
|
||||
limit_bytes: max_response_bytes,
|
||||
});
|
||||
}
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
if bytes.len().saturating_add(chunk.len()) > max_response_bytes {
|
||||
return Err(RestAdapterError::ResponseTooLarge {
|
||||
limit_bytes: max_response_bytes,
|
||||
});
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
if bytes.is_empty() {
|
||||
return Ok(Value::Null);
|
||||
@@ -163,127 +473,3 @@ fn to_reqwest_method(method: HttpMethod) -> reqwest::Method {
|
||||
HttpMethod::Delete => reqwest::Method::DELETE,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query},
|
||||
http::HeaderMap,
|
||||
routing::{get, post},
|
||||
};
|
||||
use crank_core::{HttpMethod, RestTarget};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::{RestAdapter, RestAdapterError, RestRequest};
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_rest_request_and_normalizes_json_response() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = RestAdapter::new();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/users/{user_id}".to_owned(),
|
||||
static_headers: BTreeMap::from([("x-static".to_owned(), "static".to_owned())]),
|
||||
};
|
||||
let request = RestRequest {
|
||||
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
|
||||
query_params: BTreeMap::from([("expand".to_owned(), "true".to_owned())]),
|
||||
headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
};
|
||||
|
||||
let response = adapter.execute(&target, &request).await.unwrap();
|
||||
|
||||
assert_eq!(response.status_code, 200);
|
||||
assert_eq!(
|
||||
response.body,
|
||||
json!({
|
||||
"id": "42",
|
||||
"query": "true",
|
||||
"trace": "trace-123",
|
||||
"static": "static",
|
||||
"payload": { "name": "Ada" }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_unexpected_status_with_normalized_body() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = RestAdapter::new();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/fail".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
timeout_ms: 1_000,
|
||||
};
|
||||
|
||||
let error = adapter.execute(&target, &request).await.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RestAdapterError::UnexpectedStatus {
|
||||
status: 502,
|
||||
body: Value::Object(_)
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
async fn spawn_test_server() -> String {
|
||||
let app = Router::new()
|
||||
.route("/users/{user_id}", post(create_user))
|
||||
.route("/fail", get(fail));
|
||||
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)
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
Path(user_id): Path<String>,
|
||||
Query(query): Query<BTreeMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Json<Value> {
|
||||
let trace = headers
|
||||
.get("x-trace-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
let static_header = headers
|
||||
.get("x-static")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(json!({
|
||||
"id": user_id,
|
||||
"query": query.get("expand").cloned().unwrap_or_default(),
|
||||
"trace": trace,
|
||||
"static": static_header,
|
||||
"payload": payload
|
||||
}))
|
||||
}
|
||||
|
||||
async fn fail() -> (axum::http::StatusCode, Json<Value>) {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": "upstream failed" })),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ pub enum RestAdapterError {
|
||||
InvalidHeaderName { header: String },
|
||||
#[error("invalid header value for {header}")]
|
||||
InvalidHeaderValue { header: String },
|
||||
#[error("outbound target is not allowed: {target}")]
|
||||
TargetNotAllowed { target: String },
|
||||
#[error("rest response exceeds the configured limit of {limit_bytes} bytes")]
|
||||
ResponseTooLarge { limit_bytes: usize },
|
||||
#[error("invalid outbound HTTP configuration: {details}")]
|
||||
InvalidConfiguration { details: String },
|
||||
#[error("request failed")]
|
||||
Transport(#[from] reqwest::Error),
|
||||
#[error("sse collection window expired before stream completed")]
|
||||
|
||||
@@ -8,7 +8,7 @@ use crank_core::{
|
||||
ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target,
|
||||
};
|
||||
|
||||
pub use client::RestAdapter;
|
||||
pub use client::{OutboundHttpPolicy, RestAdapter};
|
||||
pub use error::RestAdapterError;
|
||||
pub use model::{RestRequest, RestResponse};
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mod integration {
|
||||
mod client;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Redirect,
|
||||
routing::{get, post},
|
||||
};
|
||||
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
|
||||
use crank_core::{HttpMethod, RestTarget};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_rest_request_and_normalizes_json_response() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = test_adapter();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/users/{user_id}".to_owned(),
|
||||
static_headers: BTreeMap::from([("x-static".to_owned(), "static".to_owned())]),
|
||||
};
|
||||
let request = RestRequest {
|
||||
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
|
||||
query_params: BTreeMap::from([("expand".to_owned(), "true".to_owned())]),
|
||||
headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
};
|
||||
|
||||
let response = adapter.execute(&target, &request).await.unwrap();
|
||||
|
||||
assert_eq!(response.status_code, 200);
|
||||
assert_eq!(
|
||||
response.body,
|
||||
json!({
|
||||
"id": "42",
|
||||
"query": "true",
|
||||
"trace": "trace-123",
|
||||
"static": "static",
|
||||
"payload": { "name": "Ada" }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_unexpected_status_with_normalized_body() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = test_adapter();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/fail".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
timeout_ms: 1_000,
|
||||
};
|
||||
|
||||
let error = adapter.execute(&target, &request).await.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RestAdapterError::UnexpectedStatus {
|
||||
status: 502,
|
||||
body: Value::Object(_)
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_private_targets_by_default() {
|
||||
let policy = OutboundHttpPolicy::default();
|
||||
|
||||
let error = policy
|
||||
.validate_base_url("http://127.0.0.1:8080")
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, RestAdapterError::TargetNotAllowed { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_explicit_private_ipv4_and_ipv6_targets() {
|
||||
let policy = OutboundHttpPolicy::allowing_hosts(["192.168.1.10", "::1"]);
|
||||
|
||||
assert!(policy.validate_base_url("http://192.168.1.10:8080").is_ok());
|
||||
assert!(policy.validate_base_url("http://[::1]:8080").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn does_not_follow_redirects() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = test_adapter();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/redirect".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let error = adapter
|
||||
.execute(&target, &empty_request())
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RestAdapterError::UnexpectedStatus { status: 303, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_responses_over_the_configured_limit() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = RestAdapter::with_policy(
|
||||
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]).with_max_response_bytes(8),
|
||||
);
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/large".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let error = adapter
|
||||
.execute(&target, &empty_request())
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RestAdapterError::ResponseTooLarge { limit_bytes: 8 }
|
||||
));
|
||||
}
|
||||
|
||||
fn empty_request() -> RestRequest {
|
||||
RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
timeout_ms: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_adapter() -> RestAdapter {
|
||||
RestAdapter::with_policy(OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]))
|
||||
}
|
||||
|
||||
async fn spawn_test_server() -> String {
|
||||
let app = Router::new()
|
||||
.route("/users/{user_id}", post(create_user))
|
||||
.route("/fail", get(fail))
|
||||
.route("/redirect", get(|| async { Redirect::to("/large") }))
|
||||
.route(
|
||||
"/large",
|
||||
get(|| async { "response larger than eight bytes" }),
|
||||
);
|
||||
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)
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
Path(user_id): Path<String>,
|
||||
Query(query): Query<BTreeMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Json<Value> {
|
||||
let trace = headers
|
||||
.get("x-trace-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
let static_header = headers
|
||||
.get("x-static")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(json!({
|
||||
"id": user_id,
|
||||
"query": query.get("expand").cloned().unwrap_or_default(),
|
||||
"trace": trace,
|
||||
"static": static_header,
|
||||
"payload": payload
|
||||
}))
|
||||
}
|
||||
|
||||
async fn fail() -> (axum::http::StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": "upstream failed" })),
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user