commit 1cf1efadfeacd3095169d042667f1cd5e913a4fa Author: github-ops Date: Tue Jun 16 23:26:28 2026 +0000 chore: publish clean community baseline diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f3919cc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +target +notes +node_modules +dist +coverage +.idea +.vscode +.claude +.codex +.tmp +.DS_Store +*.log +var +apps/ui/node_modules +apps/ui/playwright-report +apps/ui/test-results +apps/ui/.playwright diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..afe4348 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +POSTGRES_DB=crank +POSTGRES_USER=crank +POSTGRES_PASSWORD=change-me +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_MAX_CONNECTIONS=20 +POSTGRES_MIN_CONNECTIONS=2 +POSTGRES_ACQUIRE_TIMEOUT_MS=5000 +POSTGRES_IDLE_TIMEOUT_MS=600000 +POSTGRES_MAX_LIFETIME_MS=1800000 +CRANK_ADMIN_API_IMAGE=crank/admin-api:dev +CRANK_MCP_SERVER_IMAGE=crank/mcp-server:dev +CRANK_UI_IMAGE=crank/ui:dev +CRANK_STORAGE_ROOT=/var/lib/crank/storage +CRANK_PUBLISH_BIND=127.0.0.1 +CRANK_ADMIN_BIND=0.0.0.0:3001 +CRANK_ADMIN_RATE_LIMIT_RPS=30 +CRANK_ADMIN_RATE_LIMIT_BURST=60 +CRANK_MCP_BIND=0.0.0.0:3002 +CRANK_MCP_REFRESH_MS=5000 +CRANK_MCP_RATE_LIMIT_RPS=60 +CRANK_MCP_RATE_LIMIT_BURST=120 +CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64 +CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16 +CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16 +CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16 +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 +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_BASE_URL=https://crank.example.com diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..827759e --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,144 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - "feat/**" + +env: + CARGO_BUILD_JOBS: "2" + CARGO_INCREMENTAL: "0" + RUST_TEST_THREADS: "2" + +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: Verify runner toolchain + run: | + rustc --version + cargo --version + rustfmt --version + cargo clippy --version + docker --version + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run clippy + run: cargo clippy --workspace --all-targets --all-features --jobs "$CARGO_BUILD_JOBS" -- -D warnings + + - name: Run tests + run: cargo test --workspace --all-targets --jobs "$CARGO_BUILD_JOBS" + + ui: + name: UI Checks + runs-on: ubuntu-latest + needs: rust + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Verify runner toolchain + run: | + node --version + npm --version + docker --version + + - name: Install UI dependencies + working-directory: apps/ui + run: npm ci + + - name: Build UI bundle + working-directory: apps/ui + run: npm run build + + frontend-e2e: + name: Frontend E2E + runs-on: ubuntu-latest + needs: ui + services: + crank-e2e-postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: crank + POSTGRES_USER: crank + POSTGRES_PASSWORD: crank + options: >- + --health-cmd "pg_isready -U crank -d crank" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + CRANK_E2E_USE_EXTERNAL_POSTGRES: "1" + CRANK_E2E_POSTGRES_HOST: crank-e2e-postgres + CRANK_E2E_POSTGRES_PORT: "5432" + CRANK_E2E_POSTGRES_DB: crank + CRANK_E2E_POSTGRES_USER: crank + CRANK_E2E_POSTGRES_PASSWORD: crank + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Verify runner toolchain + run: | + rustc --version + cargo --version + node --version + npm --version + docker --version + + - name: Install UI dependencies + working-directory: apps/ui + run: npm ci + + - name: Install Playwright browser + working-directory: apps/ui + run: npx playwright install --with-deps chromium + + - name: Prebuild e2e services + run: cargo build -p admin-api -p mcp-server --jobs "$CARGO_BUILD_JOBS" + + - name: Run Playwright e2e + working-directory: apps/ui + run: npx playwright test + + - name: Show Playwright stack logs + if: failure() + run: | + find .tmp/ui-e2e/logs -maxdepth 1 -type f -print -exec sed -n '1,220p' {} \; || true + + deployment: + name: Deployment Manifests + runs-on: ubuntu-latest + needs: ui + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Validate Community deployment manifest + run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..814f16b --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,230 @@ +name: Deploy + +on: + push: + branches: + - main + workflow_dispatch: + +env: + REGISTRY: git.itexp.me + IMAGE_TAG: ${{ gitea.sha }} + ADMIN_API_IMAGE: git.itexp.me/bsodfather/crank-community-admin-api + MCP_SERVER_IMAGE: git.itexp.me/bsodfather/crank-community-mcp-server + UI_IMAGE: git.itexp.me/bsodfather/crank-community-ui + OPENBAO_ENV_FILE: .openbao-env + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Verify runner toolchain + run: | + docker --version + command -v bao + bao version + + - name: Load deployment secrets from OpenBao + env: + BAO_ADDR: ${{ secrets.BAO_ADDR }} + BAO_ROLE_ID: ${{ secrets.BAO_ROLE_ID }} + BAO_SECRET_ID: ${{ secrets.BAO_SECRET_ID }} + OPENBAO_APP: crank + run: scripts/load-openbao-env.sh + + - name: Login to registry + run: | + . "$OPENBAO_ENV_FILE" + printf '%s' "$DEPLOY_REGISTRY_TOKEN" | \ + docker login '${{ env.REGISTRY }}' -u "$DEPLOY_REGISTRY_USER" --password-stdin + + - name: Build and push images + run: | + docker build -f apps/admin-api/Dockerfile \ + -t '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \ + -t '${{ env.ADMIN_API_IMAGE }}:main' \ + . + docker build -f apps/mcp-server/Dockerfile \ + -t '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \ + -t '${{ env.MCP_SERVER_IMAGE }}:main' \ + . + docker build -f apps/ui/Dockerfile \ + -t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \ + -t '${{ env.UI_IMAGE }}:main' \ + . + docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' + docker push '${{ env.ADMIN_API_IMAGE }}:main' + docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' + docker push '${{ env.MCP_SERVER_IMAGE }}:main' + docker push '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' + docker push '${{ env.UI_IMAGE }}:main' + + - name: Configure SSH key + run: | + . "$OPENBAO_ENV_FILE" + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + - name: Configure known hosts + run: | + . "$OPENBAO_ENV_FILE" + if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts + else + ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > ~/.ssh/known_hosts + fi + chmod 644 ~/.ssh/known_hosts + + - name: Sync deployment files to server + run: | + . "$OPENBAO_ENV_FILE" + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \ + "mkdir -p '$DEPLOY_PATH'" + rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \ + "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml" + + - name: Write environment file + run: | + . "$OPENBAO_ENV_FILE" + tmp_env="$(mktemp)" + append_if_set() { + if [ -n "$2" ]; then + printf '%s=%s\n' "$1" "$2" >> "$tmp_env" + fi + } + : > "$tmp_env" + append_if_set POSTGRES_DB "$POSTGRES_DB" + append_if_set POSTGRES_USER "$POSTGRES_USER" + append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD" + append_if_set POSTGRES_HOST "$POSTGRES_HOST" + if [ -n "${POSTGRES_PORT:-}" ]; then + append_if_set POSTGRES_PORT "$POSTGRES_PORT" + elif [ -n "${PGBOUNCER_PORT:-}" ]; then + append_if_set POSTGRES_PORT "$PGBOUNCER_PORT" + fi + append_if_set CRANK_STORAGE_ROOT "$CRANK_STORAGE_ROOT" + append_if_set CRANK_PUBLISH_BIND "$CRANK_PUBLISH_BIND" + append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND" + append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND" + append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS" + append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL" + append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY" + append_if_set CRANK_BASE_URL "$CRANK_BASE_URL" + append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND" + append_if_set CRANK_CACHE_URL "$CRANK_CACHE_URL" + append_if_set CRANK_CACHE_DEFAULT_TTL_MS "$CRANK_CACHE_DEFAULT_TTL_MS" + append_if_set CRANK_SESSION_SECRET "$CRANK_SESSION_SECRET" + append_if_set CRANK_PASSWORD_PEPPER "$CRANK_PASSWORD_PEPPER" + append_if_set CRANK_SESSION_TTL_HOURS "$CRANK_SESSION_TTL_HOURS" + append_if_set CRANK_BOOTSTRAP_ADMIN_EMAIL "$CRANK_BOOTSTRAP_ADMIN_EMAIL" + append_if_set CRANK_BOOTSTRAP_ADMIN_PASSWORD "$CRANK_BOOTSTRAP_ADMIN_PASSWORD" + append_if_set CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME "$CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME" + append_if_set CRANK_DEMO_SEED "$CRANK_DEMO_SEED" + { + printf 'COMPOSE_PROJECT_NAME=community\n' + printf 'CRANK_ADMIN_API_IMAGE=%s:%s\n' '${{ env.ADMIN_API_IMAGE }}' '${{ env.IMAGE_TAG }}' + printf 'CRANK_MCP_SERVER_IMAGE=%s:%s\n' '${{ env.MCP_SERVER_IMAGE }}' '${{ env.IMAGE_TAG }}' + printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}' + } >> "$tmp_env" + cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \ + "mkdir -p '$DEPLOY_PATH' && cat > '$DEPLOY_PATH/.env'" + rm -f "$tmp_env" + + - name: Validate required environment variables + run: | + . "$OPENBAO_ENV_FILE" + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" " + set -e + cd '$DEPLOY_PATH' + required_vars=' + POSTGRES_HOST + POSTGRES_PORT + POSTGRES_DB + POSTGRES_USER + POSTGRES_PASSWORD + CRANK_MASTER_KEY + CRANK_SESSION_SECRET + CRANK_PASSWORD_PEPPER + CRANK_BOOTSTRAP_ADMIN_EMAIL + CRANK_BOOTSTRAP_ADMIN_PASSWORD + CRANK_BASE_URL + ' + for var in \$required_vars; do + value=\$(grep -E \"^\${var}=\" .env | tail -n1 | cut -d= -f2- || true) + if [ -z \"\$value\" ]; then + echo \"missing required env: \$var\" >&2 + exit 1 + fi + done + " + + - name: Deploy with Docker Compose + run: | + . "$OPENBAO_ENV_FILE" + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" " + set -e + cd '$DEPLOY_PATH' + compose_profiles='' + cache_backend=\$(grep -E '^CRANK_CACHE_BACKEND=' .env | tail -n1 | cut -d= -f2- || true) + if [ \"\$cache_backend\" = 'valkey' ] || [ \"\$cache_backend\" = 'redis' ]; then + compose_profiles='--profile cache' + fi + echo '$DEPLOY_REGISTRY_TOKEN' | docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin + docker compose \$compose_profiles config -q + docker compose \$compose_profiles pull + docker compose \$compose_profiles down --remove-orphans + for container in \ + crank-ui-1 \ + crank-admin-api-1 \ + crank-mcp-server-1 \ + crank-postgres-1 \ + crank-valkey-1 \ + crank-community-ui-1 \ + crank-community-admin-api-1 \ + crank-community-mcp-server-1 \ + crank-community-postgres-1 \ + crank-community-valkey-1; do + if docker ps -a --format '{{.Names}}' | grep -Fx \"\$container\" >/dev/null; then + docker rm -f \"\$container\" + fi + done + echo 'Docker containers before freeing required ports:' + docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Ports}}' + for port in 3000 3001 3002; do + container_ids=\$(docker ps -aq --filter \"publish=\$port\") + if [ -n \"\$container_ids\" ]; then + echo \"Removing containers publishing port \$port\" + docker inspect --format '{{.Name}} {{json .NetworkSettings.Ports}}' \$container_ids || true + docker rm -f \$container_ids + fi + done + if command -v ss >/dev/null 2>&1; then + ss -ltnp '( sport = :3000 or sport = :3001 or sport = :3002 )' || true + fi + docker compose \$compose_profiles up -d --remove-orphans + " + + - name: Verify health endpoints + run: | + . "$OPENBAO_ENV_FILE" + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" " + set -e + cd '$DEPLOY_PATH' + for attempt in \$(seq 1 30); do + if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \ + && curl --fail --silent http://127.0.0.1:3001/health >/dev/null \ + && curl --fail --silent http://127.0.0.1:3002/health >/dev/null; then + exit 0 + fi + sleep 2 + done + echo 'deployment health verification failed' >&2 + docker compose ps >&2 + exit 1 + " diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..e82c12d --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,100 @@ +name: Community Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +env: + REGISTRY: git.itexp.me + IMAGE_TAG: ${{ gitea.ref_name }} + 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: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Verify runner toolchain + run: | + rustc --version + cargo --version + node --version + npm --version + docker --version + command -v bao + bao version + + - name: Build release binaries + run: cargo build --release -p admin-api -p mcp-server + + - name: Install UI dependencies + working-directory: apps/ui + run: npm ci + + - name: Build UI dist + working-directory: apps/ui + run: npm run build + + - name: Package release artifacts + run: | + mkdir -p dist/release + cp target/release/admin-api dist/release/admin-api + cp target/release/mcp-server dist/release/mcp-server + tar -C dist/release -czf dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz admin-api + tar -C dist/release -czf dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz mcp-server + tar -C apps/ui/dist -czf dist/crank-community-ui-${IMAGE_TAG}.tar.gz . + sha256sum \ + dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz \ + dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz \ + dist/crank-community-ui-${IMAGE_TAG}.tar.gz \ + > dist/crank-community-${IMAGE_TAG}-checksums.txt + + - name: Generate SBOM + run: | + docker run --rm -v "$PWD:/workspace" anchore/syft:latest \ + dir:/workspace -o spdx-json \ + > dist/crank-community-${IMAGE_TAG}-sbom.spdx.json + + - 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 release images + run: | + docker build -f apps/admin-api/Dockerfile \ + -t '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \ + -t '${{ env.ADMIN_API_IMAGE }}:latest' . + docker build -f apps/mcp-server/Dockerfile \ + -t '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \ + -t '${{ env.MCP_SERVER_IMAGE }}:latest' . + docker build -f apps/ui/Dockerfile \ + -t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \ + -t '${{ env.UI_IMAGE }}:latest' . + docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' + docker push '${{ env.ADMIN_API_IMAGE }}:latest' + docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' + docker push '${{ env.MCP_SERVER_IMAGE }}:latest' + docker push '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' + docker push '${{ env.UI_IMAGE }}:latest' + + - name: Show release bundle summary + run: | + ls -lh dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..159d57a --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +/target +/.idea +/.vscode +/.DS_Store +/node_modules +/dist +/coverage +/.env +/.openbao-env +/var +/notes +apps/ui/node_modules +apps/ui/dist +apps/ui/playwright-report +apps/ui/test-results +.tmp +*.tsbuildinfo +apps/ui/vite.config.js +apps/ui/vite.config.d.ts +*.log +__*.md +diploma/ +AGENTS.md +TASKS.md diff --git a/.sqlx/query-0b8e9bed137684683d8b6a9ed52c94b6fb3bbcb19c7a621c78c5a6d3698bdf4d.json b/.sqlx/query-0b8e9bed137684683d8b6a9ed52c94b6fb3bbcb19c7a621c78c5a6d3698bdf4d.json new file mode 100644 index 0000000..2199915 --- /dev/null +++ b/.sqlx/query-0b8e9bed137684683d8b6a9ed52c94b6fb3bbcb19c7a621c78c5a6d3698bdf4d.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n operations.id,\n operations.workspace_id,\n operations.name,\n operations.display_name,\n operations.category,\n operations.protocol,\n operations.security_level,\n ov.target_json,\n operations.status,\n operations.current_draft_version,\n operations.latest_published_version,\n operations.created_at as \"created_at!: time::OffsetDateTime\",\n operations.updated_at as \"updated_at!: time::OffsetDateTime\",\n operations.published_at as \"published_at: time::OffsetDateTime\"\n from operations\n join operation_versions ov\n on ov.operation_id = operations.id\n and ov.version = operations.current_draft_version\n where operations.workspace_id = $1\n order by operations.name asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "category", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "security_level", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "target_json", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "current_draft_version", + "type_info": "Int4" + }, + { + "ordinal": 10, + "name": "latest_published_version", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "published_at: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "0b8e9bed137684683d8b6a9ed52c94b6fb3bbcb19c7a621c78c5a6d3698bdf4d" +} diff --git a/.sqlx/query-10ac4260e678ac458750988f95e54d3a84d9870e10e86bbe0d9c9fd99bf8a1f5.json b/.sqlx/query-10ac4260e678ac458750988f95e54d3a84d9870e10e86bbe0d9c9fd99bf8a1f5.json new file mode 100644 index 0000000..fe99c21 --- /dev/null +++ b/.sqlx/query-10ac4260e678ac458750988f95e54d3a84d9870e10e86bbe0d9c9fd99bf8a1f5.json @@ -0,0 +1,167 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2\n order by ov.version asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "category", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "security_level", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "operation_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "operation_updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "operation_published_at: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "target_json", + "type_info": "Jsonb" + }, + { + "ordinal": 13, + "name": "input_schema_json", + "type_info": "Jsonb" + }, + { + "ordinal": 14, + "name": "output_schema_json", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "input_mapping_json", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "output_mapping_json", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "execution_config_json", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "tool_description_json", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "samples_json", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "generated_draft_json", + "type_info": "Jsonb" + }, + { + "ordinal": 21, + "name": "config_export_json", + "type_info": "Jsonb" + }, + { + "ordinal": 22, + "name": "change_note", + "type_info": "Text" + }, + { + "ordinal": 23, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "created_by", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "10ac4260e678ac458750988f95e54d3a84d9870e10e86bbe0d9c9fd99bf8a1f5" +} diff --git a/.sqlx/query-1912ca7a4b00fcc5c1a7fe5434b811977fbdf181436573fa96415ccfcb5fecb2.json b/.sqlx/query-1912ca7a4b00fcc5c1a7fe5434b811977fbdf181436573fa96415ccfcb5fecb2.json new file mode 100644 index 0000000..83da6db --- /dev/null +++ b/.sqlx/query-1912ca7a4b00fcc5c1a7fe5434b811977fbdf181436573fa96415ccfcb5fecb2.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n agent_id,\n version,\n status,\n instructions_json,\n tool_selection_policy_json,\n created_at as \"created_at!: time::OffsetDateTime\"\n from agent_versions\n where agent_id = $1 and version = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "agent_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "instructions_json", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "tool_selection_policy_json", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "1912ca7a4b00fcc5c1a7fe5434b811977fbdf181436573fa96415ccfcb5fecb2" +} diff --git a/.sqlx/query-1b8c77657275ab0ea28e68ae5147dbdad5d055887ef5fabd82552d4fe704f891.json b/.sqlx/query-1b8c77657275ab0ea28e68ae5147dbdad5d055887ef5fabd82552d4fe704f891.json new file mode 100644 index 0000000..06b97ab --- /dev/null +++ b/.sqlx/query-1b8c77657275ab0ea28e68ae5147dbdad5d055887ef5fabd82552d4fe704f891.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n operation_id,\n version,\n sample_kind,\n storage_ref,\n content_type,\n file_name,\n created_at as \"created_at!: time::OffsetDateTime\"\n from operation_samples\n where operation_id = $1 and version = $2\n order by created_at asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "operation_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "sample_kind", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "storage_ref", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "content_type", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "file_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "1b8c77657275ab0ea28e68ae5147dbdad5d055887ef5fabd82552d4fe704f891" +} diff --git a/.sqlx/query-20a3404581cb52fa7087ba4649fde4910e8d7c2b1b17055fdd1c54206e55a874.json b/.sqlx/query-20a3404581cb52fa7087ba4649fde4910e8d7c2b1b17055fdd1c54206e55a874.json new file mode 100644 index 0000000..6c35de9 --- /dev/null +++ b/.sqlx/query-20a3404581cb52fa7087ba4649fde4910e8d7c2b1b17055fdd1c54206e55a874.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n slug,\n display_name,\n description,\n status,\n current_draft_version,\n latest_published_version,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\",\n published_at as \"published_at: time::OffsetDateTime\"\n from agents\n where workspace_id = $1 and id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "current_draft_version", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "latest_published_version", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "published_at: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "20a3404581cb52fa7087ba4649fde4910e8d7c2b1b17055fdd1c54206e55a874" +} diff --git a/.sqlx/query-249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c.json b/.sqlx/query-249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c.json new file mode 100644 index 0000000..e929b28 --- /dev/null +++ b/.sqlx/query-249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "update users\n set email = $2,\n display_name = $3\n where id = $1\n returning\n id,\n email,\n display_name,\n status,\n created_at as \"created_at!: OffsetDateTime\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c" +} diff --git a/.sqlx/query-2b819bad2a2712dab1a182b89ec52d762d66cffdc449ee6f73c74c1d23a17314.json b/.sqlx/query-2b819bad2a2712dab1a182b89ec52d762d66cffdc449ee6f73c74c1d23a17314.json new file mode 100644 index 0000000..11227cc --- /dev/null +++ b/.sqlx/query-2b819bad2a2712dab1a182b89ec52d762d66cffdc449ee6f73c74c1d23a17314.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "select exists(\n select 1\n from memberships\n where user_id = $1\n and workspace_id = $2\n ) as \"allowed!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "allowed!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2b819bad2a2712dab1a182b89ec52d762d66cffdc449ee6f73c74c1d23a17314" +} diff --git a/.sqlx/query-3422060fba1de6d85f89c46b41cc1cf462f49f0e2fe93e9230d15f97710b74ee.json b/.sqlx/query-3422060fba1de6d85f89c46b41cc1cf462f49f0e2fe93e9230d15f97710b74ee.json new file mode 100644 index 0000000..e9828b6 --- /dev/null +++ b/.sqlx/query-3422060fba1de6d85f89c46b41cc1cf462f49f0e2fe93e9230d15f97710b74ee.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "select 1 as \"present!\"\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n where w.slug = $1 and a.slug = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "present!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3422060fba1de6d85f89c46b41cc1cf462f49f0e2fe93e9230d15f97710b74ee" +} diff --git a/.sqlx/query-38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25.json b/.sqlx/query-38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25.json new file mode 100644 index 0000000..f459be6 --- /dev/null +++ b/.sqlx/query-38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n created_at as \"created_at!: OffsetDateTime\"\n from users\n where email = $1\n limit 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false + ] + }, + "hash": "38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25" +} diff --git a/.sqlx/query-3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06.json b/.sqlx/query-3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06.json new file mode 100644 index 0000000..f5faed0 --- /dev/null +++ b/.sqlx/query-3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n created_at as \"created_at!: OffsetDateTime\"\n from users\n where id = $1\n limit 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false + ] + }, + "hash": "3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06" +} diff --git a/.sqlx/query-3df72b8a0ac4a76b15909fc78268a1c87d1cd184973bf1cecc0c725691432c08.json b/.sqlx/query-3df72b8a0ac4a76b15909fc78268a1c87d1cd184973bf1cecc0c725691432c08.json new file mode 100644 index 0000000..83e24cc --- /dev/null +++ b/.sqlx/query-3df72b8a0ac4a76b15909fc78268a1c87d1cd184973bf1cecc0c725691432c08.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into users (\n id,\n email,\n display_name,\n password_hash,\n status,\n created_at\n ) values (\n $1, $2, $3, $4, 'active', now()\n )\n on conflict (email) do update\n set display_name = excluded.display_name,\n password_hash = excluded.password_hash,\n status = 'active'\n returning id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3df72b8a0ac4a76b15909fc78268a1c87d1cd184973bf1cecc0c725691432c08" +} diff --git a/.sqlx/query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json b/.sqlx/query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json new file mode 100644 index 0000000..7317c3c --- /dev/null +++ b/.sqlx/query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n slug,\n display_name,\n status,\n settings_json,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\"\n from workspaces\n where id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "settings_json", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7" +} diff --git a/.sqlx/query-67a73cb5211a0c23d2e8d8361a8e37c488eb2420207e9c4fa682993eda20cd62.json b/.sqlx/query-67a73cb5211a0c23d2e8d8361a8e37c488eb2420207e9c4fa682993eda20cd62.json new file mode 100644 index 0000000..eb676d4 --- /dev/null +++ b/.sqlx/query-67a73cb5211a0c23d2e8d8361a8e37c488eb2420207e9c4fa682993eda20cd62.json @@ -0,0 +1,164 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n order by o.name asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "category", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "security_level", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "operation_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "operation_updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "operation_published_at: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "target_json", + "type_info": "Jsonb" + }, + { + "ordinal": 13, + "name": "input_schema_json", + "type_info": "Jsonb" + }, + { + "ordinal": 14, + "name": "output_schema_json", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "input_mapping_json", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "output_mapping_json", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "execution_config_json", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "tool_description_json", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "samples_json", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "generated_draft_json", + "type_info": "Jsonb" + }, + { + "ordinal": 21, + "name": "config_export_json", + "type_info": "Jsonb" + }, + { + "ordinal": 22, + "name": "change_note", + "type_info": "Text" + }, + { + "ordinal": 23, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "created_by", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "67a73cb5211a0c23d2e8d8361a8e37c488eb2420207e9c4fa682993eda20cd62" +} diff --git a/.sqlx/query-6ae136d8196f7b259b7e5d054ecbd063dc5746ef669f21b0db1b5843e9db22d0.json b/.sqlx/query-6ae136d8196f7b259b7e5d054ecbd063dc5746ef669f21b0db1b5843e9db22d0.json new file mode 100644 index 0000000..ff6c9a4 --- /dev/null +++ b/.sqlx/query-6ae136d8196f7b259b7e5d054ecbd063dc5746ef669f21b0db1b5843e9db22d0.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n operation_id,\n version,\n descriptor_kind,\n storage_ref,\n source_name,\n package_index_json,\n created_at as \"created_at!: time::OffsetDateTime\"\n from descriptors\n where operation_id = $1 and version = $2\n order by created_at asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "operation_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "descriptor_kind", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "storage_ref", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "source_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "package_index_json", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + true, + true, + false + ] + }, + "hash": "6ae136d8196f7b259b7e5d054ecbd063dc5746ef669f21b0db1b5843e9db22d0" +} diff --git a/.sqlx/query-6eed48468f97843bbcd6a2f90448b68c15efa72029740e28bb881f54f73a6b9b.json b/.sqlx/query-6eed48468f97843bbcd6a2f90448b68c15efa72029740e28bb881f54f73a6b9b.json new file mode 100644 index 0000000..c66b1b0 --- /dev/null +++ b/.sqlx/query-6eed48468f97843bbcd6a2f90448b68c15efa72029740e28bb881f54f73a6b9b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "select 1 as \"present!\"\n from agents a\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b\n on b.agent_id = a.id and b.agent_version = pa.version\n where a.workspace_id = $1\n and b.operation_id = $2\n limit 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "present!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6eed48468f97843bbcd6a2f90448b68c15efa72029740e28bb881f54f73a6b9b" +} diff --git a/.sqlx/query-7ad59ce20834c3de2b0345c1be28d84377b417efb30c7bdc38d4e3746a0c1a7a.json b/.sqlx/query-7ad59ce20834c3de2b0345c1be28d84377b417efb30c7bdc38d4e3746a0c1a7a.json new file mode 100644 index 0000000..fc187bd --- /dev/null +++ b/.sqlx/query-7ad59ce20834c3de2b0345c1be28d84377b417efb30c7bdc38d4e3746a0c1a7a.json @@ -0,0 +1,166 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n where po.operation_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "category", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "security_level", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "operation_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "operation_updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "operation_published_at: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "target_json", + "type_info": "Jsonb" + }, + { + "ordinal": 13, + "name": "input_schema_json", + "type_info": "Jsonb" + }, + { + "ordinal": 14, + "name": "output_schema_json", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "input_mapping_json", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "output_mapping_json", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "execution_config_json", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "tool_description_json", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "samples_json", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "generated_draft_json", + "type_info": "Jsonb" + }, + { + "ordinal": 21, + "name": "config_export_json", + "type_info": "Jsonb" + }, + { + "ordinal": 22, + "name": "change_note", + "type_info": "Text" + }, + { + "ordinal": 23, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "created_by", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "7ad59ce20834c3de2b0345c1be28d84377b417efb30c7bdc38d4e3746a0c1a7a" +} diff --git a/.sqlx/query-92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb.json b/.sqlx/query-92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb.json new file mode 100644 index 0000000..415db6d --- /dev/null +++ b/.sqlx/query-92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n w.id,\n w.slug,\n w.display_name,\n w.status,\n w.settings_json,\n w.created_at as \"created_at!: time::OffsetDateTime\",\n w.updated_at as \"updated_at!: time::OffsetDateTime\",\n m.role\n from memberships m\n join workspaces w on w.id = m.workspace_id\n where m.user_id = $1\n order by w.slug asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "settings_json", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "role", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb" +} diff --git a/.sqlx/query-9625d5160a075b55b5bb980ea8e82e9da706a34d17adf0bc94ddb4e7a3818c18.json b/.sqlx/query-9625d5160a075b55b5bb980ea8e82e9da706a34d17adf0bc94ddb4e7a3818c18.json new file mode 100644 index 0000000..f09acbf --- /dev/null +++ b/.sqlx/query-9625d5160a075b55b5bb980ea8e82e9da706a34d17adf0bc94ddb4e7a3818c18.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n slug,\n display_name,\n description,\n status,\n current_draft_version,\n latest_published_version,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\",\n published_at as \"published_at: time::OffsetDateTime\"\n from agents\n where workspace_id = $1\n order by slug asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "current_draft_version", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "latest_published_version", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "published_at: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "9625d5160a075b55b5bb980ea8e82e9da706a34d17adf0bc94ddb4e7a3818c18" +} diff --git a/.sqlx/query-a1d2c9b16b61d226701449fc22146db6f0f7d17e6867f929c44027990fb94b57.json b/.sqlx/query-a1d2c9b16b61d226701449fc22146db6f0f7d17e6867f929c44027990fb94b57.json new file mode 100644 index 0000000..8900459 --- /dev/null +++ b/.sqlx/query-a1d2c9b16b61d226701449fc22146db6f0f7d17e6867f929c44027990fb94b57.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n protocol,\n mode,\n status,\n cursor_json,\n state_json,\n expires_at as \"expires_at!: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n closed_at as \"closed_at: OffsetDateTime\"\n from stream_sessions\n where id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "agent_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "operation_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "mode", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "cursor_json", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "state_json", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "expires_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "last_poll_at: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "closed_at: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "a1d2c9b16b61d226701449fc22146db6f0f7d17e6867f929c44027990fb94b57" +} diff --git a/.sqlx/query-a24c36f988151016f9ffa834a6a5658f7f094844b892c999e6f58d6f92fbe0c2.json b/.sqlx/query-a24c36f988151016f9ffa834a6a5658f7f094844b892c999e6f58d6f92fbe0c2.json new file mode 100644 index 0000000..638d532 --- /dev/null +++ b/.sqlx/query-a24c36f988151016f9ffa834a6a5658f7f094844b892c999e6f58d6f92fbe0c2.json @@ -0,0 +1,168 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2 and ov.version = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "category", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "security_level", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "operation_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "operation_updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "operation_published_at: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "target_json", + "type_info": "Jsonb" + }, + { + "ordinal": 13, + "name": "input_schema_json", + "type_info": "Jsonb" + }, + { + "ordinal": 14, + "name": "output_schema_json", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "input_mapping_json", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "output_mapping_json", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "execution_config_json", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "tool_description_json", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "samples_json", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "generated_draft_json", + "type_info": "Jsonb" + }, + { + "ordinal": 21, + "name": "config_export_json", + "type_info": "Jsonb" + }, + { + "ordinal": 22, + "name": "change_note", + "type_info": "Text" + }, + { + "ordinal": 23, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "created_by", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "a24c36f988151016f9ffa834a6a5658f7f094844b892c999e6f58d6f92fbe0c2" +} diff --git a/.sqlx/query-b68377d8ffd5bdc9b8e417170f4940b65d2167dada6b6110985ce0bd95480b93.json b/.sqlx/query-b68377d8ffd5bdc9b8e417170f4940b65d2167dada6b6110985ce0bd95480b93.json new file mode 100644 index 0000000..21fc532 --- /dev/null +++ b/.sqlx/query-b68377d8ffd5bdc9b8e417170f4940b65d2167dada6b6110985ce0bd95480b93.json @@ -0,0 +1,99 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n protocol,\n mode,\n status,\n cursor_json,\n state_json,\n expires_at as \"expires_at!: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n closed_at as \"closed_at: OffsetDateTime\"\n from stream_sessions\n where workspace_id = $1\n and ($2::text is null or agent_id = $2)\n and ($3::text is null or operation_id = $3)\n and ($4::text is null or status = $4)\n and ($5::text is null or mode = $5)\n order by created_at desc\n limit $6", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "agent_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "operation_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "mode", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "cursor_json", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "state_json", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "expires_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "last_poll_at: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "closed_at: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "b68377d8ffd5bdc9b8e417170f4940b65d2167dada6b6110985ce0bd95480b93" +} diff --git a/.sqlx/query-b86a7406eeec83390458e35fb8611e19377582ec0ca245a162c2d2236077bb9b.json b/.sqlx/query-b86a7406eeec83390458e35fb8611e19377582ec0ca245a162c2d2236077bb9b.json new file mode 100644 index 0000000..e74aa00 --- /dev/null +++ b/.sqlx/query-b86a7406eeec83390458e35fb8611e19377582ec0ca245a162c2d2236077bb9b.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n name,\n kind,\n config_json,\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\"\n from auth_profiles\n where workspace_id = $1\n order by name asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "kind", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "config_json", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at!: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b86a7406eeec83390458e35fb8611e19377582ec0ca245a162c2d2236077bb9b" +} diff --git a/.sqlx/query-bdae00d03a55ba3b6d121578b081e970ff08427f9a3dd396b649996a9131260c.json b/.sqlx/query-bdae00d03a55ba3b6d121578b081e970ff08427f9a3dd396b649996a9131260c.json new file mode 100644 index 0000000..8762d72 --- /dev/null +++ b/.sqlx/query-bdae00d03a55ba3b6d121578b081e970ff08427f9a3dd396b649996a9131260c.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n protocol_version,\n initialized,\n workspace_slug,\n agent_slug,\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n expires_at as \"expires_at: OffsetDateTime\"\n from mcp_transport_sessions\n where id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "protocol_version", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "initialized", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "workspace_slug", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "agent_slug", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "expires_at: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "bdae00d03a55ba3b6d121578b081e970ff08427f9a3dd396b649996a9131260c" +} diff --git a/.sqlx/query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.json b/.sqlx/query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.json new file mode 100644 index 0000000..f88bd2c --- /dev/null +++ b/.sqlx/query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n m.workspace_id,\n m.user_id,\n m.role,\n m.created_at as \"created_at!: time::OffsetDateTime\",\n u.email,\n u.display_name,\n u.status,\n u.created_at as \"user_created_at!: time::OffsetDateTime\"\n from memberships m\n join users u on u.id = m.user_id\n where m.workspace_id = $1\n order by u.email asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "role", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "user_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc" +} diff --git a/.sqlx/query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json b/.sqlx/query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json new file mode 100644 index 0000000..65e4484 --- /dev/null +++ b/.sqlx/query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n name,\n kind,\n status,\n current_version,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\",\n last_used_at as \"last_used_at: time::OffsetDateTime\"\n from secrets\n where workspace_id = $1 and id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "kind", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "current_version", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "last_used_at: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89" +} diff --git a/.sqlx/query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json b/.sqlx/query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json new file mode 100644 index 0000000..3893be5 --- /dev/null +++ b/.sqlx/query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n slug,\n display_name,\n status,\n settings_json,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\"\n from workspaces\n order by slug asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "settings_json", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f" +} diff --git a/.sqlx/query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json b/.sqlx/query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json new file mode 100644 index 0000000..e27ad9f --- /dev/null +++ b/.sqlx/query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n name,\n kind,\n status,\n current_version,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\",\n last_used_at as \"last_used_at: time::OffsetDateTime\"\n from secrets\n where workspace_id = $1\n order by name asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "kind", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "current_version", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "last_used_at: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b" +} diff --git a/.sqlx/query-dd4a415a042b863b1034727737f3d14019ba405a07a8360b68e8c9c5597e17ce.json b/.sqlx/query-dd4a415a042b863b1034727737f3d14019ba405a07a8360b68e8c9c5597e17ce.json new file mode 100644 index 0000000..0a463cd --- /dev/null +++ b/.sqlx/query-dd4a415a042b863b1034727737f3d14019ba405a07a8360b68e8c9c5597e17ce.json @@ -0,0 +1,203 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n w.id as workspace_id,\n w.slug as workspace_slug,\n a.id as agent_id,\n a.slug as agent_slug,\n b.tool_name,\n b.tool_title,\n coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",\n o.id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version\n join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version\n join operations o on o.id = ov.operation_id and o.workspace_id = w.id\n where w.slug = $1 and a.slug = $2 and b.enabled = true\n order by b.tool_name asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "agent_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "agent_slug", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "tool_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "tool_title", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "tool_description!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "category", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "security_level", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "operation_created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "operation_updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 15, + "name": "operation_published_at: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 16, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 17, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 18, + "name": "target_json", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "input_schema_json", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "output_schema_json", + "type_info": "Jsonb" + }, + { + "ordinal": 21, + "name": "input_mapping_json", + "type_info": "Jsonb" + }, + { + "ordinal": 22, + "name": "output_mapping_json", + "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "execution_config_json", + "type_info": "Jsonb" + }, + { + "ordinal": 24, + "name": "tool_description_json", + "type_info": "Jsonb" + }, + { + "ordinal": 25, + "name": "samples_json", + "type_info": "Jsonb" + }, + { + "ordinal": 26, + "name": "generated_draft_json", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "config_export_json", + "type_info": "Jsonb" + }, + { + "ordinal": 28, + "name": "change_note", + "type_info": "Text" + }, + { + "ordinal": 29, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 30, + "name": "created_by", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + null, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "dd4a415a042b863b1034727737f3d14019ba405a07a8360b68e8c9c5597e17ce" +} diff --git a/.sqlx/query-e86682005480df007b488b8543adc8a6d4068e33a5509f9e314dd0d97eac178b.json b/.sqlx/query-e86682005480df007b488b8543adc8a6d4068e33a5509f9e314dd0d97eac178b.json new file mode 100644 index 0000000..19f81b5 --- /dev/null +++ b/.sqlx/query-e86682005480df007b488b8543adc8a6d4068e33a5509f9e314dd0d97eac178b.json @@ -0,0 +1,98 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n status,\n progress_json,\n result_json,\n error_json,\n expires_at as \"expires_at: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n finished_at as \"finished_at: OffsetDateTime\"\n from async_jobs\n where workspace_id = $1\n and ($2::text is null or agent_id = $2)\n and ($3::text is null or operation_id = $3)\n and ($4::text is null or status = $4)\n order by updated_at desc\n limit $5", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "agent_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "operation_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "progress_json", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "result_json", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "error_json", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "expires_at: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "last_poll_at: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "finished_at: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true + ] + }, + "hash": "e86682005480df007b488b8543adc8a6d4068e33a5509f9e314dd0d97eac178b" +} diff --git a/.sqlx/query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json b/.sqlx/query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json new file mode 100644 index 0000000..03e3aa1 --- /dev/null +++ b/.sqlx/query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n email,\n role,\n status,\n token_hash,\n expires_at as \"expires_at!: time::OffsetDateTime\",\n created_at as \"created_at!: time::OffsetDateTime\"\n from invitation_tokens\n where workspace_id = $1\n order by created_at desc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "token_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "expires_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41" +} diff --git a/.sqlx/query-f195cc4951ef129f4a1c95d6d9e8f932919d2882f3dcb5a7f865fb4448506d05.json b/.sqlx/query-f195cc4951ef129f4a1c95d6d9e8f932919d2882f3dcb5a7f865fb4448506d05.json new file mode 100644 index 0000000..3be6a67 --- /dev/null +++ b/.sqlx/query-f195cc4951ef129f4a1c95d6d9e8f932919d2882f3dcb5a7f865fb4448506d05.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n operations.id,\n operations.workspace_id,\n operations.name,\n operations.display_name,\n operations.category,\n operations.protocol,\n operations.security_level,\n ov.target_json,\n operations.status,\n operations.current_draft_version,\n operations.latest_published_version,\n operations.created_at as \"created_at!: time::OffsetDateTime\",\n operations.updated_at as \"updated_at!: time::OffsetDateTime\",\n operations.published_at as \"published_at: time::OffsetDateTime\"\n from operations\n join operation_versions ov\n on ov.operation_id = operations.id\n and ov.version = operations.current_draft_version\n where operations.workspace_id = $1 and operations.id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "category", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "security_level", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "target_json", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "current_draft_version", + "type_info": "Int4" + }, + { + "ordinal": 10, + "name": "latest_published_version", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "published_at: time::OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "f195cc4951ef129f4a1c95d6d9e8f932919d2882f3dcb5a7f865fb4448506d05" +} diff --git a/.sqlx/query-f551a9cd253b7fd0dc9a387a141a53d8a109b8aa766ab1c2b11f9b52e250adc8.json b/.sqlx/query-f551a9cd253b7fd0dc9a387a141a53d8a109b8aa766ab1c2b11f9b52e250adc8.json new file mode 100644 index 0000000..3ff8cb4 --- /dev/null +++ b/.sqlx/query-f551a9cd253b7fd0dc9a387a141a53d8a109b8aa766ab1c2b11f9b52e250adc8.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n status,\n progress_json,\n result_json,\n error_json,\n expires_at as \"expires_at: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n finished_at as \"finished_at: OffsetDateTime\"\n from async_jobs\n where id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "agent_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "operation_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "progress_json", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "result_json", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "error_json", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "expires_at: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "last_poll_at: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "finished_at: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true + ] + }, + "hash": "f551a9cd253b7fd0dc9a387a141a53d8a109b8aa766ab1c2b11f9b52e250adc8" +} diff --git a/.sqlx/query-f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d.json b/.sqlx/query-f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d.json new file mode 100644 index 0000000..2ea224a --- /dev/null +++ b/.sqlx/query-f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n s.id,\n s.user_id,\n s.current_workspace_id,\n u.email,\n u.display_name,\n u.status,\n u.created_at as \"created_at!: OffsetDateTime\"\n from user_sessions s\n join users u on u.id = s.user_id\n where s.id = $1\n and s.secret_hash = $2\n and s.status = 'active'\n and s.expires_at > now()\n limit 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "current_workspace_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d" +} diff --git a/.sqlx/query-fadf243775a355c46ae97d26049793eb1d1ad17505b1e1609a842750e3233ec2.json b/.sqlx/query-fadf243775a355c46ae97d26049793eb1d1ad17505b1e1609a842750e3233ec2.json new file mode 100644 index 0000000..33bcc6e --- /dev/null +++ b/.sqlx/query-fadf243775a355c46ae97d26049793eb1d1ad17505b1e1609a842750e3233ec2.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id,\n workspace_id,\n name,\n kind,\n config_json,\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\"\n from auth_profiles\n where workspace_id = $1 and id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "kind", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "config_json", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at!: OffsetDateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "fadf243775a355c46ae97d26049793eb1d1ad17505b1e1609a842750e3233ec2" +} diff --git a/.sqlx/query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json b/.sqlx/query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json new file mode 100644 index 0000000..ed3c067 --- /dev/null +++ b/.sqlx/query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n sv.secret_id,\n sv.version,\n sv.ciphertext,\n sv.key_version,\n sv.created_at as \"created_at!: time::OffsetDateTime\",\n sv.created_by\n from secrets s\n join secret_versions sv\n on sv.secret_id = s.id and sv.version = s.current_version\n where s.workspace_id = $1 and s.id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "secret_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "version", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "ciphertext", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "key_version", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "created_by", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a" +} diff --git a/.sqlx/query-fdd05b145021a19dda9a9f44c31ff21a2f73a4416d0cf20be418bba36def9eb4.json b/.sqlx/query-fdd05b145021a19dda9a9f44c31ff21a2f73a4416d0cf20be418bba36def9eb4.json new file mode 100644 index 0000000..8896b79 --- /dev/null +++ b/.sqlx/query-fdd05b145021a19dda9a9f44c31ff21a2f73a4416d0cf20be418bba36def9eb4.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n b.operation_id,\n a.id as agent_id,\n a.slug as agent_slug,\n a.display_name\n from agents a\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b\n on b.agent_id = a.id and b.agent_version = pa.version\n where a.workspace_id = $1\n order by a.display_name asc, b.tool_name asc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "operation_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "agent_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "agent_slug", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "fdd05b145021a19dda9a9f44c31ff21a2f73a4416d0cf20be418bba36def9eb4" +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4dc9fd3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3330 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "admin-api" +version = "0.3.1" +dependencies = [ + "argon2", + "async-trait", + "axum", + "axum-extra", + "base64", + "crank-community-auth", + "crank-core", + "crank-mapping", + "crank-registry", + "crank-runtime", + "crank-schema", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "serial_test", + "sha2", + "sqlx", + "thiserror", + "time", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crank-adapter-rest" +version = "0.3.1" +dependencies = [ + "async-trait", + "axum", + "crank-core", + "futures-util", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", +] + +[[package]] +name = "crank-community-auth" +version = "0.3.1" +dependencies = [ + "argon2", + "async-trait", + "axum-extra", + "base64", + "crank-core", + "crank-registry", + "rand 0.8.5", + "sha2", + "thiserror", + "time", + "tracing", + "uuid", +] + +[[package]] +name = "crank-community-mcp" +version = "0.3.1" +dependencies = [ + "async-trait", + "axum", + "base64", + "crank-core", + "crank-registry", + "crank-runtime", + "crank-schema", + "futures-util", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror", + "time", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "crank-core" +version = "0.3.1" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "time", + "tokio", +] + +[[package]] +name = "crank-mapping" +version = "0.3.1" +dependencies = [ + "crank-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror", +] + +[[package]] +name = "crank-registry" +version = "0.3.1" +dependencies = [ + "crank-core", + "crank-mapping", + "crank-schema", + "serde", + "serde_json", + "sqlx", + "thiserror", + "time", + "tokio", + "uuid", +] + +[[package]] +name = "crank-runtime" +version = "0.3.1" +dependencies = [ + "aes-gcm", + "async-trait", + "axum", + "base64", + "crank-adapter-rest", + "crank-core", + "crank-mapping", + "crank-schema", + "futures-util", + "hkdf", + "redis", + "serde", + "serde_json", + "sha2", + "thiserror", + "time", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "crank-schema" +version = "0.3.1" +dependencies = [ + "crank-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "mcp-server" +version = "0.3.1" +dependencies = [ + "async-trait", + "axum", + "base64", + "crank-community-mcp", + "crank-core", + "crank-mapping", + "crank-registry", + "crank-runtime", + "crank-schema", + "futures-util", + "reqwest", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror", + "time", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redis" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc42f3a12fd4408ce64d8efef67048a924e543bd35c6591c0447fda9054695f" +dependencies = [ + "arc-swap", + "backon", + "bytes", + "combine", + "futures-channel", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.6", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "time", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..396da51 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +[workspace] +members = [ + "apps/admin-api", + "apps/mcp-server", + "crates/crank-community-auth", + "crates/crank-community-mcp", + "crates/crank-core", + "crates/crank-schema", + "crates/crank-mapping", + "crates/crank-registry", + "crates/crank-runtime", + "crates/crank-adapter-rest", +] +resolver = "3" + +[workspace.package] +edition = "2024" +license = "MIT" +rust-version = "1.85" +version = "0.3.1" + +[workspace.dependencies] +aes-gcm = "0.10" +argon2 = "0.5" +axum = "0.8" +axum-extra = { version = "0.10", features = ["cookie"] } +base64 = "0.22" +hkdf = "0.12" +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "macros", "json", "time"] } +thiserror = "2" +time = { version = "0.3", 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"] } diff --git a/Crank.png b/Crank.png new file mode 100644 index 0000000..aa1c5d1 Binary files /dev/null and b/Crank.png differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5ee729c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 bsodfather + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..09ee6f7 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# Crank + +Crank Community — self-hosted платформа для публикации REST API как MCP tools. + +Проект решает практическую задачу: описать внешний REST endpoint, проверить его, +опубликовать и отдать LLM-клиентам через MCP без написания отдельного MCP server +для каждой интеграции. + +## Возможности + +- Создание REST operations через UI или YAML. +- Маппинг MCP input в query/body/header параметры REST-запроса. +- Маппинг REST response в структурированный output MCP tool. +- Публикация выбранных operations в agent-scoped MCP endpoints. +- Черновики, версии, samples, mappings, logs и usage в PostgreSQL. +- Простая авторизация через bootstrap admin user и browser session. +- PostgreSQL-backed secrets и auth profiles для upstream REST API. +- Docker Compose deployment. + +## Scope Community + +Этот репозиторий содержит только Community-возможности: + +- только REST protocol; +- один самостоятельный self-hosted deployment; +- простая admin-аутентификация; +- статические agent API keys для MCP-доступа; +- PostgreSQL как системная БД; +- optional Valkey/Redis для runtime coordination; +- Gitea Actions workflow для CI/CD. + +Репозиторий фокусируется только на перечисленном scope. + +## Архитектура + +Основные сущности: + +- `Workspace` — граница данных для operations, agents, secrets и logs. +- `Operation` — версионируемый REST integration contract. +- `Agent` — MCP surface, который публикует ограниченный набор operations. + +Рабочий flow: + +1. Оператор создает или импортирует REST operation. +2. Crank валидирует schema и mapping. +3. Оператор выполняет test call к upstream REST API. +4. Operation публикуется. +5. Agent открывает published operation как MCP tool. +6. MCP client вызывает tool через `mcp-server`. + +## Структура репозитория + +```text +apps/ + admin-api/ HTTP API для UI, auth, operations, agents, logs и settings + mcp-server/ Agent-scoped MCP Streamable HTTP server + ui/ Static web UI + +crates/ + crank-core/ Общая domain model + crank-registry/ PostgreSQL persistence + crank-runtime/ REST execution, mapping, cache, limits + crank-adapter-rest/ REST adapter + crank-community-auth/ Password hashing и sessions + crank-community-mcp/ MCP application layer + crank-mapping/ JSONPath mapping + crank-schema/ Schema normalization и validation + +deploy/community/ + docker-compose.yml Production-like Community deployment + .env.example Runtime env template +``` + +## Требования + +- Rust toolchain из `rust-toolchain.toml` +- Node.js и npm для UI +- PostgreSQL +- Docker и Docker Compose для deployment + +## Локальная разработка + +Запустите PostgreSQL и задайте переменные окружения: + +```bash +export POSTGRES_HOST=127.0.0.1 +export POSTGRES_PORT=5432 +export POSTGRES_DB=crank +export POSTGRES_USER=crank +export POSTGRES_PASSWORD=crank +export CRANK_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000 +export CRANK_SESSION_SECRET=dev-session-secret +export CRANK_PASSWORD_PEPPER=dev-password-pepper +export CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local +export CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password +export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank Owner" +export CRANK_STORAGE_ROOT=.tmp/storage +export CRANK_BASE_URL=http://127.0.0.1:3000 +``` + +Запустите backend: + +```bash +cargo run -p admin-api +cargo run -p mcp-server +``` + +Соберите UI: + +```bash +cd apps/ui +npm ci +npm run build +``` + +## Проверки + +Rust: + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +``` + +UI: + +```bash +cd apps/ui +npm ci +npm run build +npx playwright test +``` + +Playwright поднимает локальный test stack с PostgreSQL, `admin-api`, +`mcp-server` и UI test server. + +## Deployment + +Community manifest находится в `deploy/community/docker-compose.yml`. + +Production-like deployment ожидает: + +- внешний PostgreSQL; +- container images для `admin-api`, `mcp-server` и `ui`; +- runtime secrets через environment variables; +- reverse proxy перед портами `3000`, `3001`, `3002`. + +Gitea workflow `.gitea/workflows/deploy.yml` собирает images, читает runtime +configuration из OpenBao, пишет `.env` на deployment host и запускает Docker +Compose. + +Ключевые runtime variables: + +- `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` +- `CRANK_PUBLISH_BIND` + +Полная operational-документация: + +- [docs/runtime-config.md](docs/runtime-config.md) +- [docs/deployment.md](docs/deployment.md) +- [docs/mcp-interface.md](docs/mcp-interface.md) + +## MCP + +Crank публикует agent-scoped MCP endpoints через Streamable HTTP. Каждый agent +имеет собственный published tool catalog, поэтому MCP client видит только REST +tools, явно привязанные к этому agent. + +Типовой сценарий: + +1. Создать REST operation. +2. Протестировать operation. +3. Опубликовать operation. +4. Привязать operation к agent. +5. Создать agent API key. +6. Подключить MCP client к agent endpoint. + +## Английская документация + +English README: [docs/en/README.md](docs/en/README.md). + +## Лицензия + +MIT diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml new file mode 100644 index 0000000..4b745c6 --- /dev/null +++ b/apps/admin-api/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "admin-api" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[[bin]] +name = "admin-api" +path = "src/main.rs" + +[dependencies] +argon2.workspace = true +axum.workspace = true +axum-extra.workspace = true +base64.workspace = true +crank-community-auth = { path = "../../crates/crank-community-auth" } +crank-core = { path = "../../crates/crank-core" } +crank-mapping = { path = "../../crates/crank-mapping" } +crank-registry = { path = "../../crates/crank-registry" } +crank-runtime = { path = "../../crates/crank-runtime" } +crank-schema = { path = "../../crates/crank-schema" } +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +sha2.workspace = true +sqlx.workspace = true +thiserror.workspace = true +time.workspace = true +tokio = { workspace = true, features = ["fs"] } +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true + +[dev-dependencies] +async-trait = "0.1" +reqwest.workspace = true +serial_test = "3" diff --git a/apps/admin-api/Dockerfile b/apps/admin-api/Dockerfile new file mode 100644 index 0000000..a80dab9 --- /dev/null +++ b/apps/admin-api/Dockerfile @@ -0,0 +1,68 @@ +FROM rust:1.85-bookworm AS deps + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY .sqlx ./.sqlx +COPY apps/admin-api/Cargo.toml apps/admin-api/Cargo.toml +COPY apps/mcp-server/Cargo.toml apps/mcp-server/Cargo.toml +COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml +COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml +COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml +COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml +COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml +COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml + +RUN mkdir -p \ + apps/admin-api/src \ + apps/mcp-server/src \ + crates/crank-core/src \ + crates/crank-schema/src \ + crates/crank-mapping/src \ + crates/crank-registry/src \ + crates/crank-runtime/src \ + crates/crank-adapter-rest/src \ + && printf 'fn main() {}\n' > apps/admin-api/src/main.rs \ + && printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git/db \ + --mount=type=cache,target=/app/target \ + SQLX_OFFLINE=true cargo build --release -p admin-api + +FROM rust:1.85-bookworm AS builder + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY .sqlx ./.sqlx +COPY apps ./apps +COPY crates ./crates + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git/db \ + --mount=type=cache,target=/app/target \ + SQLX_OFFLINE=true cargo build --release -p admin-api \ + && cp /app/target/release/admin-api /tmp/admin-api + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api + +ENV CRANK_ADMIN_BIND=0.0.0.0:3001 + +EXPOSE 3001 + +CMD ["admin-api"] diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs new file mode 100644 index 0000000..0022148 --- /dev/null +++ b/apps/admin-api/src/app.rs @@ -0,0 +1,3957 @@ +use axum::{ + Router, middleware, + routing::{delete, get, post}, +}; + +use crate::{ + auth::{require_session, require_workspace_session}, + rate_limit::apply_api_rate_limit, + request_context::apply_request_context, + routes::{ + access::{ + create_invitation, delete_invitation, delete_membership, delete_workspace, + export_workspace, list_invitations, list_memberships, update_membership, + }, + agents::{ + archive_agent, create_agent, create_agent_platform_api_key, delete_agent, + delete_agent_platform_api_key, get_agent, get_agent_version, + list_agent_platform_api_keys, list_agents, publish_agent, + revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent, + }, + auth::{ + change_password, get_profile, get_session, login, logout, update_current_workspace, + update_profile, + }, + auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, + capabilities::get_capabilities, + machine_auth::{issue_agent_token, issue_one_time_agent_token}, + observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, + operations::{ + archive_operation, create_operation, create_version, delete_operation, + export_operation, generate_draft, get_operation, get_operation_version, + list_operations, publish_operation, run_test, update_operation, upload_input_json, + upload_output_json, + }, + secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret}, + streaming::list_protocol_capabilities, + workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace}, + }, + state::AppState, +}; + +pub fn build_app(state: AppState) -> Router { + let workspace_router = Router::new() + .route("/operations", get(list_operations).post(create_operation)) + .route("/operations/import", post(import_operation)) + .route( + "/operations/{operation_id}", + get(get_operation) + .patch(update_operation) + .delete(delete_operation), + ) + .route("/operations/{operation_id}/versions", post(create_version)) + .route( + "/operations/{operation_id}/versions/{version}", + get(get_operation_version), + ) + .route( + "/operations/{operation_id}/publish", + post(publish_operation), + ) + .route( + "/operations/{operation_id}/archive", + post(archive_operation), + ) + .route("/operations/{operation_id}/test-runs", post(run_test)) + .route( + "/operations/{operation_id}/samples/input-json", + post(upload_input_json), + ) + .route( + "/operations/{operation_id}/samples/output-json", + post(upload_output_json), + ) + .route( + "/operations/{operation_id}/drafts/generate", + post(generate_draft), + ) + .route("/operations/{operation_id}/export", get(export_operation)) + .route("/agents", get(list_agents).post(create_agent)) + .route( + "/agents/{agent_id}", + get(get_agent).patch(update_agent).delete(delete_agent), + ) + .route( + "/agents/{agent_id}/versions/{version}", + get(get_agent_version), + ) + .route("/agents/{agent_id}/bindings", post(save_agent_bindings)) + .route("/agents/{agent_id}/publish", post(publish_agent)) + .route("/agents/{agent_id}/unpublish", post(unpublish_agent)) + .route("/agents/{agent_id}/archive", post(archive_agent)) + .route( + "/agents/{agent_id}/platform-api-keys", + get(list_agent_platform_api_keys).post(create_agent_platform_api_key), + ) + .route( + "/agents/{agent_id}/platform-api-keys/{key_id}/revoke", + post(revoke_agent_platform_api_key), + ) + .route( + "/agents/{agent_id}/platform-api-keys/{key_id}", + delete(delete_agent_platform_api_key), + ) + .route( + "/auth-profiles", + get(list_auth_profiles).post(create_auth_profile), + ) + .route("/auth-profiles/{auth_profile_id}", get(get_auth_profile)) + .route("/secrets", get(list_secrets).post(create_secret)) + .route( + "/secrets/{secret_id}", + get(get_secret).delete(delete_secret), + ) + .route("/secrets/{secret_id}/rotate", post(rotate_secret)) + .route("/members", get(list_memberships)) + .route( + "/members/{user_id}", + axum::routing::patch(update_membership).delete(delete_membership), + ) + .route( + "/invitations", + get(list_invitations).post(create_invitation), + ) + .route("/invitations/{invitation_id}", delete(delete_invitation)) + .route("/export", get(export_workspace)) + .route("/logs", get(list_logs)) + .route("/logs/{log_id}", get(get_log)) + .route("/usage", get(get_usage)) + .route("/usage/operations/{operation_id}", get(get_operation_usage)) + .route("/usage/agents/{agent_id}", get(get_agent_usage)) + .route("/protocol-capabilities", get(list_protocol_capabilities)); + + let workspace_root_router = Router::new() + .route("/capabilities", get(get_capabilities)) + .route("/workspaces", get(list_workspaces).post(create_workspace)) + .layer(middleware::from_fn_with_state( + state.clone(), + require_session, + )); + + let workspace_scoped_router = Router::new() + .route( + "/workspaces/{workspace_id}", + get(get_workspace) + .patch(update_workspace) + .delete(delete_workspace), + ) + .nest("/workspaces/{workspace_id}", workspace_router) + .layer(middleware::from_fn_with_state( + state.clone(), + require_workspace_session, + )); + + let admin_router = workspace_root_router.merge(workspace_scoped_router); + + let protected_auth_router = Router::new() + .route("/logout", post(logout)) + .route("/session", get(get_session)) + .route("/profile", get(get_profile).patch(update_profile)) + .route("/current-workspace", post(update_current_workspace)) + .route("/password", post(change_password)) + .layer(middleware::from_fn_with_state( + state.clone(), + require_session, + )); + + Router::new() + .route("/health", get(crate::routes::health)) + .nest( + "/api/auth", + Router::new() + .route("/login", post(login)) + .merge(protected_auth_router), + ) + .nest( + "/mcp-auth/v1", + Router::new() + .route("/token", post(issue_agent_token)) + .route("/token/one-time", post(issue_one_time_agent_token)), + ) + .nest("/api/admin", admin_router) + .layer(middleware::from_fn_with_state( + state.clone(), + apply_api_rate_limit, + )) + .layer(middleware::from_fn(apply_request_context)) + .with_state(state) +} + +use crate::routes::operations::import_operation; + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + env, fmt, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, + }; + + use async_trait::async_trait; + use axum::{Json, Router, routing::post}; + #[cfg(any())] + use crank_adapter_grpc::test_support as grpc_test_support; + #[cfg(any())] + use crank_core::{ + AsyncJobHandle, AsyncJobId, DescriptorId, ExecutionMode, GrpcTarget, JobStatus, + OperationId, SoapBindingStyle, SoapOperationMetadata, SoapTarget, SoapVersion, + StreamSession, StreamStatus, TransportBehavior, + }; + use crank_core::{ + ExecutionConfig, GraphqlOperationType, GraphqlTarget, HttpMethod, MembershipRole, + OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, + ToolDescription, WebsocketTarget, WorkspaceId, + }; + use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome}; + use crank_mapping::{MappingRule, MappingSet}; + use crank_registry::PostgresRegistry; + #[cfg(any())] + use crank_registry::{CreateAsyncJobRequest, CreateStreamSessionRequest}; + use crank_runtime::SecretCrypto; + use crank_schema::{Schema, SchemaKind}; + use serde_json::{Value, json}; + use serial_test::serial; + use sqlx::{Connection, Executor, PgConnection}; + #[cfg(any())] + use time::OffsetDateTime; + use tokio::net::TcpListener; + + use crate::{ + 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>, + handle: Option>, + } + + 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 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 { + 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::() + .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::() + .await + .unwrap(); + let published = client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap() + .json::() + .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::() + .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::() + .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::() + .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::() + .await + .unwrap(); + assert_eq!(updated["status"], "draft"); + + let detail = client + .get(format!("{base_url}/operations/{operation_id}")) + .send() + .await + .unwrap() + .json::() + .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::() + .await + .unwrap(); + assert_eq!(archived["status"], "archived"); + + let deleted = client + .delete(format!("{base_url}/operations/{operation_id}")) + .send() + .await + .unwrap() + .json::() + .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::().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::() + .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::() + .await + .unwrap(); + let published = client + .post(format!("{base_url}/agents/{agent_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap() + .json::() + .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 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::() + .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::() + .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::() + .await + .unwrap(); + assert_eq!(updated["agent_id"], agent_id); + + let detail = client + .get(format!("{base_url}/agents/{agent_id}")) + .send() + .await + .unwrap() + .json::() + .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::() + .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::().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"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn manages_platform_access_resources() { + 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 = client + .get(format!("{base_url}/members")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let created_invitation = client + .post(format!("{base_url}/invitations")) + .json(&json!({ + "email": "operator@example.com", + "role": "operator" + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let invitation_id = created_invitation["invitation"]["invitation"]["id"] + .as_str() + .unwrap() + .to_owned(); + let invitations = client + .get(format!("{base_url}/invitations")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let delete_invitation_status = client + .delete(format!("{base_url}/invitations/{invitation_id}")) + .send() + .await + .unwrap() + .status(); + + assert_eq!(members["items"][0]["role"], "owner"); + assert_eq!( + created_invitation["invitation"]["invitation"]["status"], + "pending" + ); + assert!( + created_invitation["invite_token"] + .as_str() + .unwrap() + .starts_with("invite_") + ); + assert_eq!( + invitations["items"][0]["invitation"]["email"], + "operator@example.com" + ); + assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT); + } + + #[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", + "scopes": ["read", "write"] + })) + .send() + .await + .unwrap(), + ) + .await; + let key_id = created_key["api_key"]["api_key"]["id"] + .as_str() + .unwrap() + .to_owned(); + + 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!( + listed_keys["items"][0]["api_key"]["agent_id"], + json!(agent_id) + ); + assert_eq!( + listed_keys["items"][0]["api_key"]["name"], + "sales-routing-primary" + ); + 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"], 1); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_short_lived_machine_token_issue_in_community() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_short_lived_token_contract"); + 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(); + + let response = reqwest::Client::new() + .post(format!("{root_url}/mcp-auth/v1/token")) + .json(&json!({ + "grant_type": "agent_key", + "agent_key": "crk_agent_demo_secret", + "scope": ["tools:call"] + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); + let payload = response.json::().await.unwrap(); + assert_eq!(payload["error"]["code"], "forbidden"); + assert_eq!( + payload["error"]["message"], + "short-lived machine access is not available in Community" + ); + assert_eq!(payload["error"]["context"]["edition"], "community"); + assert_eq!( + payload["error"]["context"]["machine_access_mode"], + "short_lived_token" + ); + assert_eq!(payload["error"]["context"]["grant_type"], "agent_key"); + assert_eq!(payload["error"]["context"]["upgrade_required"], json!(true)); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_one_time_machine_token_issue_in_community() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_one_time_token_contract"); + 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(); + + let response = reqwest::Client::new() + .post(format!("{root_url}/mcp-auth/v1/token/one-time")) + .json(&json!({ + "agent_key": "crk_agent_demo_secret", + "operation_id": "op_sensitive_01", + "scope": ["tools:call"] + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); + let payload = response.json::().await.unwrap(); + assert_eq!(payload["error"]["code"], "forbidden"); + assert_eq!( + payload["error"]["message"], + "one-time machine access is not available in Community" + ); + assert_eq!(payload["error"]["context"]["edition"], "community"); + assert_eq!( + payload["error"]["context"]["machine_access_mode"], + "one_time_token" + ); + assert_eq!( + payload["error"]["context"]["operation_id"], + "op_sensitive_01" + ); + assert_eq!(payload["error"]["context"]["upgrade_required"], json!(true)); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn returns_community_protocol_capabilities_only_for_supported_protocols() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_protocol_capabilities"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let response = assert_success_json( + client + .get(format!("{base_url}/protocol-capabilities")) + .send() + .await + .unwrap(), + ) + .await; + + assert_eq!(response["items"].as_array().unwrap().len(), 1); + assert_eq!( + response["items"] + .as_array() + .unwrap() + .iter() + .map(|item| item["protocol"].as_str().unwrap()) + .collect::>(), + vec!["rest"] + ); + for item in response["items"].as_array().unwrap() { + assert_eq!(item["supports_execution_modes"], json!(["unary"])); + assert_eq!( + item["supports_transport_behaviors"], + json!(["request_response"]) + ); + } + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_graphql_protocol_for_community_operation_create() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_graphql_reject_create"); + let upstream_base_url = spawn_graphql_server().await; + 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}/operations")) + .json(&test_graphql_operation_payload( + &upstream_base_url, + "crm_graphql_not_in_community", + )) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "validation_error"); + assert_eq!( + body["error"]["message"], + "protocol graphql is not supported in Community" + ); + assert_eq!( + body["error"]["context"], + json!({ + "protocol": "graphql", + "edition": "community", + }) + ); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_unsupported_protocol_for_community_operation_create() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_protocol_reject"); + 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}/operations")) + .json(&test_websocket_operation_payload( + "wss://example.com/stream", + "telemetry_ws", + )) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "validation_error"); + assert_eq!( + body["error"]["message"], + "protocol websocket is not supported in Community" + ); + assert_eq!( + body["error"]["context"], + json!({ + "protocol": "websocket", + "edition": "community", + }) + ); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_premium_security_level_for_community_operation_create() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_security_reject_create"); + 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_elevated"); + payload.security_level = OperationSecurityLevel::Elevated; + + let response = client + .post(format!("{base_url}/operations")) + .json(&payload) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "validation_error"); + assert_eq!( + body["error"]["message"], + "security level elevated is not supported in Community" + ); + assert_eq!( + body["error"]["context"], + json!({ + "security_level": "elevated", + "edition": "community", + }) + ); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_premium_security_level_for_community_operation_update() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_security_reject_update"); + 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_update_elevated", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let response = client + .patch(format!("{base_url}/operations/{operation_id}")) + .json(&json!({ + "display_name": "Create Lead", + "category": "sales", + "security_level": "elevated", + "target": { + "kind": "rest", + "base_url": upstream_base_url, + "method": "POST", + "path_template": "/crm/leads", + "static_headers": {} + }, + "input_schema": object_schema("email"), + "output_schema": object_schema("id"), + "input_mapping": { + "rules": [{ + "source": "$.mcp.email", + "target": "$.request.body.email", + "required": true, + "default_value": null, + "transform": null, + "condition": null, + "notes": null + }] + }, + "output_mapping": { + "rules": [{ + "source": "$.response.body.id", + "target": "$.output.id", + "required": true, + "default_value": null, + "transform": null, + "condition": null, + "notes": null + }] + }, + "execution_config": { + "timeout_ms": 1000, + "retry_policy": null, + "auth_profile_ref": null, + "headers": {}, + "protocol_options": null, + "streaming": null + }, + "tool_description": { + "title": "Create Lead", + "description": "Creates a CRM lead", + "tags": ["crm"], + "examples": [] + } + })) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "validation_error"); + assert_eq!( + body["error"]["message"], + "security level elevated is not supported in Community" + ); + assert_eq!( + body["error"]["context"], + json!({ + "security_level": "elevated", + "edition": "community", + }) + ); + } + + #[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::().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::().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 manages_workspace_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 updated_members = assert_success_json( + client + .patch(format!("{base_url}/members/{}", second_user_id.as_str())) + .json(&json!({ "role": "admin" })) + .send() + .await + .unwrap(), + ) + .await; + let updated_member = updated_members["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["user"]["id"] == second_user_id.as_str()) + .unwrap(); + assert_eq!(updated_member["role"], "admin"); + + let exported = assert_success_json( + client + .get(format!("{base_url}/export")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!( + exported["workspace"]["workspace"]["id"], + DEFAULT_WORKSPACE_ID + ); + assert_eq!(exported["memberships"].as_array().unwrap().len(), 2); + + 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::NO_CONTENT); + + let delete_workspace_status = client + .delete(base_url.as_ref()) + .send() + .await + .unwrap() + .status(); + assert_eq!(delete_workspace_status, reqwest::StatusCode::NO_CONTENT); + + let root_url = base_url + .as_ref() + .split("/api/admin/workspaces/") + .next() + .unwrap(); + let missing_workspace = client + .get(format!( + "{root_url}/api/admin/workspaces/{DEFAULT_WORKSPACE_ID}" + )) + .send() + .await + .unwrap(); + assert_eq!(missing_workspace.status(), reqwest::StatusCode::FORBIDDEN); + } + + #[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 = AdminService::new( + registry.clone(), + storage_root, + test_auth_settings(), + test_secret_crypto(), + ); + + service.bootstrap_admin_user().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!( + workspaces + .iter() + .any(|item| item.workspace.slug == "growth-lab") + ); + + let default_workspace_id = WorkspaceId::new(DEFAULT_WORKSPACE_ID); + let operations = service + .list_operations(&default_workspace_id) + .await + .unwrap(); + assert!(operations.len() >= 2); + + let agents = service.list_agents(&default_workspace_id).await.unwrap(); + assert!(!agents.is_empty()); + + assert!(agents.iter().any(|agent| agent.key_count > 0)); + + let logs = service + .list_logs( + &default_workspace_id, + crate::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 switches_current_workspace_in_session() { + 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 created_workspace = assert_success_json( + client + .post(format!("{root_url}/api/admin/workspaces")) + .json(&json!({ + "slug": "growth-lab", + "display_name": "Growth Lab", + "settings": {} + })) + .send() + .await + .unwrap(), + ) + .await; + let workspace_id = created_workspace["workspace"]["id"].as_str().unwrap(); + + let switched = assert_success_json( + client + .post(format!("{root_url}/api/auth/current-workspace")) + .json(&json!({ "workspace_id": workspace_id })) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(switched["current_workspace_id"], workspace_id); + + let session = assert_success_json( + client + .get(format!("{root_url}/api/auth/session")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(session["current_workspace_id"], 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::() + .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::() + .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::() + .await + .unwrap(); + let usage = client + .get(format!("{base_url}/usage?period=7d")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_usage = client + .get(format!( + "{base_url}/usage/operations/{operation_id}?period=7d" + )) + .send() + .await + .unwrap() + .json::() + .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::() + .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::() + .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::() + .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::() + .await + .unwrap(); + + assert_eq!(logs["items"][0]["log"]["request_id"], request_id); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn creates_publishes_and_tests_graphql_operation() { + let registry = test_registry().await; + let storage_root = test_storage_root("graphql"); + let upstream_base_url = spawn_graphql_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_graphql_operation_payload( + &upstream_base_url, + "crm_create_lead_graphql", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let published = client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap() + .json::() + .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::() + .await + .unwrap(); + + assert_eq!(published["published_version"], 1); + assert_eq!(test_run["ok"], true); + assert_eq!( + test_run["request_preview"]["variables"]["email"], + "user@example.com" + ); + assert_eq!(test_run["response_preview"]["id"], "lead_123"); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn uploads_descriptor_set_and_lists_grpc_services() { + let registry = test_registry().await; + let storage_root = test_storage_root("grpc_descriptor"); + let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_operation_payload( + &server_addr, + "echo_descriptor", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let uploaded = client + .post(format!( + "{base_url}/operations/{operation_id}/descriptors/descriptor-set" + )) + .header("x-file-name", "echo_descriptor.bin") + .body(grpc_test_support::echo::FILE_DESCRIPTOR_SET.to_vec()) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let services = client + .get(format!( + "{base_url}/operations/{operation_id}/grpc/services" + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert!(uploaded["descriptor_id"].as_str().is_some()); + assert_eq!(services["services"][0]["package"], "echo"); + assert_eq!(services["services"][0]["service"], "EchoService"); + assert_eq!(services["services"][0]["methods"][0]["name"], "UnaryEcho"); + assert_eq!(services["services"][0]["methods"][0]["kind"], "unary"); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn creates_publishes_and_tests_grpc_operation() { + let registry = test_registry().await; + let storage_root = test_storage_root("grpc_runtime"); + let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_operation_payload(&server_addr, "echo_runtime")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let published = client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "message": "hello" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(published["published_version"], 1); + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["request_preview"]["grpc"]["message"], "hello"); + assert_eq!(test_run["response_preview"]["message"], "hello"); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn returns_window_metadata_for_streaming_test_runs() { + let registry = test_registry().await; + let storage_root = test_storage_root("grpc_window_test_run"); + let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_window_operation_payload( + &server_addr, + "echo_window_runtime", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "message": "hello" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["mode"], "window"); + assert!(test_run["window"].is_object()); + assert!(test_run["window"]["window_complete"].is_boolean()); + assert!(test_run["window"]["truncated"].is_boolean()); + assert!(test_run["window"]["has_more"].is_boolean()); + assert!(test_run["response_preview"]["items"].is_array()); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn starts_stream_session_from_streaming_test_runs() { + let registry = test_registry().await; + let storage_root = test_storage_root("grpc_session_test_run"); + let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_session_operation_payload( + &server_addr, + "echo_session_runtime", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "message": "hello" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let session_id = test_run["stream_session"]["session_id"] + .as_str() + .unwrap() + .to_owned(); + + let session = client + .get(format!("{base_url}/stream-sessions/{session_id}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["mode"], "session"); + assert_eq!(test_run["stream_session"]["status"], "running"); + assert!(test_run["response_preview"]["preview"]["items"].is_array()); + assert_eq!(session["id"], session_id); + assert_eq!(session["status"], "running"); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn starts_async_job_from_streaming_test_runs() { + let registry = test_registry().await; + let storage_root = test_storage_root("async_job_test_run"); + 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_rest_async_job_operation_payload( + &upstream_base_url, + "crm_async_job_runtime", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + 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::() + .await + .unwrap(); + let job_id = test_run["async_job"]["job_id"].as_str().unwrap().to_owned(); + + let mut job = Value::Null; + for _ in 0..20 { + job = client + .get(format!("{base_url}/async-jobs/{job_id}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + if job["status"] == "completed" { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + let result = client + .get(format!("{base_url}/async-jobs/{job_id}/result")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["mode"], "async_job"); + assert_eq!(test_run["async_job"]["status"], "running"); + assert_eq!(job["status"], "completed"); + assert_eq!(result["id"], "lead_123"); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn returns_structured_context_for_pending_async_job_result() { + let registry = test_registry().await; + let storage_root = test_storage_root("async_job_pending_result"); + let upstream_base_url = spawn_upstream_server().await; + let job_id = AsyncJobId::new("job_pending_result".to_owned()); + let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; + let client = authorized_client(&base_url).await; + let created = client + .post(format!("{base_url}/operations")) + .json(&test_rest_async_job_operation_payload( + &upstream_base_url, + "crm_async_job_pending_result", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); + let now = OffsetDateTime::now_utc(); + registry + .create_async_job(CreateAsyncJobRequest { + job: &AsyncJobHandle { + id: job_id.clone(), + workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), + agent_id: None, + operation_id, + status: JobStatus::Running, + progress: json!({ "pct": 25 }), + result: None, + error: None, + expires_at: Some(now + time::Duration::minutes(5)), + last_poll_at: None, + created_at: now, + updated_at: now, + finished_at: None, + }, + }) + .await + .unwrap(); + + let response = client + .get(format!("{base_url}/async-jobs/{}/result", job_id.as_str())) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "conflict"); + assert_eq!(body["error"]["message"], "async job result is not ready"); + assert_eq!( + body["error"]["context"], + json!({ + "job_id": job_id.as_str(), + "status": "running" + }) + ); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rate_limits_rapid_stream_session_reads() { + let registry = test_registry().await; + let storage_root = test_storage_root("stream_session_rate_limit"); + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_grpc_session_operation_payload( + &server_addr, + "echo_session_rate_limit", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); + let now = OffsetDateTime::now_utc(); + let session_id = crank_core::StreamSessionId::new("sess_rate_limited".to_owned()); + registry + .create_stream_session(CreateStreamSessionRequest { + session: &StreamSession { + id: session_id.clone(), + workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), + agent_id: None, + operation_id, + protocol: Protocol::Grpc, + mode: ExecutionMode::Session, + status: StreamStatus::Running, + cursor: None, + state: json!({ "items": [] }), + expires_at: now + time::Duration::minutes(5), + last_poll_at: Some(now), + created_at: now, + closed_at: None, + }, + }) + .await + .unwrap(); + + let response = client + .get(format!( + "{base_url}/stream-sessions/{}", + session_id.as_str() + )) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS); + assert_eq!(body["error"]["code"], "rate_limited"); + assert_eq!( + body["error"]["message"], + "stream session poll rate limit exceeded" + ); + assert_eq!(body["error"]["context"]["session_id"], session_id.as_str()); + assert!(body["error"]["context"]["poll_after_ms"].as_u64().unwrap() > 0); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rate_limits_rapid_async_job_result_polls() { + let registry = test_registry().await; + let storage_root = test_storage_root("async_job_rate_limit"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_rest_async_job_operation_payload( + &upstream_base_url, + "crm_async_job_rate_limit", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); + let now = OffsetDateTime::now_utc(); + let job_id = AsyncJobId::new("job_rate_limited".to_owned()); + registry + .create_async_job(CreateAsyncJobRequest { + job: &AsyncJobHandle { + id: job_id.clone(), + workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), + agent_id: None, + operation_id, + status: JobStatus::Running, + progress: json!({ "pct": 25 }), + result: None, + error: None, + expires_at: Some(now + time::Duration::minutes(5)), + last_poll_at: Some(now), + created_at: now, + updated_at: now, + finished_at: None, + }, + }) + .await + .unwrap(); + + let response = client + .get(format!("{base_url}/async-jobs/{}/result", job_id.as_str())) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS); + assert_eq!(body["error"]["code"], "rate_limited"); + assert_eq!( + body["error"]["message"], + "async job poll rate limit exceeded" + ); + assert_eq!(body["error"]["context"]["job_id"], job_id.as_str()); + assert!(body["error"]["context"]["poll_after_ms"].as_u64().unwrap() > 0); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn allows_completed_async_job_result_fetch_without_poll_delay() { + let registry = test_registry().await; + let storage_root = test_storage_root("async_job_completed_result"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_rest_async_job_operation_payload( + &upstream_base_url, + "crm_async_job_completed_result", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); + let now = OffsetDateTime::now_utc(); + let job_id = AsyncJobId::new("job_completed_result".to_owned()); + registry + .create_async_job(CreateAsyncJobRequest { + job: &AsyncJobHandle { + id: job_id.clone(), + workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), + agent_id: None, + operation_id, + status: JobStatus::Completed, + progress: json!({ "pct": 100 }), + result: Some(json!({ "id": "lead_123" })), + error: None, + expires_at: Some(now + time::Duration::minutes(5)), + last_poll_at: Some(now), + created_at: now, + updated_at: now, + finished_at: Some(now), + }, + }) + .await + .unwrap(); + + let response = client + .get(format!("{base_url}/async-jobs/{}/result", job_id.as_str())) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::OK); + assert_eq!(body["id"], "lead_123"); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn returns_structured_context_for_missing_stream_session() { + let registry = test_registry().await; + let storage_root = test_storage_root("missing_stream_session"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let response = client + .get(format!("{base_url}/stream-sessions/sess_missing")) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::NOT_FOUND); + assert_eq!(body["error"]["code"], "not_found"); + assert_eq!( + body["error"]["message"], + "stream session sess_missing was not found" + ); + assert_eq!( + body["error"]["context"], + json!({ + "session_id": "sess_missing" + }) + ); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn uploads_wsdl_and_tests_soap_operation() { + let registry = test_registry().await; + let storage_root = test_storage_root("soap_runtime"); + let endpoint = spawn_soap_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_soap_operation_payload( + &endpoint, + "crm_create_lead_soap", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let uploaded = client + .post(format!( + "{base_url}/operations/{operation_id}/descriptors/wsdl" + )) + .header("x-file-name", "lead.wsdl") + .body(SOAP_TEST_WSDL) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + let services = client + .get(format!( + "{base_url}/operations/{operation_id}/soap/services" + )) + .send() + .await + .unwrap() + .json::() + .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::() + .await + .unwrap(); + + assert_eq!(uploaded["version"], 1); + assert_eq!(services["services"][0]["service_name"], "LeadService"); + assert_eq!(services["services"][0]["ports"][0]["port_name"], "LeadPort"); + assert_eq!( + services["services"][0]["ports"][0]["operations"][0]["operation_name"], + "CreateLead" + ); + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["response_preview"]["id"], "lead_123"); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn returns_structured_context_when_wsdl_is_missing() { + let registry = test_registry().await; + let storage_root = test_storage_root("soap_missing_wsdl"); + let endpoint = spawn_soap_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_soap_operation_payload( + &endpoint, + "crm_create_lead_soap_missing_wsdl", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let response = client + .get(format!( + "{base_url}/operations/{operation_id}/soap/services" + )) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::NOT_FOUND); + assert_eq!(body["error"]["code"], "not_found"); + assert_eq!(body["error"]["message"], "wsdl was not uploaded"); + assert_eq!( + body["error"]["context"], + json!({ + "operation_id": operation_id, + "version": 1, + "descriptor_kind": "wsdl_upload" + }) + ); + } + + #[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::() + .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::().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 + }) + ); + } + + #[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::() + .await + .unwrap(); + let created = client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "crm_export_target", + )) + .send() + .await + .unwrap() + .json::() + .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::() + .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 rejects_premium_security_level_for_community_yaml_upsert() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_yaml_security_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 created = client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "crm_yaml_standard", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let mut yaml = client + .get(format!("{base_url}/operations/{operation_id}/export")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + yaml = yaml.replace("security_level: standard", "security_level: elevated"); + + let response = client + .post(format!("{base_url}/operations/import?mode=upsert")) + .body(yaml) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "validation_error"); + assert_eq!( + body["error"]["message"], + "security level elevated is not supported in Community" + ); + assert_eq!( + body["error"]["context"], + json!({ + "security_level": "elevated", + "edition": "community", + }) + ); + } + + #[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::().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::() + .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::().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::().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::().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}") + ); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn roundtrips_graphql_operation_through_yaml_upsert() { + let registry = test_registry().await; + let storage_root = test_storage_root("yaml_graphql"); + let upstream_base_url = spawn_graphql_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_graphql_operation_payload( + &upstream_base_url, + "crm_yaml_graphql", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + 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::() + .await + .unwrap(); + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 2, + "input": { "email": "user@example.com" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(imported["operation_id"], operation_id); + assert_eq!(imported["version"], 2); + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["response_preview"]["id"], "lead_123"); + } + + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn roundtrips_grpc_operation_through_yaml_upsert() { + let registry = test_registry().await; + let storage_root = test_storage_root("yaml_grpc"); + let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_operation_payload(&server_addr, "echo_yaml_grpc")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + 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::() + .await + .unwrap(); + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 2, + "input": { "message": "hello" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(imported["operation_id"], operation_id); + assert_eq!(imported["version"], 2); + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["response_preview"]["message"], "hello"); + } + + #[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::() + .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::() + .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" + ); + } + + fn build_test_app(registry: PostgresRegistry, storage_root: std::path::PathBuf) -> Router { + build_app(AppState { + service: AdminService::new( + 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(), + ), + }) + } + + 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) + } + + async fn spawn_graphql_server() -> String { + let app = Router::new().route("/", post(graphql_handler)); + 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) + } + + #[cfg(any())] + async fn spawn_soap_server() -> String { + let app = Router::new().route("/", post(soap_handler)); + 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 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), + } + } + + async fn authorized_client(workspace_base_url: impl AsRef) -> 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 + } + + #[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: AdminService::new( + registry, + storage_root, + test_auth_settings(), + test_secret_crypto(), + ), + api_rate_limiter: crank_runtime::RequestRateLimiter::new( + crank_runtime::RequestRateLimitConfig::new(1, 1).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(); + }); + + 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::().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(crate::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"); + } + + 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() + } + + async fn create_lead(Json(payload): Json) -> Json { + Json(json!({ + "id": "lead_123", + "status": "created", + "email": payload["email"] + })) + } + + async fn graphql_handler(Json(payload): Json) -> Json { + let email = payload + .get("variables") + .and_then(|variables| variables.get("email")) + .and_then(Value::as_str) + .unwrap_or_default(); + + Json(json!({ + "data": { + "createLead": { + "id": "lead_123", + "status": "created", + "email": email + } + } + })) + } + + #[cfg(any())] + async fn soap_handler(body: String) -> (axum::http::StatusCode, String) { + assert!(body.contains("user@example.com")); + + ( + axum::http::StatusCode::OK, + r#" + + + lead_123 + created + + + "# + .to_owned(), + ) + } + + async fn test_registry() -> PostgresRegistry { + let database_url = env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:15432/crank".to_owned()); + let mut admin_connection = PgConnection::connect(&database_url).await.unwrap(); + let schema = format!( + "test_admin_api_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + + admin_connection + .execute(sqlx::query(&format!("create schema {schema}"))) + .await + .unwrap(); + let registry = + PostgresRegistry::connect(&format!("{database_url}?options=-csearch_path%3D{schema}")) + .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 + } + + 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() + )) + } + + 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(), + }, + } + } + + fn test_secret_crypto() -> SecretCrypto { + SecretCrypto::new(TEST_MASTER_KEY).unwrap() + } + + 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, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Create Lead".to_owned(), + description: "Creates a CRM lead".to_owned(), + tags: vec!["crm".to_owned()], + examples: Vec::new(), + }, + } + } + + fn test_graphql_operation_payload(endpoint: &str, name: &str) -> OperationPayload { + OperationPayload { + name: name.to_owned(), + display_name: "Create Lead GraphQL".to_owned(), + category: "sales".to_owned(), + protocol: Protocol::Graphql, + security_level: OperationSecurityLevel::Standard, + target: Target::Graphql(GraphqlTarget { + endpoint: endpoint.to_owned(), + operation_type: GraphqlOperationType::Mutation, + operation_name: "CreateLead".to_owned(), + query_template: + "mutation CreateLead($email: String!) { createLead(email: $email) { id status email } }" + .to_owned(), + response_path: "$.response.body.data.createLead".to_owned(), + }), + input_schema: object_schema("email"), + output_schema: object_schema("id"), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.variables.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.data.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, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Create Lead GraphQL".to_owned(), + description: "Creates a CRM lead through GraphQL".to_owned(), + tags: vec!["crm".to_owned(), "graphql".to_owned()], + examples: Vec::new(), + }, + } + } + + #[cfg(any())] + fn test_grpc_operation_payload(server_addr: &str, name: &str) -> OperationPayload { + OperationPayload { + name: name.to_owned(), + display_name: "Unary Echo gRPC".to_owned(), + category: "support".to_owned(), + protocol: Protocol::Grpc, + security_level: OperationSecurityLevel::Standard, + target: Target::Grpc(GrpcTarget { + server_addr: server_addr.to_owned(), + package: "echo".to_owned(), + service: "EchoService".to_owned(), + method: "UnaryEcho".to_owned(), + read_only: false, + descriptor_ref: DescriptorId::new("desc_echo"), + descriptor_set_b64: grpc_test_support::descriptor_set_b64(), + }), + input_schema: object_schema("message"), + output_schema: object_schema("message"), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.message".to_owned(), + target: "$.request.grpc.message".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.data.message".to_owned(), + target: "$.output.message".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, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Unary Echo gRPC".to_owned(), + description: "Echoes a unary gRPC payload".to_owned(), + tags: vec!["grpc".to_owned()], + examples: Vec::new(), + }, + } + } + + #[cfg(any())] + fn test_grpc_window_operation_payload(server_addr: &str, name: &str) -> OperationPayload { + let mut payload = test_grpc_operation_payload(server_addr, name); + payload.display_name = "Server Echo Window".to_owned(); + payload.target = Target::Grpc(GrpcTarget { + server_addr: server_addr.to_owned(), + package: "echo".to_owned(), + service: "EchoService".to_owned(), + method: "ServerEcho".to_owned(), + read_only: false, + descriptor_ref: DescriptorId::new("desc_echo_stream"), + descriptor_set_b64: grpc_test_support::descriptor_set_b64(), + }); + payload.execution_config.streaming = Some(crank_core::StreamingConfig { + mode: ExecutionMode::Window, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(100), + poll_interval_ms: None, + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: None, + max_session_lifetime_ms: None, + max_items: Some(2), + max_bytes: None, + aggregation_mode: crank_core::AggregationMode::RawItems, + summary_path: None, + items_path: Some("$.response.body.items".to_owned()), + cursor_path: None, + status_path: None, + done_path: Some("$.response.body.done".to_owned()), + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: crank_core::ToolFamilyConfig::default(), + }); + payload + } + + #[cfg(any())] + fn test_grpc_session_operation_payload(server_addr: &str, name: &str) -> OperationPayload { + let mut payload = test_grpc_window_operation_payload(server_addr, name); + payload.display_name = "Server Echo Session".to_owned(); + if let Some(streaming) = payload.execution_config.streaming.as_mut() { + streaming.mode = ExecutionMode::Session; + streaming.poll_interval_ms = Some(1_000); + streaming.max_session_lifetime_ms = Some(60_000); + streaming.tool_family = crank_core::ToolFamilyConfig { + start_tool_name: Some("echo_session_start".to_owned()), + poll_tool_name: Some("echo_session_poll".to_owned()), + stop_tool_name: Some("echo_session_stop".to_owned()), + status_tool_name: None, + result_tool_name: None, + cancel_tool_name: None, + }; + } + payload + } + + #[cfg(any())] + fn test_rest_async_job_operation_payload(base_url: &str, name: &str) -> OperationPayload { + let mut payload = test_operation_payload(base_url, name); + payload.display_name = "Create Lead Async Job".to_owned(); + payload.execution_config.streaming = Some(crank_core::StreamingConfig { + mode: ExecutionMode::AsyncJob, + transport_behavior: TransportBehavior::DeferredResult, + window_duration_ms: None, + poll_interval_ms: Some(1_000), + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: None, + max_session_lifetime_ms: Some(300_000), + max_items: None, + max_bytes: None, + aggregation_mode: crank_core::AggregationMode::SummaryPlusSamples, + summary_path: None, + items_path: None, + cursor_path: None, + status_path: None, + done_path: None, + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: crank_core::ToolFamilyConfig { + start_tool_name: Some("crm_async_start".to_owned()), + poll_tool_name: None, + stop_tool_name: None, + status_tool_name: Some("crm_async_status".to_owned()), + result_tool_name: Some("crm_async_result".to_owned()), + cancel_tool_name: Some("crm_async_cancel".to_owned()), + }, + }); + payload + } + + #[cfg(any())] + fn test_soap_operation_payload(endpoint: &str, name: &str) -> OperationPayload { + OperationPayload { + name: name.to_owned(), + display_name: "Create Lead SOAP".to_owned(), + category: "sales".to_owned(), + protocol: Protocol::Soap, + security_level: OperationSecurityLevel::Standard, + target: Target::Soap(SoapTarget { + wsdl_ref: "sample_wsdl".into(), + service_name: "LeadService".to_owned(), + port_name: "LeadPort".to_owned(), + operation_name: "CreateLead".to_owned(), + endpoint_override: Some(endpoint.to_owned()), + soap_version: SoapVersion::Soap11, + soap_action: Some("urn:createLead".to_owned()), + binding_style: SoapBindingStyle::DocumentLiteral, + headers: Vec::new(), + fault_contract: None, + metadata: SoapOperationMetadata { + input_part_names: vec!["CreateLeadRequest".to_owned()], + output_part_names: vec!["CreateLeadResponse".to_owned()], + namespaces: vec!["urn:crm".to_owned()], + }, + }), + 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, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Create Lead SOAP".to_owned(), + description: "Creates a CRM lead through SOAP".to_owned(), + tags: vec!["crm".to_owned(), "soap".to_owned()], + examples: Vec::new(), + }, + } + } + + fn test_websocket_operation_payload(url: &str, name: &str) -> OperationPayload { + OperationPayload { + name: name.to_owned(), + display_name: "Telemetry WebSocket".to_owned(), + category: "telemetry".to_owned(), + protocol: Protocol::Websocket, + security_level: OperationSecurityLevel::Standard, + target: Target::Websocket(WebsocketTarget { + url: url.to_owned(), + subprotocols: Vec::new(), + subscribe_message_template: Some(json!({ "subscribe": true })), + unsubscribe_message_template: None, + static_headers: BTreeMap::new(), + }), + input_schema: object_schema("channel"), + output_schema: object_schema("event"), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.channel".to_owned(), + target: "$.request.body.channel".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.body.event".to_owned(), + target: "$.output.event".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, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Telemetry WebSocket".to_owned(), + description: "Streams telemetry events".to_owned(), + tags: vec!["telemetry".to_owned(), "websocket".to_owned()], + examples: Vec::new(), + }, + } + } + + #[cfg(any())] + const SOAP_TEST_WSDL: &str = r#" + + + + + + + + + + + + +"#; + + 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(), + } + } +} diff --git a/apps/admin-api/src/auth.rs b/apps/admin-api/src/auth.rs new file mode 100644 index 0000000..7548ba1 --- /dev/null +++ b/apps/admin-api/src/auth.rs @@ -0,0 +1,124 @@ +use axum::{ + extract::{OriginalUri, Request, State}, + middleware::Next, + response::Response, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar}; +use crank_community_auth::{ + cleared_session_cookie as build_cleared_session_cookie, + create_session_cookie as build_session_cookie, hash_password as community_hash_password, + session_cookie as build_session_cookie_header, +}; +use crank_core::{User, UserSessionId, WorkspaceId}; +use crank_registry::WorkspaceMembershipRecord; +use serde::Serialize; + +use crate::{error::ApiError, state::AppState}; + +pub use crank_community_auth::{ + SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password, +}; + +#[derive(Clone)] +pub struct BootstrapAdminConfig { + pub email: String, + pub password: String, + pub display_name: String, +} + +#[derive(Clone)] +pub struct AuthSettings { + pub session_secret: String, + pub password_pepper: String, + pub session_ttl_hours: i64, + pub cookie_secure: bool, + pub bootstrap_admin: BootstrapAdminConfig, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AuthenticatedSession { + pub session_id: UserSessionId, + pub user: User, + pub memberships: Vec, + pub current_workspace_id: Option, +} + +pub fn hash_password(password: &str, pepper: &str) -> Result { + community_hash_password(password, pepper) + .map_err(|error| ApiError::internal(format!("failed to hash password: {error}"))) +} + +pub fn create_session_cookie(settings: &AuthSettings) -> Result { + build_session_cookie(settings.session_ttl_hours) + .map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}"))) +} + +pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> { + build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours) +} + +pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> { + build_cleared_session_cookie(settings.cookie_secure) +} + +pub async fn require_session( + State(state): State, + jar: CookieJar, + mut request: Request, + next: Next, +) -> Result { + let session = resolve_authenticated_session(state.clone(), &jar).await?; + request.extensions_mut().insert(session); + Ok(next.run(request).await) +} + +pub async fn require_workspace_session( + State(state): State, + jar: CookieJar, + original_uri: OriginalUri, + mut request: Request, + next: Next, +) -> Result { + let session = resolve_authenticated_session(state.clone(), &jar).await?; + let workspace_id = workspace_id_from_path(original_uri.path()) + .ok_or_else(|| ApiError::internal("workspace middleware was applied to an invalid path"))?; + + let has_access = state + .service + .user_has_workspace_access(&session.user.id, &workspace_id) + .await?; + if !has_access { + return Err(ApiError::forbidden("workspace access denied")); + } + + request.extensions_mut().insert(session); + Ok(next.run(request).await) +} + +async fn resolve_authenticated_session( + state: AppState, + jar: &CookieJar, +) -> Result { + let (session_id, session_value) = extract_session_token(jar) + .ok_or_else(|| ApiError::unauthorized("authentication required"))?; + let session = state + .service + .get_session(&session_id, &session_value) + .await? + .ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?; + + state.service.touch_session(&session_id).await?; + + Ok(session) +} + +fn workspace_id_from_path(path: &str) -> Option { + let marker = "/api/admin/workspaces/"; + let (_, suffix) = path.split_once(marker)?; + let workspace_id = suffix.split('/').next()?; + if workspace_id.is_empty() { + return None; + } + + Some(WorkspaceId::new(workspace_id)) +} diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs new file mode 100644 index 0000000..ed8e207 --- /dev/null +++ b/apps/admin-api/src/error.rs @@ -0,0 +1,571 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use crank_mapping::MappingError; +use crank_registry::RegistryError; +use crank_runtime::RuntimeError; +use crank_schema::SchemaError; +use serde_json::{Value, json}; +use thiserror::Error; +use tracing::{error, warn}; + +use crate::storage::StorageError; + +#[derive(Debug, Error)] +pub enum ApiError { + #[error("{message}")] + Unauthorized { + message: String, + context: Option, + }, + #[error("{message}")] + Forbidden { + message: String, + context: Option, + }, + #[error("{message}")] + Validation { + message: String, + context: Option, + }, + #[error("{message}")] + NotFound { + message: String, + context: Option, + }, + #[error("{message}")] + Conflict { + message: String, + context: Option, + }, + #[error("{message}")] + RateLimited { + message: String, + context: Option, + }, + #[error("{message}")] + Internal { + message: String, + context: Option, + }, +} + +impl ApiError { + pub fn unauthorized(message: impl Into) -> Self { + Self::Unauthorized { + message: message.into(), + context: None, + } + } + + pub fn forbidden(message: impl Into) -> Self { + Self::Forbidden { + message: message.into(), + context: None, + } + } + + pub fn validation(message: impl Into) -> Self { + Self::Validation { + message: message.into(), + context: None, + } + } + + pub fn internal(message: impl Into) -> Self { + Self::Internal { + message: message.into(), + context: None, + } + } + + pub(crate) fn rate_limited_with_context(message: impl Into, context: Value) -> Self { + Self::RateLimited { + message: message.into(), + context: Some(context), + } + } + + pub(crate) fn validation_with_context(message: impl Into, context: Value) -> Self { + Self::Validation { + message: message.into(), + context: Some(context), + } + } + + pub(crate) fn not_found_with_context(message: impl Into, context: Value) -> Self { + Self::NotFound { + message: message.into(), + context: Some(context), + } + } + + pub(crate) fn conflict_with_context(message: impl Into, context: Value) -> Self { + Self::Conflict { + message: message.into(), + context: Some(context), + } + } + + pub(crate) fn forbidden_with_context(message: impl Into, context: Value) -> Self { + Self::Forbidden { + message: message.into(), + context: Some(context), + } + } + + fn status_code(&self) -> StatusCode { + match self { + Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED, + Self::Forbidden { .. } => StatusCode::FORBIDDEN, + Self::Validation { .. } => StatusCode::BAD_REQUEST, + Self::NotFound { .. } => StatusCode::NOT_FOUND, + Self::Conflict { .. } => StatusCode::CONFLICT, + Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS, + Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn code(&self) -> &'static str { + match self { + Self::Unauthorized { .. } => "unauthorized", + Self::Forbidden { .. } => "forbidden", + Self::Validation { .. } => "validation_error", + Self::NotFound { .. } => "not_found", + Self::Conflict { .. } => "conflict", + Self::RateLimited { .. } => "rate_limited", + Self::Internal { .. } => "internal_error", + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + match &self { + Self::Internal { message, .. } => { + error!(error_code = self.code(), error_message = %message) + } + Self::Unauthorized { message, .. } + | Self::Forbidden { message, .. } + | Self::Validation { message, .. } + | Self::NotFound { message, .. } + | Self::Conflict { message, .. } + | Self::RateLimited { message, .. } => { + warn!(error_code = self.code(), error_message = %message) + } + } + + let mut error = json!({ + "code": self.code(), + "message": self.to_string(), + }); + if let Some(context) = self.context() { + error["context"] = context; + } + + let body = Json(json!({ + "error": error + })); + + (self.status_code(), body).into_response() + } +} + +impl ApiError { + fn context(&self) -> Option { + match self { + Self::Unauthorized { context, .. } + | Self::Forbidden { context, .. } + | Self::Validation { context, .. } + | Self::NotFound { context, .. } + | Self::Conflict { context, .. } + | Self::RateLimited { context, .. } + | Self::Internal { context, .. } => context.clone(), + } + } +} + +impl From for ApiError { + fn from(value: RegistryError) -> Self { + match value { + RegistryError::WorkspaceNotFound { workspace_id } => Self::not_found_with_context( + format!("workspace {workspace_id} was not found"), + json!({ "workspace_id": workspace_id }), + ), + RegistryError::UserNotFound { user_id } => Self::not_found_with_context( + format!("user {user_id} was not found"), + json!({ "user_id": user_id }), + ), + RegistryError::MembershipNotFound { + workspace_id, + user_id, + } => Self::not_found_with_context( + format!("membership for user {user_id} in workspace {workspace_id} was not found"), + json!({ + "workspace_id": workspace_id, + "user_id": user_id, + }), + ), + RegistryError::AgentNotFound { agent_id } => Self::not_found_with_context( + format!("agent {agent_id} was not found"), + json!({ "agent_id": agent_id }), + ), + RegistryError::InvitationNotFound { invitation_id } => Self::not_found_with_context( + format!("invitation {invitation_id} was not found"), + json!({ "invitation_id": invitation_id }), + ), + RegistryError::PlatformApiKeyNotFound { key_id } => Self::not_found_with_context( + format!("platform api key {key_id} was not found"), + json!({ "key_id": key_id }), + ), + RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context( + format!("secret {secret_id} was not found"), + json!({ "secret_id": secret_id }), + ), + RegistryError::StreamSessionNotFound { session_id } => Self::not_found_with_context( + format!("stream session {session_id} was not found"), + json!({ "session_id": session_id }), + ), + RegistryError::AsyncJobNotFound { job_id } => Self::not_found_with_context( + format!("async job {job_id} was not found"), + json!({ "job_id": job_id }), + ), + RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context( + format!("invocation log {log_id} was not found"), + json!({ "log_id": log_id }), + ), + RegistryError::PublishedAgentNotFound { + workspace_slug, + agent_slug, + } => Self::not_found_with_context( + format!("published agent {workspace_slug}/{agent_slug} was not found"), + json!({ + "workspace_slug": workspace_slug, + "agent_slug": agent_slug, + }), + ), + RegistryError::OperationNotFound { operation_id } => Self::not_found_with_context( + format!("operation {operation_id} was not found"), + json!({ "operation_id": operation_id }), + ), + RegistryError::OperationVersionNotFound { + operation_id, + version, + } => Self::not_found_with_context( + format!("operation version {version} for {operation_id} was not found"), + json!({ + "operation_id": operation_id, + "version": version, + }), + ), + RegistryError::OperationHasPublishedAgentBindings { operation_id } => { + Self::conflict_with_context( + format!( + "operation {operation_id} cannot be deleted while it is bound to a published agent" + ), + json!({ "operation_id": operation_id }), + ) + } + RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context( + format!("auth profile {auth_profile_id} was not found"), + json!({ "auth_profile_id": auth_profile_id }), + ), + RegistryError::OperationAlreadyExists { operation_id } => Self::conflict_with_context( + format!("operation {operation_id} already exists"), + json!({ "operation_id": operation_id }), + ), + RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context( + format!("workspace with slug {slug} already exists"), + json!({ "slug": slug }), + ), + RegistryError::SecretNameAlreadyExists { workspace_id, name } => { + Self::conflict_with_context( + format!("secret with name {name} already exists in workspace {workspace_id}"), + json!({ + "workspace_id": workspace_id, + "name": name, + }), + ) + } + RegistryError::SecretReferencedByAuthProfile { + secret_id, + auth_profile_id, + } => Self::conflict_with_context( + format!("secret {secret_id} is referenced by auth profile {auth_profile_id}"), + json!({ + "secret_id": secret_id, + "auth_profile_id": auth_profile_id, + }), + ), + RegistryError::InvalidStreamSessionTransition { + session_id, + from, + to, + } => Self::conflict_with_context( + format!("invalid stream session transition for {session_id}: {from} -> {to}"), + json!({ + "session_id": session_id, + "from": from, + "to": to, + }), + ), + RegistryError::InvalidAsyncJobTransition { job_id, from, to } => { + Self::conflict_with_context( + format!("invalid async job transition for {job_id}: {from} -> {to}"), + json!({ + "job_id": job_id, + "from": from, + "to": to, + }), + ) + } + RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context( + format!("user with email {email} already exists"), + json!({ "email": email }), + ), + RegistryError::InvalidInitialVersion { + operation_id, + version, + } => Self::validation_with_context( + format!("operation {operation_id} must start with version 1, got {version}"), + json!({ + "operation_id": operation_id, + "version": version, + }), + ), + RegistryError::InvalidVersionSequence { + operation_id, + expected, + actual, + } => Self::validation_with_context( + format!("operation {operation_id} expected next version {expected}, got {actual}"), + json!({ + "operation_id": operation_id, + "expected": expected, + "actual": actual, + }), + ), + RegistryError::ImmutableOperationFieldChanged { + operation_id, + field, + } => Self::validation_with_context( + format!("operation {operation_id} changed immutable field {field}"), + json!({ + "operation_id": operation_id, + "field": field, + }), + ), + RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context( + format!("unsupported enum representation for field {field}"), + json!({ "field": field }), + ), + RegistryError::InvalidNumericValue { field, value } => Self::validation_with_context( + format!("invalid numeric value for field {field}: {value}"), + json!({ + "field": field, + "value": value, + }), + ), + RegistryError::YamlImportJobNotFound { job_id } => Self::validation_with_context( + format!("yaml import job {job_id} was not found"), + json!({ "job_id": job_id }), + ), + RegistryError::Storage(_) | RegistryError::Serialization(_) => { + Self::internal(value.to_string()) + } + } + } +} + +impl From for ApiError { + fn from(value: MappingError) -> Self { + Self::validation(value.to_string()) + } +} + +impl From for ApiError { + fn from(value: SchemaError) -> Self { + Self::validation(value.to_string()) + } +} + +impl From for ApiError { + fn from(value: StorageError) -> Self { + match value { + StorageError::InvalidStorageRef { details } => Self::validation_with_context( + format!("invalid storage reference: {details}"), + json!({ "details": details }), + ), + StorageError::Io(_) | StorageError::Serialization(_) => { + Self::internal(value.to_string()) + } + } + } +} + +pub fn runtime_test_failure(error: &RuntimeError) -> Value { + let mut payload = json!({ + "code": runtime_test_failure_code(error), + "message": error.to_string() + }); + if let Some(context) = runtime_error_context(error) { + payload["context"] = context; + } + payload +} + +fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { + match error { + RuntimeError::Schema(_) => "runtime_schema_error", + RuntimeError::Mapping(_) => "runtime_mapping_error", + RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error", + RuntimeError::GrpcAdapter(_) => "runtime_grpc_error", + RuntimeError::RestAdapter(_) => "runtime_rest_error", + RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error", + RuntimeError::SoapAdapter(_) => "runtime_soap_error", + RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error", + RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error", + RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded", + RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error", + RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error", + RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error", + RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error", + RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error", + RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { + "runtime_secret_error" + } + RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error", + RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error", + } +} + +pub fn runtime_error_context(error: &RuntimeError) -> Option { + match error { + RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({ + "field": field, + "reason": reason, + })), + RuntimeError::InvalidStreamingPayload { field, reason } => Some(json!({ + "field": field, + "reason": reason, + })), + RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({ + "secret_id": secret_id, + "reason": reason, + })), + RuntimeError::SecretCrypto { operation, details } => Some(json!({ + "operation": operation, + "details": details, + })), + RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({ + "auth_profile_id": auth_profile_id, + })), + RuntimeError::MissingSecret { secret_id } => Some(json!({ + "secret_id": secret_id, + })), + RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({ + "secret_id": secret_id, + "version": version, + })), + RuntimeError::MissingStreamingConfig { operation_id } => Some(json!({ + "operation_id": operation_id, + })), + RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({ + "operation_id": operation_id, + "mode": mode, + })), + RuntimeError::UnsupportedProtocol { protocol } => Some(json!({ + "protocol": protocol, + })), + RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({ + "kind": kind, + "limit": limit, + })), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{ApiError, runtime_error_context, runtime_test_failure}; + use crank_registry::RegistryError; + use crank_runtime::RuntimeError; + + #[test] + fn runtime_test_failure_includes_structured_context() { + let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest { + field: "request.headers".to_owned(), + reason: "must be an object".to_owned(), + }); + + assert_eq!(payload["code"], "runtime_request_error"); + assert_eq!( + payload["context"], + json!({ + "field": "request.headers", + "reason": "must be an object" + }) + ); + } + + #[test] + fn runtime_error_context_includes_secret_crypto_operation() { + let context = runtime_error_context(&RuntimeError::SecretCrypto { + operation: "decode secret envelope", + details: "bad base64".to_owned(), + }) + .unwrap(); + + assert_eq!( + context, + json!({ + "operation": "decode secret envelope", + "details": "bad base64" + }) + ); + } + + #[test] + fn runtime_test_failure_includes_runtime_overload_context() { + let payload = runtime_test_failure(&RuntimeError::ConcurrencyLimitExceeded { + kind: "window", + limit: 16, + }); + + assert_eq!(payload["code"], "runtime_overloaded"); + assert_eq!( + payload["context"], + json!({ + "kind": "window", + "limit": 16 + }) + ); + } + + #[test] + fn registry_errors_preserve_structured_context_in_api_error() { + let error = ApiError::from(RegistryError::InvalidAsyncJobTransition { + job_id: "job_123".to_owned(), + from: "running".to_owned(), + to: "completed".to_owned(), + }); + + match error { + ApiError::Conflict { context, .. } => { + assert_eq!( + context, + Some(json!({ + "job_id": "job_123", + "from": "running", + "to": "completed" + })) + ); + } + other => panic!("unexpected error variant: {other:?}"), + } + } +} diff --git a/apps/admin-api/src/lib.rs b/apps/admin-api/src/lib.rs new file mode 100644 index 0000000..51e3520 --- /dev/null +++ b/apps/admin-api/src/lib.rs @@ -0,0 +1,9 @@ +pub mod app; +pub mod auth; +pub mod error; +pub mod rate_limit; +pub mod request_context; +pub mod routes; +pub mod service; +pub mod state; +pub mod storage; diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs new file mode 100644 index 0000000..4339b77 --- /dev/null +++ b/apps/admin-api/src/main.rs @@ -0,0 +1,156 @@ +use std::{env, net::SocketAddr, path::PathBuf}; + +use admin_api::{ + app::build_app, + auth::{AuthSettings, BootstrapAdminConfig}, + service::AdminServiceBuilder, + state::AppState, +}; +use crank_community_auth::PasswordIdentityProvider; +use crank_registry::{PostgresPoolConfig, PostgresRegistry}; +use crank_runtime::{ + RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores, + RuntimeLimits, SecretCrypto, community_default, +}; +use sqlx::postgres::PgConnectOptions; +use tokio::net::TcpListener; +use tracing::info; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + env::var("CRANK_LOG_LEVEL").unwrap_or_else(|_| "admin_api=info,tower_http=info".into()), + ) + .init(); + + let storage_root = PathBuf::from( + env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()), + ); + let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into()); + let base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()); + let socket_addr: SocketAddr = bind_addr.parse()?; + let pool_config = PostgresPoolConfig::from_env()?; + let registry = PostgresRegistry::connect_with_options_and_pool_config( + database_options_from_env()?, + pool_config, + ) + .await?; + let auth_settings = AuthSettings { + session_secret: env::var("CRANK_SESSION_SECRET")?, + password_pepper: env::var("CRANK_PASSWORD_PEPPER")?, + session_ttl_hours: env::var("CRANK_SESSION_TTL_HOURS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(24), + cookie_secure: base_url.starts_with("https://"), + bootstrap_admin: BootstrapAdminConfig { + email: env::var("CRANK_BOOTSTRAP_ADMIN_EMAIL")?, + password: env::var("CRANK_BOOTSTRAP_ADMIN_PASSWORD")?, + display_name: env::var("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME") + .unwrap_or_else(|_| "Crank Owner".into()), + }, + }; + let runtime_limits = RuntimeLimits::from_env()?; + let cache_config = RuntimeCacheConfig::from_env()?; + 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() + .with_limits(runtime_limits) + .with_response_cache(cache_stores.response.clone()) + .build(); + let identity_provider = + PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone()); + let service = AdminServiceBuilder::new( + registry, + storage_root, + auth_settings, + secret_crypto, + runtime, + ) + .with_identity_provider(std::sync::Arc::new(identity_provider)) + .build(); + service.bootstrap_admin_user().await?; + if env_flag("CRANK_DEMO_SEED") { + service.seed_demo_assets().await?; + } + let state = AppState { + service, + api_rate_limiter: if cache_config.backend.is_external() { + RequestRateLimiter::new_shared(api_rate_limit, cache_stores.rate_limit.clone()) + } else { + RequestRateLimiter::new(api_rate_limit) + }, + }; + let app = build_app(state); + let listener = TcpListener::bind(socket_addr).await?; + + info!( + runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary, + runtime_max_concurrent_window = runtime_limits.max_concurrent_window, + runtime_max_concurrent_sessions = runtime_limits.max_concurrent_sessions, + runtime_max_concurrent_jobs = runtime_limits.max_concurrent_jobs, + admin_rate_limit_rps = api_rate_limit.requests_per_second, + admin_rate_limit_burst = api_rate_limit.burst, + cache_backend = %cache_config.backend, + max_connections = pool_config.max_connections, + min_connections = pool_config.min_connections, + acquire_timeout_ms = pool_config.acquire_timeout_ms, + idle_timeout_ms = pool_config.idle_timeout_ms, + max_lifetime_ms = pool_config.max_lifetime_ms, + "postgres pool configured" + ); + info!("admin-api listening on {}", socket_addr); + + axum::serve(listener, app).await?; + + Ok(()) +} + +fn env_flag(name: &str) -> bool { + matches!( + env::var(name) + .ok() + .as_deref() + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1" | "true" | "yes" | "on") + ) +} + +fn admin_api_rate_limit_config_from_env() +-> Result> { + let requests_per_second = env::var("CRANK_ADMIN_RATE_LIMIT_RPS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(30); + let burst = env::var("CRANK_ADMIN_RATE_LIMIT_BURST") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(60); + + Ok(RequestRateLimitConfig::new(requests_per_second, burst)?) +} + +fn database_options_from_env() -> Result> { + if let Ok(database_url) = env::var("CRANK_DATABASE_URL") { + return Ok(database_url.parse::()?); + } + + let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into()); + let port = env::var("POSTGRES_PORT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(5432); + let database = env::var("POSTGRES_DB").unwrap_or_else(|_| "crank".into()); + let username = env::var("POSTGRES_USER").unwrap_or_else(|_| "crank".into()); + let password = env::var("POSTGRES_PASSWORD").unwrap_or_else(|_| "crank".into()); + + Ok(PgConnectOptions::new() + .host(&host) + .port(port) + .database(&database) + .username(&username) + .password(&password)) +} diff --git a/apps/admin-api/src/rate_limit.rs b/apps/admin-api/src/rate_limit.rs new file mode 100644 index 0000000..0100eb2 --- /dev/null +++ b/apps/admin-api/src/rate_limit.rs @@ -0,0 +1,106 @@ +use axum::{ + extract::{Request, State}, + http::header::{COOKIE, HeaderMap}, + middleware::Next, + response::Response, +}; +use crank_runtime::RateLimitRejection; + +use crate::{auth::SESSION_COOKIE_NAME, error::ApiError, state::AppState}; + +pub async fn apply_api_rate_limit( + State(state): State, + request: Request, + next: Next, +) -> Result { + let key = rate_limit_key(request.headers(), request.uri().path()); + if let Err(rejection) = state.api_rate_limiter.check(&key).await { + return Err(ApiError::rate_limited_with_context( + "request rate limit exceeded", + rejection_context(rejection), + )); + } + + Ok(next.run(request).await) +} + +fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value { + serde_json::json!({ + "retry_after_ms": rejection.retry_after_ms, + }) +} + +fn rate_limit_key(headers: &HeaderMap, path: &str) -> String { + if let Some(session_id) = session_id_from_headers(headers) { + return format!("session:{session_id}"); + } + + if let Some(forwarded_for) = header_value(headers, "x-forwarded-for") { + let ip = forwarded_for + .split(',') + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown"); + return format!("ip:{ip}"); + } + + if let Some(real_ip) = header_value(headers, "x-real-ip") { + return format!("ip:{real_ip}"); + } + + format!("anonymous:{path}") +} + +fn session_id_from_headers(headers: &HeaderMap) -> Option { + 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()); + } + } + + None +} + +fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> { + headers.get(name)?.to_str().ok().map(str::trim) +} + +#[cfg(test)] +mod tests { + use axum::http::{HeaderMap, HeaderValue, header::COOKIE}; + + use super::rate_limit_key; + + #[test] + fn keys_by_session_cookie_first() { + let mut headers = HeaderMap::new(); + headers.insert( + COOKIE, + HeaderValue::from_static("theme=dark; crank_session=sess_123.secret_456"), + ); + headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5")); + + assert_eq!( + rate_limit_key(&headers, "/api/auth/login"), + "session:sess_123" + ); + } + + #[test] + fn falls_back_to_forwarded_ip() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static("10.0.0.5, 10.0.0.6"), + ); + + assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5"); + } +} diff --git a/apps/admin-api/src/request_context.rs b/apps/admin-api/src/request_context.rs new file mode 100644 index 0000000..cda184e --- /dev/null +++ b/apps/admin-api/src/request_context.rs @@ -0,0 +1,165 @@ +use axum::{ + extract::Request, + http::{HeaderMap, HeaderName, HeaderValue}, + middleware::Next, + response::Response, +}; +use tracing::info; +use uuid::Uuid; + +pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id"); +const MAX_REQUEST_ID_LEN: usize = 128; + +#[derive(Clone, Debug)] +pub struct RequestContext { + pub request_id: String, +} + +pub async fn apply_request_context(mut request: Request, next: Next) -> Response { + let context = RequestContext { + request_id: resolve_request_id(request.headers()), + }; + let method = request.method().clone(); + let path = request.uri().path().to_owned(); + request.extensions_mut().insert(context.clone()); + + let mut response = next.run(request).await; + info!( + request_id = %context.request_id, + method = %method, + path, + status = response.status().as_u16(), + "admin request completed" + ); + if let Ok(value) = HeaderValue::from_str(&context.request_id) { + response.headers_mut().insert(REQUEST_ID_HEADER, value); + } + response +} + +fn resolve_request_id(headers: &HeaderMap) -> String { + headers + .get(&REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| is_valid_request_id(value)) + .map(ToOwned::to_owned) + .unwrap_or_else(|| Uuid::now_v7().to_string()) +} + +fn is_valid_request_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_REQUEST_ID_LEN + && value + .bytes() + .all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';') +} + +#[cfg(test)] +mod tests { + use std::io; + use std::sync::{Arc, Mutex}; + + use axum::{Router, routing::get}; + use reqwest::Client; + use tokio::net::TcpListener; + use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*}; + + use super::{REQUEST_ID_HEADER, apply_request_context, is_valid_request_id}; + + #[test] + fn accepts_visible_ascii_request_ids() { + assert!(is_valid_request_id("req_test_123")); + assert!(is_valid_request_id("trace-123/abc")); + } + + #[test] + fn rejects_empty_or_control_request_ids() { + assert!(!is_valid_request_id("")); + assert!(!is_valid_request_id("bad value")); + assert!(!is_valid_request_id("bad\nvalue")); + } + + #[derive(Clone, Default)] + struct SharedLogWriter { + buffer: Arc>>, + } + + 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>>, + } + + impl io::Write for SharedLogGuard { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.buffer.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn logs_request_completion_with_request_id() { + let writer = SharedLogWriter::default(); + let subscriber = tracing_subscriber::registry().with( + tracing_subscriber::fmt::layer() + .with_writer(writer.clone()) + .without_time() + .with_ansi(false) + .with_target(false) + .compact() + .with_filter(LevelFilter::INFO), + ); + let dispatch = tracing::Dispatch::new(subscriber); + let app = Router::new() + .route("/probe", get(|| async { "ok" })) + .layer(axum::middleware::from_fn(apply_request_context)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + let _guard = tracing::dispatcher::set_default(&dispatch); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let response = Client::new() + .get(format!("http://{address}/probe")) + .header(REQUEST_ID_HEADER.as_str(), "req_admin_trace_123") + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response.headers()[REQUEST_ID_HEADER.as_str()] + .to_str() + .unwrap(), + "req_admin_trace_123" + ); + + let logs = writer.output(); + assert!(logs.contains("admin request completed")); + assert!(logs.contains("req_admin_trace_123")); + assert!(logs.contains("GET")); + assert!(logs.contains("/probe")); + assert!(logs.contains("status=200")); + } +} diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs new file mode 100644 index 0000000..a86d765 --- /dev/null +++ b/apps/admin-api/src/routes.rs @@ -0,0 +1,21 @@ +pub mod access; +pub mod agents; +pub mod auth; +pub mod auth_profiles; +pub mod capabilities; +pub mod machine_auth; +pub mod observability; +pub mod operations; +pub mod secrets; +pub mod streaming; +pub mod workspaces; + +use axum::Json; +use serde_json::json; + +pub async fn health() -> Json { + Json(json!({ + "service": "admin-api", + "status": "ok" + })) +} diff --git a/apps/admin-api/src/routes/access.rs b/apps/admin-api/src/routes/access.rs new file mode 100644 index 0000000..51a74f1 --- /dev/null +++ b/apps/admin-api/src/routes/access.rs @@ -0,0 +1,287 @@ +use axum::{ + Extension, Json, + extract::{Path, State}, + http::StatusCode, +}; +use crank_core::{ + AuditActor, AuditEvent, AuditEventId, AuditTarget, AuditTargetKind, PolicyAction, + PolicyDecision, PolicyScope, SessionActor, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + auth::AuthenticatedSession, + error::ApiError, + service::{InvitationPayload, UpdateMembershipPayload}, + state::AppState, +}; + +#[derive(Deserialize)] +pub struct WorkspacePath { + pub workspace_id: String, +} + +#[derive(Deserialize)] +pub struct WorkspaceInvitationPath { + pub workspace_id: String, + pub invitation_id: String, +} + +#[derive(Deserialize)] +pub struct WorkspaceMembershipPath { + pub workspace_id: String, + pub user_id: String, +} + +pub async fn list_memberships( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_memberships(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn update_membership( + Path(path): Path, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); + enforce_workspace_policy( + &state, + &session, + &workspace_id, + PolicyAction::WriteWorkspaceAccess, + )?; + let items = state + .service + .update_membership_role( + &workspace_id, + &session.user.id, + &path.user_id.as_str().into(), + payload, + ) + .await?; + record_access_audit( + &state, + &session, + &workspace_id, + "membership.role_updated", + AuditTargetKind::Membership, + path.user_id.clone(), + json!({ "user_id": path.user_id }), + ) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn delete_membership( + Path(path): Path, + State(state): State, + Extension(session): Extension, +) -> Result { + let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); + enforce_workspace_policy( + &state, + &session, + &workspace_id, + PolicyAction::WriteWorkspaceAccess, + )?; + state + .service + .remove_membership( + &workspace_id, + &session.user.id, + &path.user_id.as_str().into(), + ) + .await?; + record_access_audit( + &state, + &session, + &workspace_id, + "membership.removed", + AuditTargetKind::Membership, + path.user_id.clone(), + json!({ "user_id": path.user_id }), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn list_invitations( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_invitations(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_invitation( + Path(path): Path, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); + enforce_workspace_policy( + &state, + &session, + &workspace_id, + PolicyAction::WriteWorkspaceAccess, + )?; + let created = state + .service + .create_invitation(&workspace_id, payload) + .await?; + record_access_audit( + &state, + &session, + &workspace_id, + "invitation.created", + AuditTargetKind::Invitation, + created.invitation.invitation.id.as_str().to_owned(), + json!({ "invitation_id": created.invitation.invitation.id.as_str() }), + ) + .await?; + Ok(Json(json!(created))) +} + +pub async fn delete_invitation( + Path(path): Path, + State(state): State, + Extension(session): Extension, +) -> Result { + let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); + enforce_workspace_policy( + &state, + &session, + &workspace_id, + PolicyAction::WriteWorkspaceAccess, + )?; + state + .service + .delete_invitation(&workspace_id, &path.invitation_id.as_str().into()) + .await?; + record_access_audit( + &state, + &session, + &workspace_id, + "invitation.deleted", + AuditTargetKind::Invitation, + path.invitation_id.clone(), + json!({ "invitation_id": path.invitation_id }), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn export_workspace( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let exported = state + .service + .export_workspace(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!(exported))) +} + +pub async fn delete_workspace( + Path(path): Path, + State(state): State, + Extension(session): Extension, +) -> Result { + let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); + enforce_workspace_policy( + &state, + &session, + &workspace_id, + PolicyAction::WriteWorkspace, + )?; + state + .service + .delete_workspace(&workspace_id, &session.user.id) + .await?; + record_access_audit( + &state, + &session, + &workspace_id, + "workspace.deleted", + AuditTargetKind::Workspace, + workspace_id.as_str().to_owned(), + json!({ "workspace_id": workspace_id.as_str() }), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +fn enforce_workspace_policy( + state: &AppState, + session: &AuthenticatedSession, + workspace_id: &crank_core::WorkspaceId, + action: PolicyAction, +) -> Result<(), ApiError> { + let membership = session + .memberships + .iter() + .find(|membership| membership.workspace.id == *workspace_id) + .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; + let actor = SessionActor { + user_id: session.user.id.clone(), + workspace_id: workspace_id.clone(), + role: membership.role, + }; + + match state.service.policy_engine().check( + &actor, + action, + PolicyScope::Workspace(workspace_id.clone()), + ) { + PolicyDecision::Allow => Ok(()), + PolicyDecision::Deny { reason } => Err(ApiError::forbidden(reason)), + } +} + +async fn record_access_audit( + state: &AppState, + session: &AuthenticatedSession, + workspace_id: &crank_core::WorkspaceId, + action: &str, + target_kind: AuditTargetKind, + target_id: String, + payload: Value, +) -> Result<(), ApiError> { + state + .service + .audit_sink() + .record(AuditEvent { + id: AuditEventId::new(format!("audit_{}", Uuid::now_v7().simple())), + occurred_at: OffsetDateTime::now_utc(), + actor: AuditActor { + user_id: session.user.id.clone(), + email: session.user.email.clone(), + session_id: Some(session.session_id.clone()), + }, + action: action.to_owned(), + target: AuditTarget { + workspace_id: workspace_id.clone(), + kind: target_kind, + id: target_id, + }, + payload, + source_ip: None, + user_agent: None, + }) + .await + .map_err(|error| ApiError::internal(format!("failed to record audit event: {error}"))) +} diff --git a/apps/admin-api/src/routes/agents.rs b/apps/admin-api/src/routes/agents.rs new file mode 100644 index 0000000..faa7f69 --- /dev/null +++ b/apps/admin-api/src/routes/agents.rs @@ -0,0 +1,242 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{ + error::ApiError, + service::{ + AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload, + UpdateAgentPayload, + }, + state::AppState, +}; + +#[derive(Deserialize)] +pub struct WorkspacePath { + pub workspace_id: String, +} + +#[derive(Deserialize)] +pub struct WorkspaceAgentPath { + pub workspace_id: String, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct WorkspaceAgentVersionPath { + pub workspace_id: String, + pub agent_id: String, + pub version: u32, +} + +#[derive(Deserialize)] +pub struct WorkspaceAgentPlatformApiKeyPath { + pub workspace_id: String, + pub agent_id: String, + pub key_id: String, +} + +pub async fn list_agents( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_agents(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_agent( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let created = state + .service + .create_agent(&path.workspace_id.as_str().into(), payload) + .await?; + Ok(Json(json!(created))) +} + +pub async fn get_agent( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let agent = state + .service + .get_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + ) + .await?; + Ok(Json(json!(agent))) +} + +pub async fn update_agent( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let updated = state + .service + .update_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(updated))) +} + +pub async fn delete_agent( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let deleted = state + .service + .delete_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + ) + .await?; + Ok(Json(json!(deleted))) +} + +pub async fn get_agent_version( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let version = state + .service + .get_agent_version( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + path.version, + ) + .await?; + Ok(Json(json!(version))) +} + +pub async fn save_agent_bindings( + Path(path): Path, + State(state): State, + Json(payload): Json>, +) -> Result, ApiError> { + let record = state + .service + .save_agent_bindings( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(record))) +} + +pub async fn publish_agent( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let published = state + .service + .publish_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + payload.version, + ) + .await?; + Ok(Json(json!(published))) +} + +pub async fn unpublish_agent( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let updated = state + .service + .unpublish_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + ) + .await?; + Ok(Json(json!(updated))) +} + +pub async fn archive_agent( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let updated = state + .service + .archive_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + ) + .await?; + Ok(Json(json!(updated))) +} + +pub async fn list_agent_platform_api_keys( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_agent_platform_api_keys( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + ) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_agent_platform_api_key( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let created = state + .service + .create_agent_platform_api_key( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(created))) +} + +pub async fn revoke_agent_platform_api_key( + Path(path): Path, + State(state): State, +) -> Result { + state + .service + .revoke_agent_platform_api_key( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + &path.key_id.as_str().into(), + ) + .await?; + Ok(axum::http::StatusCode::NO_CONTENT) +} + +pub async fn delete_agent_platform_api_key( + Path(path): Path, + State(state): State, +) -> Result { + state + .service + .delete_agent_platform_api_key( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + &path.key_id.as_str().into(), + ) + .await?; + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/apps/admin-api/src/routes/auth.rs b/apps/admin-api/src/routes/auth.rs new file mode 100644 index 0000000..7cfb6a7 --- /dev/null +++ b/apps/admin-api/src/routes/auth.rs @@ -0,0 +1,109 @@ +use axum::{Extension, Json, extract::State, http::StatusCode, response::IntoResponse}; +use axum_extra::extract::cookie::CookieJar; +use serde_json::json; + +use crate::{ + auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie}, + error::ApiError, + service::{ + ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload, + }, + state::AppState, +}; + +pub async fn login( + State(state): State, + jar: CookieJar, + Json(payload): Json, +) -> Result { + let (session_data, session) = state.service.login(payload).await?; + let cookie_value = format!( + "{}.{}", + session_data.session_id.as_str(), + session_data.value + ); + let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value)); + + Ok((jar, Json(serde_json::json!(session)))) +} + +pub async fn logout( + State(state): State, + jar: CookieJar, +) -> Result { + if let Some((session_id, session_value)) = extract_session_token(&jar) { + state.service.logout(&session_id, &session_value).await?; + } + + let jar = jar.remove(cleared_session_cookie(state.service.auth_settings())); + Ok((jar, StatusCode::NO_CONTENT)) +} + +pub async fn get_session( + State(state): State, + jar: CookieJar, +) -> Result, ApiError> { + let (session_id, session_value) = extract_session_token(&jar) + .ok_or_else(|| ApiError::unauthorized("authentication required"))?; + let session = state + .service + .session_response(&session_id, &session_value) + .await? + .ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?; + + Ok(Json(serde_json::json!(session))) +} + +pub async fn get_profile( + Extension(session): Extension, +) -> Result, ApiError> { + Ok(Json(json!({ + "user": session.user, + "memberships": session.memberships, + "current_workspace_id": session.current_workspace_id + }))) +} + +pub async fn update_profile( + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let updated = state + .service + .update_profile( + &session.user.id, + session.current_workspace_id.as_ref(), + payload, + ) + .await?; + Ok(Json(json!(updated))) +} + +pub async fn update_current_workspace( + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let updated = state + .service + .set_current_workspace( + &session.session_id, + &session.user.id, + &payload.workspace_id.as_str().into(), + ) + .await?; + Ok(Json(json!(updated))) +} + +pub async fn change_password( + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result { + state + .service + .change_password(&session.user.id, payload) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/admin-api/src/routes/auth_profiles.rs b/apps/admin-api/src/routes/auth_profiles.rs new file mode 100644 index 0000000..669d228 --- /dev/null +++ b/apps/admin-api/src/routes/auth_profiles.rs @@ -0,0 +1,56 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{error::ApiError, service::AuthProfilePayload, state::AppState}; + +#[derive(Deserialize)] +pub struct WorkspacePath { + pub workspace_id: String, +} + +#[derive(Deserialize)] +pub struct WorkspaceAuthProfilePath { + pub workspace_id: String, + pub auth_profile_id: String, +} + +pub async fn list_auth_profiles( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_auth_profiles(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_auth_profile( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let profile = state + .service + .create_auth_profile(&path.workspace_id.as_str().into(), payload) + .await?; + Ok(Json(json!(profile))) +} + +pub async fn get_auth_profile( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let profile = state + .service + .get_auth_profile( + &path.workspace_id.as_str().into(), + &path.auth_profile_id.as_str().into(), + ) + .await?; + Ok(Json(json!(profile))) +} diff --git a/apps/admin-api/src/routes/capabilities.rs b/apps/admin-api/src/routes/capabilities.rs new file mode 100644 index 0000000..aa97bbb --- /dev/null +++ b/apps/admin-api/src/routes/capabilities.rs @@ -0,0 +1,8 @@ +use axum::{Json, extract::State}; +use serde_json::json; + +use crate::state::AppState; + +pub async fn get_capabilities(State(state): State) -> Json { + Json(json!(state.service.capability_profile().capabilities())) +} diff --git a/apps/admin-api/src/routes/machine_auth.rs b/apps/admin-api/src/routes/machine_auth.rs new file mode 100644 index 0000000..4c88d52 --- /dev/null +++ b/apps/admin-api/src/routes/machine_auth.rs @@ -0,0 +1,124 @@ +use axum::{Json, extract::State}; +use crank_core::{ + IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MachineAccessMode, MembershipRole, + TokenIssuerActor, TokenIssuerError, UserId, WorkspaceId, +}; +use serde_json::json; + +use crate::{error::ApiError, state::AppState}; + +pub async fn issue_agent_token( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let edition = state.service.capability_profile().capabilities().edition; + let grant_type = payload.grant_type.clone(); + let response = state + .service + .token_issuer() + .issue_short_lived(payload, &community_token_issuer_actor()) + .await + .map_err(|error| { + map_token_issuer_error( + error, + edition, + MachineAccessMode::ShortLivedToken, + json!({ + "grant_type": grant_type, + "upgrade_required": true, + }), + ) + })?; + Ok(Json(serde_json::json!(response))) +} + +pub async fn issue_one_time_agent_token( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let edition = state.service.capability_profile().capabilities().edition; + let operation_id = payload.operation_id.as_str().to_owned(); + let response = state + .service + .token_issuer() + .issue_one_time(payload, &community_token_issuer_actor()) + .await + .map_err(|error| { + map_token_issuer_error( + error, + edition, + MachineAccessMode::OneTimeToken, + json!({ + "operation_id": operation_id, + "upgrade_required": true, + }), + ) + })?; + Ok(Json(serde_json::json!(response))) +} + +fn community_token_issuer_actor() -> TokenIssuerActor { + TokenIssuerActor { + user_id: UserId::new("user_community_public"), + workspace_id: WorkspaceId::new("ws_community_public"), + role: MembershipRole::Viewer, + } +} + +fn map_token_issuer_error( + error: TokenIssuerError, + edition: crank_core::ProductEdition, + machine_access_mode: MachineAccessMode, + extra_context: serde_json::Value, +) -> ApiError { + match error { + TokenIssuerError::NotSupportedInEdition => ApiError::forbidden_with_context( + match machine_access_mode { + MachineAccessMode::ShortLivedToken => { + "short-lived machine access is not available in Community" + } + MachineAccessMode::OneTimeToken => { + "one-time machine access is not available in Community" + } + MachineAccessMode::StaticAgentKey => { + "static agent key machine access is not available for token issue" + } + }, + merge_machine_auth_context(edition, machine_access_mode, extra_context), + ), + TokenIssuerError::InvalidGrant(reason) => ApiError::validation_with_context( + "invalid machine token grant", + json!({ "reason": reason }), + ), + TokenIssuerError::AgentKeyUnknown => ApiError::validation("agent key is unknown"), + TokenIssuerError::OperationNotStrict => ApiError::validation("operation is not strict"), + TokenIssuerError::OperationNotPublishedForAgent => { + ApiError::validation("operation is not published for agent") + } + TokenIssuerError::RegistryFailure(details) => { + ApiError::internal(format!("token issuer registry failure: {details}")) + } + TokenIssuerError::ReplayGuardFailure(details) => { + ApiError::internal(format!("token issuer replay guard failure: {details}")) + } + } +} + +fn merge_machine_auth_context( + edition: crank_core::ProductEdition, + machine_access_mode: MachineAccessMode, + extra_context: serde_json::Value, +) -> serde_json::Value { + let mut context = json!({ + "edition": edition, + "machine_access_mode": machine_access_mode, + }); + + if let (Some(base), Some(extra)) = (context.as_object_mut(), extra_context.as_object()) { + for (key, value) in extra { + base.insert(key.clone(), value.clone()); + } + } + + context +} diff --git a/apps/admin-api/src/routes/observability.rs b/apps/admin-api/src/routes/observability.rs new file mode 100644 index 0000000..dfa6329 --- /dev/null +++ b/apps/admin-api/src/routes/observability.rs @@ -0,0 +1,100 @@ +use axum::{ + Json, + extract::{Path, Query, State}, +}; +use serde_json::{Value, json}; + +use crate::{ + error::ApiError, + routes::access::WorkspacePath, + service::{LogsQuery, UsageRequestQuery}, + state::AppState, +}; + +#[derive(serde::Deserialize)] +pub struct WorkspaceLogPath { + pub workspace_id: String, + pub log_id: String, +} + +#[derive(serde::Deserialize)] +pub struct WorkspaceOperationUsagePath { + pub workspace_id: String, + pub operation_id: String, +} + +#[derive(serde::Deserialize)] +pub struct WorkspaceAgentUsagePath { + pub workspace_id: String, + pub agent_id: String, +} + +pub async fn list_logs( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_logs(&path.workspace_id.as_str().into(), query) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn get_log( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let item = state + .service + .get_log( + &path.workspace_id.as_str().into(), + &path.log_id.as_str().into(), + ) + .await?; + Ok(Json(json!(item))) +} + +pub async fn get_usage( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, ApiError> { + let usage = state + .service + .get_usage_overview(&path.workspace_id.as_str().into(), query) + .await?; + Ok(Json(json!(usage))) +} + +pub async fn get_operation_usage( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, ApiError> { + let usage = state + .service + .get_operation_usage( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + query, + ) + .await?; + Ok(Json(json!(usage))) +} + +pub async fn get_agent_usage( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, ApiError> { + let usage = state + .service + .get_agent_usage( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + query, + ) + .await?; + Ok(Json(json!(usage))) +} diff --git a/apps/admin-api/src/routes/operations.rs b/apps/admin-api/src/routes/operations.rs new file mode 100644 index 0000000..49d6b4c --- /dev/null +++ b/apps/admin-api/src/routes/operations.rs @@ -0,0 +1,283 @@ +use axum::{ + Json, + extract::{Extension, Path, Query, State}, + http::{HeaderMap, StatusCode, header}, + response::IntoResponse, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{ + error::ApiError, + request_context::RequestContext, + service::{ + ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload, + PublishPayload, TestRunPayload, UpdateOperationPayload, + }, + state::AppState, +}; + +#[derive(Deserialize)] +pub struct WorkspacePath { + pub workspace_id: String, +} + +#[derive(Deserialize)] +pub struct WorkspaceOperationPath { + pub workspace_id: String, + pub operation_id: String, +} + +#[derive(Deserialize)] +pub struct WorkspaceOperationVersionPath { + pub workspace_id: String, + pub operation_id: String, + pub version: u32, +} + +pub async fn list_operations( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_operations(&path.workspace_id.as_str().into()) + .await?; + let total = items.len(); + Ok(Json(json!({ + "items": items, + "page": 1, + "page_size": total, + "total": total + }))) +} + +pub async fn create_operation( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let created = state + .service + .create_operation(&path.workspace_id.as_str().into(), payload) + .await?; + Ok(Json(json!(created))) +} + +pub async fn get_operation( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let operation = state + .service + .get_operation( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + ) + .await?; + Ok(Json(json!(operation))) +} + +pub async fn update_operation( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let result = state + .service + .update_operation( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(result))) +} + +pub async fn delete_operation( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let result = state + .service + .delete_operation( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + ) + .await?; + Ok(Json(json!(result))) +} + +pub async fn get_operation_version( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let version = state + .service + .get_operation_version( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + path.version, + ) + .await?; + Ok(Json(json!(version))) +} + +pub async fn create_version( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let created = state + .service + .create_version( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(created))) +} + +pub async fn publish_operation( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let published = state + .service + .publish_operation( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + payload.version, + ) + .await?; + Ok(Json(json!(published))) +} + +pub async fn archive_operation( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let archived = state + .service + .archive_operation( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + ) + .await?; + Ok(Json(json!(archived))) +} + +pub async fn run_test( + Path(path): Path, + State(state): State, + Extension(request_context): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let result = state + .service + .run_test( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + payload, + &request_context.request_id, + ) + .await?; + Ok(Json(json!(result))) +} + +pub async fn upload_input_json( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let sample = state + .service + .save_json_sample( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + crank_registry::SampleKind::InputJson, + &payload, + ) + .await?; + + Ok(Json(json!({ + "sample_id": sample.id.as_str(), + "sample_kind": sample.sample_kind, + "version": sample.version + }))) +} + +pub async fn upload_output_json( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let sample = state + .service + .save_json_sample( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + crank_registry::SampleKind::OutputJson, + &payload, + ) + .await?; + + Ok(Json(json!({ + "sample_id": sample.id.as_str(), + "sample_kind": sample.sample_kind, + "version": sample.version + }))) +} + +pub async fn generate_draft( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let draft = state + .service + .generate_draft( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(draft))) +} + +pub async fn export_operation( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result { + let yaml = state + .service + .export_operation( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + query, + ) + .await?; + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/yaml"), + ); + + Ok((StatusCode::OK, headers, yaml)) +} + +pub async fn import_operation( + Path(path): Path, + Query(query): Query, + State(state): State, + body: String, +) -> Result, ApiError> { + let imported = state + .service + .import_operation(&path.workspace_id.as_str().into(), query, &body) + .await?; + Ok(Json(json!(imported))) +} diff --git a/apps/admin-api/src/routes/secrets.rs b/apps/admin-api/src/routes/secrets.rs new file mode 100644 index 0000000..4bb1817 --- /dev/null +++ b/apps/admin-api/src/routes/secrets.rs @@ -0,0 +1,98 @@ +use axum::{ + Extension, Json, + extract::{Path, State}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{ + auth::AuthenticatedSession, + error::ApiError, + service::{RotateSecretPayload, SecretPayload}, + state::AppState, +}; + +#[derive(Deserialize)] +pub struct WorkspacePath { + pub workspace_id: String, +} + +#[derive(Deserialize)] +pub struct WorkspaceSecretPath { + pub workspace_id: String, + pub secret_id: String, +} + +pub async fn list_secrets( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_secrets(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_secret( + Path(path): Path, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let secret = state + .service + .create_secret( + &path.workspace_id.as_str().into(), + Some(&session.user.id), + payload, + ) + .await?; + Ok(Json(json!(secret))) +} + +pub async fn get_secret( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let secret = state + .service + .get_secret( + &path.workspace_id.as_str().into(), + &path.secret_id.as_str().into(), + ) + .await?; + Ok(Json(json!(secret))) +} + +pub async fn rotate_secret( + Path(path): Path, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let secret = state + .service + .rotate_secret( + &path.workspace_id.as_str().into(), + &path.secret_id.as_str().into(), + Some(&session.user.id), + payload, + ) + .await?; + Ok(Json(json!(secret))) +} + +pub async fn delete_secret( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + state + .service + .delete_secret( + &path.workspace_id.as_str().into(), + &path.secret_id.as_str().into(), + ) + .await?; + Ok(Json(json!({ "ok": true }))) +} diff --git a/apps/admin-api/src/routes/streaming.rs b/apps/admin-api/src/routes/streaming.rs new file mode 100644 index 0000000..319a348 --- /dev/null +++ b/apps/admin-api/src/routes/streaming.rs @@ -0,0 +1,16 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use serde_json::{Value, json}; + +use crate::{error::ApiError, routes::access::WorkspacePath, state::AppState}; + +pub async fn list_protocol_capabilities( + Path(_path): Path, + State(state): State, +) -> Result, ApiError> { + Ok(Json(json!({ + "items": state.service.list_protocol_capabilities().await + }))) +} diff --git a/apps/admin-api/src/routes/workspaces.rs b/apps/admin-api/src/routes/workspaces.rs new file mode 100644 index 0000000..f75cfb4 --- /dev/null +++ b/apps/admin-api/src/routes/workspaces.rs @@ -0,0 +1,58 @@ +use axum::{ + Extension, Json, + extract::{Path, State}, +}; +use serde_json::{Value, json}; + +use crate::{ + auth::AuthenticatedSession, + error::ApiError, + service::{UpdateWorkspacePayload, WorkspacePayload}, + state::AppState, +}; + +pub async fn list_workspaces( + State(state): State, + Extension(session): Extension, +) -> Result, ApiError> { + let items = state + .service + .list_workspaces_for_user(&session.user.id) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_workspace( + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let workspace = state + .service + .create_workspace(&session.user.id, payload) + .await?; + Ok(Json(json!(workspace))) +} + +pub async fn get_workspace( + Path(workspace_id): Path, + State(state): State, +) -> Result, ApiError> { + let workspace = state + .service + .get_workspace(&workspace_id.as_str().into()) + .await?; + Ok(Json(json!(workspace))) +} + +pub async fn update_workspace( + Path(workspace_id): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let workspace = state + .service + .update_workspace(&workspace_id.as_str().into(), payload) + .await?; + Ok(Json(json!(workspace))) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs new file mode 100644 index 0000000..06ae6e2 --- /dev/null +++ b/apps/admin-api/src/service.rs @@ -0,0 +1,4506 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use crank_core::{ + Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode, + AsyncJobHandle, AuditSink, AuthConfig, AuthKind, AuthProfile, AuthProfileId, CapabilityProfile, + CommunityCapabilityProfile, ConfigExport, EditionCapabilities, ExecutionMode, ExportMode, + GeneratedDraft, GeneratedDraftStatus, IdentityError, IdentityProvider, InvitationId, + InvitationStatus, InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, + InvocationSource, InvocationStatus, JobStatus, LoginOutcome, MachineTokenIssuer, + MembershipRole, NoMachineTokenIssuer, NoopAuditSink, OperationId, OperationSecurityLevel, + OperationStatus, OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, + PlatformApiKeyStatus, PolicyEngine, ProductEdition, Protocol, ResponseCachePolicy, SampleId, + Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus, Target, + TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus, +}; +use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; +use crank_registry::{ + AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateAsyncJobRequest, + CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, + CreateSecretRequest, CreateStreamSessionRequest, CreateVersionRequest, CreateWorkspaceRequest, + InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, + OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, + OperationVersionRecord, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, + PublishRequest, RegistryError, RegistryOperation, RotateSecretRequest, SampleKind, + SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveSampleMetadataRequest, + UpdateAsyncJobStatusRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, + UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, + WorkspaceMembershipRecord, WorkspaceRecord, +}; +use crank_runtime::{ + PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, RuntimeOperation, + RuntimeRequestContext, SecretCrypto, +}; +use crank_schema::Schema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tracing::{info, instrument}; +use uuid::Uuid; + +use crate::{ + auth::{ + AuthSettings, AuthenticatedSession, SessionCookie, create_session_cookie, hash_password, + hash_session_secret, verify_password, + }, + error::ApiError, + storage::LocalArtifactStorage, +}; + +#[derive(Clone)] +pub struct AdminService { + registry: PostgresRegistry, + runtime: RuntimeExecutor, + storage: LocalArtifactStorage, + auth_settings: AuthSettings, + secret_crypto: SecretCrypto, + identity_provider: Option>, + policy_engine: Arc, + audit_sink: Arc, + token_issuer: Arc, + capability_profile: Arc, +} + +pub struct AdminServiceBuilder { + registry: PostgresRegistry, + storage_root: PathBuf, + auth_settings: AuthSettings, + secret_crypto: SecretCrypto, + runtime: RuntimeExecutor, + identity_provider: Option>, + policy_engine: Option>, + audit_sink: Option>, + token_issuer: Option>, + capability_profile: Option>, +} + +#[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, + pub current_workspace_id: Option, +} + +#[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, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct NewVersionPayload { + #[serde(flatten)] + pub operation: OperationPayload, + #[serde(default)] + pub change_note: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_session: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub async_job: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WindowTestRunView { + pub window_complete: bool, + pub truncated: bool, + pub has_more: bool, + pub cursor: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StreamSessionStartView { + pub session_id: String, + pub status: StreamStatus, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: OffsetDateTime, + pub poll_after_ms: u64, + pub preview: Value, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AsyncJobStartView { + pub job_id: String, + pub status: JobStatus, + pub progress: Value, +} + +#[derive(Debug, Serialize, Deserialize)] +struct StoredSessionState { + input: Value, + summary: Value, + items: Vec, + next_index: usize, + batch_size: usize, +} + +enum TestRunOutcome { + Unary { + output: Value, + }, + Window { + output: crank_runtime::WindowExecutionResult, + }, + Session { + output: StreamSessionStartView, + }, + AsyncJob { + output: AsyncJobStartView, + }, +} + +impl TestRunOutcome { + fn into_result_views( + self, + ) -> ( + &'static str, + Value, + Option, + Option, + Option, + ) { + match self { + Self::Unary { output } => ("admin test run completed", output, None, None, None), + Self::Window { output } => ( + "admin window test run completed", + json!({ + "summary": output.summary, + "items": output.items, + "cursor": output.cursor, + "window_complete": output.window_complete, + "truncated": output.truncated, + "has_more": output.has_more, + }), + Some(WindowTestRunView { + window_complete: output.window_complete, + truncated: output.truncated, + has_more: output.has_more, + cursor: output.cursor, + }), + None, + None, + ), + Self::Session { output } => ( + "admin session test run started", + json!({ + "session_id": output.session_id, + "status": output.status, + "expires_at": format_timestamp(output.expires_at), + "poll_after_ms": output.poll_after_ms, + "preview": output.preview, + }), + None, + Some(output), + None, + ), + Self::AsyncJob { output } => ( + "admin async job test run started", + json!({ + "job_id": output.job_id, + "status": output.status, + "progress": output.progress, + }), + None, + None, + Some(output), + ), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AuthProfilePayload { + pub name: String, + pub kind: AuthKind, + pub config: AuthConfig, +} + +#[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, + pub display_name: Option, + pub status: Option, + pub settings: Option, +} + +#[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, + #[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, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, + pub operation_count: usize, + pub operation_ids: Vec, + 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 InvitationPayload { + pub email: String, + pub role: MembershipRole, + pub expires_at: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct UpdateMembershipPayload { + pub role: MembershipRole, +} + +#[derive(Clone, Debug, Serialize)] +pub struct CreatedInvitationResponse { + pub invitation: InvitationRecord, + pub invite_token: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PlatformApiKeyPayload { + pub name: String, + pub scopes: Vec, +} + +#[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 memberships: Vec, + pub invitations: Vec, + pub operations: Vec, + pub agents: Vec, + pub platform_api_keys: Vec, + pub exported_at: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct LogsQuery { + pub level: Option, + pub search: Option, + pub source: Option, + pub operation_id: Option, + pub agent_id: Option, + pub period: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct UsageRequestQuery { + pub period: Option, + pub source: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct UsageOverviewResponse { + pub summary: UsageSummary, + pub timeline: Vec, + pub operations: Vec, + pub agents: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ProtocolCapabilityView { + pub protocol: Protocol, + pub supports_execution_modes: Vec, + pub supports_transport_behaviors: Vec, + pub supports_auth_kinds: Vec, + pub supports_upload_artifacts: Vec, + pub supports_cursor_path: bool, + pub supports_done_path: bool, + pub supports_aggregation_mode: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GenerateDraftPayload { + #[serde(default)] + pub sources: Vec, +} + +#[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, + #[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, 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, +} + +#[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, +} + +struct InvocationRecordRequest<'a> { + workspace_id: &'a WorkspaceId, + agent_id: Option<&'a AgentId>, + operation: &'a RegistryOperation, + request_id: Option<&'a str>, + source: InvocationSource, + level: InvocationLevel, + status: InvocationStatus, + message: String, + status_code: Option, + error_kind: Option, + duration_ms: u64, + request_preview: Value, + response_preview: Value, +} + +#[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, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, + pub usage_summary: OperationUsageSummaryView, + pub agent_refs: Vec, +} + +#[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, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, + pub draft_version_ref: VersionRef, + pub published_version_ref: Option, + pub agent_refs: Vec, +} + +#[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, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DescriptorUploadResponse { + pub descriptor_id: String, + pub version: u32, +} + +#[derive(Clone, Debug, Serialize)] +pub struct GrpcServiceSummary { + pub package: String, + pub service: String, + pub methods: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct GrpcMethodSummary { + pub name: String, + pub kind: String, + pub input_schema: Schema, + pub output_schema: Schema, +} + +fn default_operation_category() -> String { + "general".to_owned() +} + +impl AdminService { + #[cfg(test)] + pub fn new( + registry: PostgresRegistry, + storage_root: PathBuf, + auth_settings: AuthSettings, + secret_crypto: SecretCrypto, + ) -> Self { + Self::new_with_runtime( + registry, + storage_root, + auth_settings, + secret_crypto, + RuntimeExecutor::new(), + ) + } + + #[cfg(test)] + pub fn new_with_runtime( + registry: PostgresRegistry, + storage_root: PathBuf, + auth_settings: AuthSettings, + secret_crypto: SecretCrypto, + runtime: RuntimeExecutor, + ) -> Self { + AdminServiceBuilder::new( + registry, + storage_root, + auth_settings, + secret_crypto, + runtime, + ) + .build() + } + + pub fn auth_settings(&self) -> &AuthSettings { + &self.auth_settings + } + + pub fn policy_engine(&self) -> &Arc { + &self.policy_engine + } + + pub fn audit_sink(&self) -> &Arc { + &self.audit_sink + } + + pub fn token_issuer(&self) -> &Arc { + &self.token_issuer + } + + pub fn capability_profile(&self) -> &Arc { + &self.capability_profile + } +} + +impl AdminServiceBuilder { + pub fn new( + registry: PostgresRegistry, + storage_root: PathBuf, + auth_settings: AuthSettings, + secret_crypto: SecretCrypto, + runtime: RuntimeExecutor, + ) -> Self { + Self { + registry, + storage_root, + auth_settings, + secret_crypto, + runtime, + identity_provider: None, + policy_engine: None, + audit_sink: None, + token_issuer: None, + capability_profile: None, + } + } + + pub fn with_identity_provider(mut self, provider: Arc) -> Self { + self.identity_provider = Some(provider); + self + } + + #[allow(dead_code)] + pub fn with_policy_engine(mut self, policy_engine: Arc) -> Self { + self.policy_engine = Some(policy_engine); + self + } + + #[allow(dead_code)] + pub fn with_audit_sink(mut self, audit_sink: Arc) -> Self { + self.audit_sink = Some(audit_sink); + self + } + + #[allow(dead_code)] + pub fn with_token_issuer(mut self, token_issuer: Arc) -> Self { + self.token_issuer = Some(token_issuer); + self + } + + #[allow(dead_code)] + pub fn with_capability_profile( + mut self, + capability_profile: Arc, + ) -> Self { + self.capability_profile = Some(capability_profile); + self + } + + pub fn build(self) -> AdminService { + AdminService { + registry: self.registry, + runtime: self.runtime, + storage: LocalArtifactStorage::new(self.storage_root), + auth_settings: self.auth_settings, + secret_crypto: self.secret_crypto, + identity_provider: self.identity_provider, + policy_engine: self + .policy_engine + .unwrap_or_else(|| Arc::new(OwnerOnlyPolicyEngine)), + audit_sink: self.audit_sink.unwrap_or_else(|| Arc::new(NoopAuditSink)), + token_issuer: self + .token_issuer + .unwrap_or_else(|| Arc::new(NoMachineTokenIssuer)), + capability_profile: self + .capability_profile + .unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)), + } + } +} + +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?; + self.seed_growth_workspace_demo(&admin_user_id).await?; + + Ok(()) + } + + pub async fn get_session( + &self, + session_id: &UserSessionId, + session_value: &str, + ) -> Result, 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 current_workspace_id = 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 { + 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), + Ok(LoginOutcome::TwoFactorRequired(_)) => Err(ApiError::internal( + "two-factor login flow is not configured for this service", + )), + 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(()) + } + + pub async fn list_workspaces_for_user( + &self, + user_id: &crank_core::UserId, + ) -> Result, 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 { + 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 { + 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?; + + Ok(WorkspaceRecord { workspace }) + } + + #[instrument(skip(self))] + pub async fn session_response( + &self, + session_id: &UserSessionId, + session_value: &str, + ) -> Result, 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 { + let display_name = payload.display_name.trim(); + let email = payload.email.trim().to_ascii_lowercase(); + + if display_name.is_empty() { + return Err(ApiError::validation("display name is required")); + } + if email.is_empty() || !email.contains('@') { + return Err(ApiError::validation("a valid email address is required")); + } + + 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 set_current_workspace( + &self, + session_id: &UserSessionId, + user_id: &crank_core::UserId, + workspace_id: &WorkspaceId, + ) -> Result { + 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 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(()) + } + + pub async fn get_workspace( + &self, + workspace_id: &WorkspaceId, + ) -> Result { + 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 { + 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 }) + } + + #[instrument(skip(self))] + pub async fn list_memberships( + &self, + workspace_id: &WorkspaceId, + ) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + Ok(self.registry.list_memberships(workspace_id).await?) + } + + pub async fn update_membership_role( + &self, + workspace_id: &WorkspaceId, + actor_user_id: &crank_core::UserId, + target_user_id: &crank_core::UserId, + payload: UpdateMembershipPayload, + ) -> Result, ApiError> { + let memberships = self.list_memberships(workspace_id).await?; + let actor_membership = memberships + .iter() + .find(|membership| &membership.user.id == actor_user_id) + .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; + let target_membership = memberships + .iter() + .find(|membership| &membership.user.id == target_user_id) + .ok_or_else(|| { + ApiError::not_found_with_context( + format!( + "membership for user {} in workspace {} was not found", + target_user_id.as_str(), + workspace_id.as_str() + ), + json!({ + "workspace_id": workspace_id.as_str(), + "user_id": target_user_id.as_str(), + }), + ) + })?; + + if !matches!( + actor_membership.role, + MembershipRole::Owner | MembershipRole::Admin + ) { + return Err(ApiError::forbidden( + "only owners and admins can manage workspace members", + )); + } + + if matches!(target_membership.role, MembershipRole::Owner) + && !matches!(payload.role, MembershipRole::Owner) + { + let owner_count = memberships + .iter() + .filter(|membership| matches!(membership.role, MembershipRole::Owner)) + .count(); + if owner_count <= 1 { + return Err(ApiError::validation( + "workspace must keep at least one owner", + )); + } + } + + self.registry + .update_membership_role(workspace_id, target_user_id, payload.role) + .await?; + self.list_memberships(workspace_id).await + } + + pub async fn remove_membership( + &self, + workspace_id: &WorkspaceId, + actor_user_id: &crank_core::UserId, + target_user_id: &crank_core::UserId, + ) -> Result<(), ApiError> { + let memberships = self.list_memberships(workspace_id).await?; + let actor_membership = memberships + .iter() + .find(|membership| &membership.user.id == actor_user_id) + .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; + let target_membership = memberships + .iter() + .find(|membership| &membership.user.id == target_user_id) + .ok_or_else(|| { + ApiError::not_found_with_context( + format!( + "membership for user {} in workspace {} was not found", + target_user_id.as_str(), + workspace_id.as_str() + ), + json!({ + "workspace_id": workspace_id.as_str(), + "user_id": target_user_id.as_str(), + }), + ) + })?; + + if !matches!( + actor_membership.role, + MembershipRole::Owner | MembershipRole::Admin + ) { + return Err(ApiError::forbidden( + "only owners and admins can manage workspace members", + )); + } + + if matches!(target_membership.role, MembershipRole::Owner) { + let owner_count = memberships + .iter() + .filter(|membership| matches!(membership.role, MembershipRole::Owner)) + .count(); + if owner_count <= 1 { + return Err(ApiError::validation( + "workspace must keep at least one owner", + )); + } + } + + self.registry + .delete_membership(workspace_id, target_user_id) + .await?; + Ok(()) + } + + #[instrument(skip(self))] + pub async fn list_invitations( + &self, + workspace_id: &WorkspaceId, + ) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + Ok(self.registry.list_invitations(workspace_id).await?) + } + + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), email = %payload.email))] + pub async fn create_invitation( + &self, + workspace_id: &WorkspaceId, + payload: InvitationPayload, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + + let invite_token = generate_access_secret("invite"); + let invitation = InvitationRecord { + invitation: InvitationToken { + id: InvitationId::new(new_prefixed_id("inv")), + workspace_id: workspace_id.clone(), + email: payload.email, + role: payload.role, + status: InvitationStatus::Pending, + token_hash: hash_access_secret(&invite_token), + expires_at: match payload.expires_at { + Some(expires_at) => parse_timestamp(&expires_at)?, + None => default_invitation_expiry()?, + }, + created_at: OffsetDateTime::now_utc(), + }, + }; + + self.registry + .create_invitation(CreateInvitationRequest { + invitation: &invitation.invitation, + }) + .await?; + + Ok(CreatedInvitationResponse { + invitation, + invite_token, + }) + } + + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), invitation_id = %invitation_id.as_str()))] + pub async fn delete_invitation( + &self, + workspace_id: &WorkspaceId, + invitation_id: &InvitationId, + ) -> Result<(), ApiError> { + self.registry + .delete_invitation(workspace_id, invitation_id) + .await?; + Ok(()) + } + + pub async fn export_workspace( + &self, + workspace_id: &WorkspaceId, + ) -> Result { + let workspace = self.get_workspace(workspace_id).await?; + let memberships = self.list_memberships(workspace_id).await?; + let invitations = self + .list_invitations(workspace_id) + .await? + .into_iter() + .map(|record| { + json!({ + "id": record.invitation.id, + "email": record.invitation.email, + "role": record.invitation.role, + "status": record.invitation.status, + "expires_at": record.invitation.expires_at, + "created_at": record.invitation.created_at, + }) + }) + .collect(); + let operations = self.list_operations(workspace_id).await?; + let agents = self.list_agents(workspace_id).await?; + let platform_api_keys = self.registry.list_platform_api_keys(workspace_id).await?; + + Ok(WorkspaceExportResponse { + workspace, + memberships, + invitations, + operations, + agents, + platform_api_keys, + exported_at: now_string()?, + }) + } + + pub async fn delete_workspace( + &self, + workspace_id: &WorkspaceId, + actor_user_id: &crank_core::UserId, + ) -> Result<(), ApiError> { + let memberships = self.list_memberships(workspace_id).await?; + let actor_membership = memberships + .iter() + .find(|membership| &membership.user.id == actor_user_id) + .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; + + if !matches!(actor_membership.role, MembershipRole::Owner) { + return Err(ApiError::forbidden( + "only workspace owners can delete a workspace", + )); + } + + self.registry.delete_workspace(workspace_id).await?; + Ok(()) + } + + #[instrument(skip(self))] + pub async fn list_agent_platform_api_keys( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + ) -> Result, 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 { + 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() }), + ) + })?; + + let secret = generate_access_secret("crk"); + 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(), + scopes: payload.scopes, + status: PlatformApiKeyStatus::Active, + created_at: OffsetDateTime::now_utc(), + last_used_at: None, + }, + }; + + 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(()) + } + + #[instrument(skip(self))] + pub async fn list_logs( + &self, + workspace_id: &WorkspaceId, + query: LogsQuery, + ) -> Result, 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 { + 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 get_usage_overview( + &self, + workspace_id: &WorkspaceId, + query: UsageRequestQuery, + ) -> Result { + 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 { + 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 { + 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() }), + ) + }) + } + + pub async fn list_protocol_capabilities(&self) -> Vec { + let capabilities = self.get_capabilities().await; + capabilities + .supported_protocols + .into_iter() + .map(|protocol| protocol_capability_view(protocol, capabilities.edition)) + .collect() + } + + pub async fn get_capabilities(&self) -> EditionCapabilities { + self.capability_profile().capabilities() + } + + pub async fn list_operations( + &self, + workspace_id: &WorkspaceId, + ) -> Result, 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 { + 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 { + 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 { + 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, + }), + 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(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 { + 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, + }), + 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 { + 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, + 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 { + 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 { + 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 { + 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 { + 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 = runtime + .execution_config + .streaming + .as_ref() + .map(|streaming| streaming.mode) + .unwrap_or(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, + ))], + window: None, + stream_session: None, + async_job: None, + }); + } + }; + + 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) => match mode { + ExecutionMode::Unary => self + .runtime + .execute_with_auth_and_context( + &runtime, + &payload.input, + resolved_auth.as_ref(), + Some(&runtime_request_context), + ) + .await + .map(|output| TestRunOutcome::Unary { output }), + ExecutionMode::Window => self + .runtime + .execute_window_with_auth_and_context( + &runtime, + &payload.input, + resolved_auth.as_ref(), + Some(&runtime_request_context), + ) + .await + .map(|output| TestRunOutcome::Window { output }), + ExecutionMode::Session => self + .start_stream_session_test( + workspace_id, + &record.snapshot, + &runtime, + &payload.input, + resolved_auth.as_ref(), + &runtime_request_context, + ) + .await + .map(|output| TestRunOutcome::Session { output }), + ExecutionMode::AsyncJob => self + .start_async_job_test( + workspace_id, + &record.snapshot, + &runtime, + &payload.input, + &runtime_request_context, + ) + .await + .map(|output| TestRunOutcome::AsyncJob { output }), + }, + Err(error) => Err(error), + } { + Ok(outcome) => { + let duration_ms = + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + let (message, response_preview, window, stream_session, async_job) = + outcome.into_result_views(); + 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: message.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(), + window, + stream_session, + async_job, + }) + } + 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)], + window: None, + stream_session: None, + async_job: None, + }) + } + } + } + + async fn resolve_operation_auth( + &self, + workspace_id: &WorkspaceId, + execution_config: &crank_core::ExecutionConfig, + ) -> Result, RuntimeError> { + let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else { + return Ok(None); + }; + + let auth_profile = self + .registry + .get_auth_profile(workspace_id, auth_profile_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + operation: "load auth profile", + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingAuthProfile { + auth_profile_id: auth_profile_id.as_str().to_owned(), + })?; + + self.resolve_auth_profile(workspace_id, &auth_profile) + .await + .map(Some) + } + + async fn start_stream_session_test( + &self, + workspace_id: &WorkspaceId, + operation: &RegistryOperation, + runtime: &RuntimeOperation, + input: &Value, + resolved_auth: Option<&ResolvedAuth>, + request_context: &RuntimeRequestContext, + ) -> Result { + let streaming = runtime.execution_config.streaming.as_ref().ok_or_else(|| { + RuntimeError::MissingStreamingConfig { + operation_id: runtime.operation_id.as_str().to_owned(), + } + })?; + let seed = self + .runtime + .execute_session_seed_with_auth_and_context( + runtime, + input, + resolved_auth, + Some(request_context), + ) + .await?; + let batch_size = streaming.max_items.unwrap_or(10).max(1) as usize; + let preview_count = seed.items.len().min(batch_size); + let preview_items = seed.items[..preview_count].to_vec(); + let next_index = preview_count; + let created_at = OffsetDateTime::now_utc(); + let expires_at = created_at + .checked_add(time::Duration::milliseconds( + streaming.max_session_lifetime_ms.unwrap_or(60_000) as i64, + )) + .ok_or_else(|| RuntimeError::SecretCrypto { + operation: "compute stream session expiration", + details: "failed to compute stream session expiration".to_owned(), + })?; + let session = StreamSession { + id: crank_core::StreamSessionId::new(new_prefixed_id("sess")), + workspace_id: workspace_id.clone(), + agent_id: None, + operation_id: operation.id.clone(), + protocol: operation.protocol, + mode: ExecutionMode::Session, + status: StreamStatus::Running, + cursor: (next_index < seed.items.len()).then(|| json!(next_index)), + state: json!(StoredSessionState { + input: input.clone(), + summary: seed.summary.clone(), + items: seed.items.clone(), + next_index, + batch_size, + }), + expires_at, + last_poll_at: None, + created_at, + closed_at: None, + }; + self.registry + .create_stream_session(CreateStreamSessionRequest { session: &session }) + .await + .map_err(|error| RuntimeError::SecretCrypto { + operation: "persist stream session", + details: error.to_string(), + })?; + + Ok(StreamSessionStartView { + session_id: session.id.as_str().to_owned(), + status: session.status, + expires_at, + poll_after_ms: streaming.poll_interval_ms.unwrap_or(1_000), + preview: json!({ + "summary": seed.summary, + "items": preview_items, + }), + }) + } + + async fn start_async_job_test( + &self, + workspace_id: &WorkspaceId, + operation: &RegistryOperation, + runtime: &RuntimeOperation, + input: &Value, + request_context: &RuntimeRequestContext, + ) -> Result { + let created_at = OffsetDateTime::now_utc(); + let streaming = runtime.execution_config.streaming.as_ref().ok_or_else(|| { + RuntimeError::MissingStreamingConfig { + operation_id: runtime.operation_id.as_str().to_owned(), + } + })?; + let expires_at = created_at + .checked_add(time::Duration::milliseconds( + streaming.max_session_lifetime_ms.unwrap_or(300_000) as i64, + )) + .ok_or_else(|| RuntimeError::SecretCrypto { + operation: "compute async job expiration", + details: "failed to compute async job expiration".to_owned(), + })?; + let job = AsyncJobHandle { + id: crank_core::AsyncJobId::new(new_prefixed_id("job")), + workspace_id: workspace_id.clone(), + agent_id: None, + operation_id: operation.id.clone(), + status: JobStatus::Running, + progress: json!({ "pct": 0 }), + result: None, + error: None, + expires_at: Some(expires_at), + last_poll_at: None, + created_at, + updated_at: created_at, + finished_at: None, + }; + self.registry + .create_async_job(CreateAsyncJobRequest { job: &job }) + .await + .map_err(|error| RuntimeError::SecretCrypto { + operation: "persist async job", + details: error.to_string(), + })?; + + let registry = self.registry.clone(); + let secret_crypto = self.secret_crypto.clone(); + let runtime_for_task = self.runtime.clone(); + let workspace_for_task = workspace_id.clone(); + let operation_for_task = runtime.clone(); + let input_for_task = input.clone(); + let request_context_for_task = request_context.clone(); + let job_id = job.id.clone(); + tokio::spawn(async move { + let resolved_auth = resolve_runtime_auth_for_task( + ®istry, + &secret_crypto, + &workspace_for_task, + &operation_for_task.execution_config, + ) + .await; + let result = match resolved_auth { + Ok(resolved_auth) => { + runtime_for_task + .execute_with_auth_and_context( + &operation_for_task, + &input_for_task, + resolved_auth.as_ref(), + Some(&request_context_for_task), + ) + .await + } + Err(error) => Err(error), + }; + let finished_at = OffsetDateTime::now_utc(); + let _ = match result { + Ok(output) => { + registry + .update_async_job_status(UpdateAsyncJobStatusRequest { + job_id: &job_id, + current_status: JobStatus::Running, + next_status: JobStatus::Completed, + progress: &json!({ "pct": 100 }), + result: Some(&output), + error: None, + expires_at: None, + updated_at: &finished_at, + finished_at: Some(&finished_at), + }) + .await + } + Err(error) => { + registry + .update_async_job_status(UpdateAsyncJobStatusRequest { + job_id: &job_id, + current_status: JobStatus::Running, + next_status: JobStatus::Failed, + progress: &json!({ "pct": 100 }), + result: None, + error: Some(&json!({ + "code": runtime_error_code(&error), + "message": error.to_string(), + })), + expires_at: None, + updated_at: &finished_at, + finished_at: Some(&finished_at), + }) + .await + } + }; + }); + + Ok(AsyncJobStartView { + job_id: job.id.as_str().to_owned(), + status: job.status, + progress: job.progress, + }) + } + + async fn resolve_auth_profile( + &self, + workspace_id: &WorkspaceId, + auth_profile: &AuthProfile, + ) -> Result { + let mut secrets = BTreeMap::new(); + let used_at = OffsetDateTime::now_utc(); + + for secret_id in auth_profile.config.secret_ids() { + let secret = self + .registry + .get_secret(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + operation: "load secret", + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecret { + secret_id: secret_id.as_str().to_owned(), + })?; + let version = self + .registry + .get_current_secret_version(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + operation: "load current secret version", + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecretVersion { + secret_id: secret_id.as_str().to_owned(), + version: secret.secret.current_version, + })?; + let plaintext = self.secret_crypto.decrypt( + &version.secret_version.key_version, + &version.secret_version.ciphertext, + )?; + self.registry + .touch_secret(workspace_id, secret_id, &used_at) + .await + .map_err(|error| RuntimeError::SecretCrypto { + operation: "touch secret", + details: error.to_string(), + })?; + secrets.insert(secret_id.clone(), plaintext); + } + + ResolvedAuth::from_profile(auth_profile, &secrets) + } + + #[instrument(skip(self))] + pub async fn list_auth_profiles( + &self, + workspace_id: &WorkspaceId, + ) -> Result, 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, 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 { + 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 { + 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 { + 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 list_agents( + &self, + workspace_id: &WorkspaceId, + ) -> Result, 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: crank_registry::UsageBucket::Hour, + }) + .await?; + let calls_today = usage + .into_iter() + .map(|item| (item.agent_id.as_str().to_owned(), item.calls_total)) + .collect::>(); + 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::>(); + 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 { + 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::>(); + let usage = self + .registry + .get_usage_for_agent( + UsageQuery { + workspace_id, + period: UsagePeriod::Last24Hours, + source: None, + created_after: &today_start_utc()?, + bucket: crank_registry::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 { + 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 { + 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 { + 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 { + 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, + ) -> Result { + 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::>(); + + 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 { + let published_at = OffsetDateTime::now_utc(); + 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), + }) + } + + #[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 { + 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 { + 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), + }) + } + + #[instrument(skip(self))] + pub async fn get_auth_profile( + &self, + workspace_id: &WorkspaceId, + auth_profile_id: &AuthProfileId, + ) -> Result { + 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 { + 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(()) + } + + #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))] + pub async fn export_operation( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + query: ExportQuery, + ) -> Result { + let version = match query.version { + Some(version) => version, + None => { + self.get_operation(workspace_id, operation_id) + .await? + .current_draft_version + } + }; + let record = self + .get_operation_version(workspace_id, operation_id, version) + .await?; + let document = YamlOperationDocument { + format_version: "1".to_owned(), + kind: "operation".to_owned(), + operation: RegistryOperation { + config_export: Some(ConfigExport { + format_version: "1".to_owned(), + export_mode: query.mode, + }), + ..record.snapshot + }, + }; + + serde_yaml::to_string(&document).map_err(|error| ApiError::internal(error.to_string())) + } + + #[instrument(skip(self, yaml_document), fields(mode = ?query.mode))] + pub async fn import_operation( + &self, + workspace_id: &WorkspaceId, + query: ImportQuery, + yaml_document: &str, + ) -> Result { + let document: YamlOperationDocument = serde_yaml::from_str(yaml_document) + .map_err(|error| ApiError::validation(error.to_string()))?; + if document.kind != "operation" { + return Err(ApiError::validation("yaml kind must be operation")); + } + + let payload = OperationPayload { + name: document.operation.name.clone(), + display_name: document.operation.display_name.clone(), + category: document.operation.category.clone(), + protocol: document.operation.protocol, + security_level: document.operation.security_level, + target: document.operation.target.clone(), + input_schema: document.operation.input_schema.clone(), + output_schema: document.operation.output_schema.clone(), + input_mapping: document.operation.input_mapping.clone(), + output_mapping: document.operation.output_mapping.clone(), + execution_config: document.operation.execution_config.clone(), + tool_description: document.operation.tool_description.clone(), + }; + + match query.mode { + ImportMode::Create => { + let created = self.create_operation(workspace_id, payload).await?; + Ok(ImportResponse { + operation_id: created.operation_id, + workspace_id: created.workspace_id, + version: created.version, + import_mode: ImportMode::Create, + warnings: Vec::new(), + }) + } + ImportMode::Upsert => { + if let Some(existing) = self + .find_operation_by_name(workspace_id, &document.operation.name) + .await? + { + let created = self + .create_version( + workspace_id, + &existing.id, + NewVersionPayload { + operation: payload, + change_note: Some("yaml upsert".to_owned()), + }, + ) + .await?; + + let response = ImportResponse { + operation_id: created.operation_id, + workspace_id: created.workspace_id, + version: created.version, + import_mode: ImportMode::Upsert, + warnings: Vec::new(), + }; + info!( + operation_id = %response.operation_id, + version = response.version, + "operation imported by upsert" + ); + Ok(response) + } else { + let created = self.create_operation(workspace_id, payload).await?; + let response = ImportResponse { + operation_id: created.operation_id, + workspace_id: created.workspace_id, + version: created.version, + import_mode: ImportMode::Upsert, + warnings: Vec::new(), + }; + info!( + operation_id = %response.operation_id, + version = response.version, + "operation imported by upsert" + ); + Ok(response) + } + } + } + } + + #[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 { + 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 { + 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 validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> { + self.validate_operation_capabilities(payload.protocol, payload.security_level)?; + validate_protocol_target(payload.protocol, &payload.target)?; + validate_streaming_policy(&payload.execution_config)?; + validate_response_cache_policy(&payload.target, &payload.execution_config)?; + payload.input_mapping.validate_paths()?; + payload.output_mapping.validate_paths()?; + Ok(()) + } + + fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> { + self.validate_operation_capabilities(operation.protocol, operation.security_level)?; + validate_protocol_target(operation.protocol, &operation.target)?; + validate_streaming_policy(&operation.execution_config)?; + validate_response_cache_policy(&operation.target, &operation.execution_config)?; + operation.input_mapping.validate_paths()?; + operation.output_mapping.validate_paths()?; + Ok(()) + } + + fn validate_operation_capabilities( + &self, + protocol: Protocol, + security_level: OperationSecurityLevel, + ) -> Result<(), ApiError> { + let supported_protocols = [Protocol::Rest]; + if !supported_protocols.contains(&protocol) { + return Err(ApiError::validation_with_context( + format!( + "protocol {} is not supported in Community", + serde_json::to_string(&protocol) + .unwrap_or_else(|_| "\"unknown\"".to_owned()) + .trim_matches('"') + ), + json!({ + "protocol": protocol, + "edition": "community", + }), + )); + } + + if security_level != OperationSecurityLevel::Standard { + return Err(ApiError::validation_with_context( + format!( + "security level {} is not supported in Community", + serde_json::to_string(&security_level) + .unwrap_or_else(|_| "\"unknown\"".to_owned()) + .trim_matches('"') + ), + json!({ + "security_level": security_level, + "edition": "community", + }), + )); + } + + Ok(()) + } + + async fn find_operation_by_name( + &self, + workspace_id: &WorkspaceId, + name: &str, + ) -> Result, ApiError> { + Ok(self + .registry + .list_operations(workspace_id) + .await? + .into_iter() + .find(|operation| operation.name == name)) + } + + async fn find_agent_by_slug( + &self, + workspace_id: &WorkspaceId, + slug: &str, + ) -> Result, ApiError> { + Ok(self + .registry + .list_agents(workspace_id) + .await? + .into_iter() + .find(|agent| agent.slug == slug)) + } + + async fn ensure_workspace_exists(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> { + self.get_workspace(workspace_id).await.map(|_| ()) + } + + async fn seed_default_workspace_demo( + &self, + owner_user_id: &crank_core::UserId, + workspace_id: &WorkspaceId, + ) -> Result<(), ApiError> { + let ops_admin_id = self + .ensure_demo_user("ops-manager@crank.demo", "Ops Manager") + .await?; + let analyst_id = self + .ensure_demo_user("analyst@crank.demo", "Revenue Analyst") + .await?; + let contractor_id = self + .ensure_demo_user("contractor@crank.demo", "Delivery Contractor") + .await?; + + self.registry + .ensure_membership(workspace_id, owner_user_id, MembershipRole::Owner) + .await?; + self.registry + .ensure_membership(workspace_id, &ops_admin_id, MembershipRole::Admin) + .await?; + self.registry + .ensure_membership(workspace_id, &analyst_id, MembershipRole::Operator) + .await?; + self.registry + .ensure_membership(workspace_id, &contractor_id, MembershipRole::Viewer) + .await?; + + self.ensure_demo_invitation( + workspace_id, + InvitationPayload { + email: "partner@crank.demo".to_owned(), + role: MembershipRole::Viewer, + expires_at: None, + }, + ) + .await?; + self.ensure_demo_invitation( + workspace_id, + InvitationPayload { + email: "automation@crank.demo".to_owned(), + role: MembershipRole::Operator, + expires_at: None, + }, + ) + .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 archived_operation = self + .ensure_demo_operation(workspace_id, demo_archived_operation_payload()) + .await?; + self.ensure_operation_archived(workspace_id, &archived_operation) + .await?; + + let revops_agent = self + .ensure_demo_agent(workspace_id, demo_revops_agent_payload()) + .await?; + self.ensure_demo_agent_bindings( + workspace_id, + &AgentId::new(revops_agent.id.clone()), + vec![AgentBindingPayload { + operation_id: rest_operation.id.as_str().to_owned(), + operation_version: rest_operation.current_draft_version, + tool_name: "create_crm_lead".to_owned(), + tool_title: "Create CRM Lead".to_owned(), + tool_description_override: Some( + "Create a new CRM lead in the revenue workspace.".to_owned(), + ), + enabled: true, + }], + true, + ) + .await?; + + self.ensure_demo_platform_api_key( + workspace_id, + &AgentId::new(revops_agent.id.clone()), + "Web Console Demo Key", + vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + false, + ) + .await?; + + self.seed_demo_invocation_logs( + workspace_id, + &AgentId::new(revops_agent.id), + &rest_operation.id, + ) + .await?; + + Ok(()) + } + + async fn seed_growth_workspace_demo( + &self, + owner_user_id: &crank_core::UserId, + ) -> Result<(), ApiError> { + let workspace = self + .ensure_demo_workspace( + owner_user_id, + WorkspacePayload { + slug: "growth-lab".to_owned(), + display_name: "Growth Lab".to_owned(), + settings: json!({ + "tier": "demo", + "region": "eu-central", + "notes": "Secondary workspace for workspace switch testing" + }), + }, + ) + .await?; + let workspace_id = workspace.workspace.id; + + let growth_pm_id = self + .ensure_demo_user("growth.pm@crank.demo", "Growth PM") + .await?; + self.registry + .ensure_membership(&workspace_id, owner_user_id, MembershipRole::Owner) + .await?; + self.registry + .ensure_membership(&workspace_id, &growth_pm_id, MembershipRole::Admin) + .await?; + + self.ensure_demo_invitation( + &workspace_id, + InvitationPayload { + email: "agency@crank.demo".to_owned(), + role: MembershipRole::Viewer, + expires_at: None, + }, + ) + .await?; + Ok(()) + } + + async fn ensure_demo_workspace( + &self, + owner_user_id: &crank_core::UserId, + payload: WorkspacePayload, + ) -> Result { + if let Some(existing) = self + .registry + .list_workspaces_for_user(owner_user_id) + .await? + .into_iter() + .find(|record| record.workspace.slug == payload.slug) + { + return Ok(WorkspaceRecord { + workspace: existing.workspace, + }); + } + + self.create_workspace(owner_user_id, payload).await + } + + async fn ensure_demo_user( + &self, + email: &str, + display_name: &str, + ) -> Result { + let password_hash = hash_password(DEMO_USER_PASSWORD, &self.auth_settings.password_pepper)?; + self.registry + .upsert_bootstrap_user(email, display_name, &password_hash) + .await + .map_err(ApiError::from) + } + + async fn ensure_demo_invitation( + &self, + workspace_id: &WorkspaceId, + payload: InvitationPayload, + ) -> Result<(), ApiError> { + if self + .list_invitations(workspace_id) + .await? + .iter() + .any(|record| record.invitation.email == payload.email) + { + return Ok(()); + } + + self.create_invitation(workspace_id, payload).await?; + Ok(()) + } + + async fn ensure_demo_platform_api_key( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + name: &str, + scopes: Vec, + revoke: bool, + ) -> Result<(), ApiError> { + let existing = self + .list_agent_platform_api_keys(workspace_id, agent_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(), + scopes, + }, + ) + .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 { + 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_operation_archived( + &self, + workspace_id: &WorkspaceId, + summary: &OperationSummary, + ) -> Result<(), ApiError> { + if summary.status == OperationStatus::Archived { + return Ok(()); + } + + self.archive_operation(workspace_id, &summary.id).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 { + 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, + 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, + revops_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(revops_agent_id), + operation: &rest_operation.snapshot, + request_id: None, + source: InvocationSource::AgentToolCall, + level: InvocationLevel::Info, + status: InvocationStatus::Ok, + message: "lead created in CRM".to_owned(), + status_code: Some(201), + error_kind: None, + duration_ms: 182, + request_preview: json!({ + "path": {}, + "query": {}, + "headers": { "x-demo-source": "crank-seed" }, + "variables": null, + "grpc": null, + "body": demo_rest_request_sample() + }), + response_preview: demo_rest_response_sample(), + }) + .await?; + Ok(()) + } + + async fn record_invocation( + &self, + request: InvocationRecordRequest<'_>, + ) -> Result<(), ApiError> { + let log = InvocationLog { + id: InvocationLogId::new(new_prefixed_id("log")), + workspace_id: request.workspace_id.clone(), + agent_id: request.agent_id.cloned(), + operation_id: request.operation.id.clone(), + source: request.source, + level: request.level, + status: request.status, + tool_name: request.operation.name.clone(), + message: request.message, + request_id: request.request_id.map(ToOwned::to_owned), + status_code: request.status_code, + duration_ms: request.duration_ms, + error_kind: request.error_kind, + request_preview: request.request_preview, + response_preview: request.response_preview, + created_at: OffsetDateTime::now_utc(), + }; + + self.registry + .create_invocation_log(CreateInvocationLogRequest { log: &log }) + .await?; + + Ok(()) + } +} + +const DEMO_USER_PASSWORD: &str = "CrankDemoPass123!"; + +fn build_request_preview( + mapping: &MappingSet, + input: &Value, +) -> Result { + let mapped = mapping.apply(&json!({ "mcp": input }))?; + let prepared = PreparedRequest::from_mapping_output(&mapped).map_err(|error| { + crank_mapping::MappingError::InvalidJsonPath { + path: error.to_string(), + } + })?; + + Ok(json!({ + "path": prepared.path_params, + "query": prepared.query_params, + "headers": prepared.headers, + "grpc": prepared.grpc.unwrap_or(Value::Null), + "variables": prepared.variables.unwrap_or(Value::Null), + "body": prepared.body.unwrap_or(Value::Null) + })) +} + +fn validate_protocol_target(protocol: Protocol, target: &Target) -> Result<(), ApiError> { + let is_match = matches!( + (protocol, target), + (Protocol::Rest, Target::Rest(_)) + | (Protocol::Graphql, Target::Graphql(_)) + | (Protocol::Grpc, Target::Grpc(_)) + | (Protocol::Websocket, Target::Websocket(_)) + | (Protocol::Soap, Target::Soap(_)) + ); + + if is_match { + return Ok(()); + } + + Err(ApiError::validation("protocol and target kind must match")) +} + +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(()) +} + +fn latest_sample_ref( + samples: &[OperationSampleMetadata], + sample_kind: SampleKind, +) -> Option { + samples + .iter() + .rev() + .find(|sample| sample.sample_kind == sample_kind) + .cloned() +} + +fn default_export_mode() -> ExportMode { + ExportMode::Portable +} + +fn demo_revops_agent_payload() -> AgentPayload { + AgentPayload { + slug: "revops-copilot".to_owned(), + display_name: "RevOps Copilot".to_owned(), + description: "Revenue operations assistant with CRM and billing tools.".to_owned(), + instructions: json!({ + "system": "Prefer CRM mutations first, then billing lookups for confirmation." + }), + tool_selection_policy: json!({ + "max_tools": 8, + "prefer_tag": ["sales", "finance"] + }), + } +} + +fn demo_rest_operation_payload() -> OperationPayload { + let input = demo_rest_input_sample(); + let request = demo_rest_request_sample(); + let response = demo_rest_response_sample(); + let output = demo_rest_output_sample(); + + OperationPayload { + name: "crm_create_lead".to_owned(), + display_name: "Create CRM Lead".to_owned(), + category: "sales".to_owned(), + protocol: Protocol::Rest, + security_level: OperationSecurityLevel::Standard, + target: Target::Rest(crank_core::RestTarget { + base_url: "https://crm.demo.internal".to_owned(), + method: crank_core::HttpMethod::Post, + path_template: "/v1/leads".to_owned(), + static_headers: BTreeMap::from([("x-demo-source".to_owned(), "crank-seed".to_owned())]), + }), + input_schema: Schema::from_json_sample(&input), + output_schema: Schema::from_json_sample(&output), + input_mapping: infer_mapping_from_samples( + &input, + JsonPathRoot::Mcp, + &request, + JsonPathRoot::RequestBody, + ), + 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, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: crank_core::ToolDescription { + title: "Create CRM Lead".to_owned(), + description: "Create a lead record in the CRM system.".to_owned(), + tags: vec!["sales".to_owned(), "crm".to_owned()], + examples: vec![crank_core::ToolExample { + input: json!({ + "email": "sarah.connor@example.com", + "company": "Cyberdyne" + }), + }], + }, + } +} + +fn demo_archived_operation_payload() -> OperationPayload { + let input = json!({ + "contactId": "contact_123", + "reason": "Duplicate profile" + }); + let request = json!({ + "contactId": "contact_123", + "reason": "Duplicate profile" + }); + let response = json!({ + "archived": true, + "contactId": "contact_123" + }); + + OperationPayload { + name: "marketing_archive_contact".to_owned(), + display_name: "Archive Marketing Contact".to_owned(), + category: "marketing".to_owned(), + protocol: Protocol::Rest, + security_level: OperationSecurityLevel::Standard, + target: Target::Rest(crank_core::RestTarget { + base_url: "https://marketing.demo.internal".to_owned(), + method: crank_core::HttpMethod::Patch, + path_template: "/v1/contacts/archive".to_owned(), + static_headers: BTreeMap::new(), + }), + input_schema: Schema::from_json_sample(&input), + output_schema: Schema::from_json_sample(&response), + input_mapping: infer_mapping_from_samples( + &input, + JsonPathRoot::Mcp, + &request, + JsonPathRoot::RequestBody, + ), + output_mapping: infer_mapping_from_samples( + &response, + JsonPathRoot::ResponseBody, + &response, + JsonPathRoot::Output, + ), + execution_config: crank_core::ExecutionConfig { + timeout_ms: 6_000, + retry_policy: None, + response_cache: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: crank_core::ToolDescription { + title: "Archive Marketing Contact".to_owned(), + description: "Legacy archived flow kept for audit only.".to_owned(), + tags: vec!["marketing".to_owned()], + examples: Vec::new(), + }, + } +} + +fn demo_rest_input_sample() -> Value { + json!({ + "firstName": "Sarah", + "lastName": "Connor", + "email": "sarah.connor@example.com", + "company": "Cyberdyne", + "source": "website" + }) +} + +fn demo_rest_request_sample() -> Value { + demo_rest_input_sample() +} + +fn demo_rest_response_sample() -> Value { + json!({ + "id": "lead_1001", + "status": "created", + "owner": "revops" + }) +} + +fn demo_rest_output_sample() -> Value { + demo_rest_response_sample() +} + +fn default_enabled() -> bool { + true +} + +fn new_prefixed_id(prefix: &str) -> String { + format!("{prefix}_{}", Uuid::now_v7().simple()) +} + +fn generate_access_secret(prefix: &str) -> String { + let random = URL_SAFE_NO_PAD.encode(Uuid::now_v7().as_bytes()); + format!("{prefix}_{random}") +} + +fn hash_access_secret(secret: &str) -> String { + let digest = Sha256::digest(secret.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +fn now_string() -> Result { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string())) +} + +fn parse_timestamp(value: &str) -> Result { + OffsetDateTime::parse(value, &Rfc3339) + .map_err(|_| ApiError::validation("timestamp must be RFC 3339")) +} + +fn format_timestamp(timestamp: OffsetDateTime) -> String { + timestamp + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) +} + +fn map_identity_error(error: IdentityError) -> ApiError { + match error { + IdentityError::BadCredentials => ApiError::unauthorized("invalid email or password"), + IdentityError::AccountDisabled => ApiError::forbidden("account is disabled"), + IdentityError::NotSupportedForProvider => ApiError::internal( + "password login is not supported by the configured identity provider", + ), + IdentityError::Internal(message) => ApiError::internal(message), + } +} + +async fn resolve_runtime_auth_for_task( + registry: &PostgresRegistry, + secret_crypto: &SecretCrypto, + workspace_id: &WorkspaceId, + execution_config: &crank_core::ExecutionConfig, +) -> Result, RuntimeError> { + let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else { + return Ok(None); + }; + + let auth_profile = registry + .get_auth_profile(workspace_id, auth_profile_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + operation: "load auth profile", + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingAuthProfile { + auth_profile_id: auth_profile_id.as_str().to_owned(), + })?; + + let mut secrets = BTreeMap::new(); + let used_at = OffsetDateTime::now_utc(); + + for secret_id in auth_profile.config.secret_ids() { + let secret = registry + .get_secret(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + operation: "load secret", + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecret { + secret_id: secret_id.as_str().to_owned(), + })?; + let version = registry + .get_current_secret_version(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + operation: "load current secret version", + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecretVersion { + secret_id: secret_id.as_str().to_owned(), + version: secret.secret.current_version, + })?; + let plaintext = secret_crypto.decrypt( + &version.secret_version.key_version, + &version.secret_version.ciphertext, + )?; + registry + .touch_secret(workspace_id, secret_id, &used_at) + .await + .map_err(|error| RuntimeError::SecretCrypto { + operation: "touch secret", + details: error.to_string(), + })?; + secrets.insert(secret_id.clone(), plaintext); + } + + ResolvedAuth::from_profile(&auth_profile, &secrets).map(Some) +} + +fn default_invitation_expiry() -> Result { + OffsetDateTime::now_utc() + .checked_add(time::Duration::days(7)) + .ok_or_else(|| ApiError::internal("failed to compute invitation expiry")) +} + +fn runtime_error_code(error: &RuntimeError) -> &'static str { + match error { + RuntimeError::Schema(_) => "schema_error", + RuntimeError::Mapping(_) => "mapping_error", + RuntimeError::InvalidPreparedRequest { .. } => "invalid_request", + RuntimeError::GraphqlAdapter(_) => "graphql_error", + RuntimeError::GrpcAdapter(_) => "grpc_error", + RuntimeError::RestAdapter(_) => "rest_error", + RuntimeError::ProtocolAdapter(_) => "adapter_error", + RuntimeError::SoapAdapter(_) => "soap_error", + RuntimeError::WebsocketAdapter(_) => "websocket_error", + RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", + RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded", + RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error", + RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error", + RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error", + RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found", + RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { + "secret_not_found" + } + RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error", + RuntimeError::SecretCrypto { .. } => "secret_crypto_error", + } +} + +fn protocol_capability_view(protocol: Protocol, edition: ProductEdition) -> ProtocolCapabilityView { + let community_build = matches!(edition, ProductEdition::Community); + let supports_execution_modes = if community_build { + vec![ExecutionMode::Unary] + } else { + [ + ExecutionMode::Unary, + ExecutionMode::Window, + ExecutionMode::Session, + ExecutionMode::AsyncJob, + ] + .into_iter() + .filter(|mode| protocol.supports_execution_mode(*mode)) + .collect() + }; + let supports_transport_behaviors = if community_build { + vec![TransportBehavior::RequestResponse] + } else { + [ + TransportBehavior::RequestResponse, + TransportBehavior::ServerStream, + TransportBehavior::StatefulSession, + TransportBehavior::DeferredResult, + ] + .into_iter() + .filter(|behavior| protocol.supports_transport_behavior(*behavior)) + .collect() + }; + let supports_upload_artifacts = Vec::new(); + + ProtocolCapabilityView { + protocol, + supports_execution_modes, + supports_transport_behaviors, + supports_auth_kinds: vec![ + "none".to_owned(), + "bearer".to_owned(), + "basic".to_owned(), + "api_key_header".to_owned(), + "api_key_query".to_owned(), + ], + supports_upload_artifacts, + supports_cursor_path: !matches!(protocol, Protocol::Graphql), + supports_done_path: !matches!(protocol, Protocol::Graphql), + supports_aggregation_mode: vec![ + AggregationMode::RawItems, + AggregationMode::SummaryOnly, + AggregationMode::SummaryPlusSamples, + AggregationMode::Stats, + AggregationMode::LatestState, + ], + } +} + +fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket), ApiError> { + let now = OffsetDateTime::now_utc(); + let (start, bucket) = match period { + UsagePeriod::Last30Minutes => ( + now.checked_sub(time::Duration::minutes(30)) + .ok_or_else(|| ApiError::internal("failed to compute usage period"))?, + UsageBucket::Hour, + ), + UsagePeriod::LastHour => ( + now.checked_sub(time::Duration::hours(1)) + .ok_or_else(|| ApiError::internal("failed to compute usage period"))?, + UsageBucket::Hour, + ), + UsagePeriod::Last6Hours => ( + now.checked_sub(time::Duration::hours(6)) + .ok_or_else(|| ApiError::internal("failed to compute usage period"))?, + UsageBucket::Hour, + ), + UsagePeriod::Last24Hours => ( + now.checked_sub(time::Duration::hours(24)) + .ok_or_else(|| ApiError::internal("failed to compute usage period"))?, + UsageBucket::Hour, + ), + UsagePeriod::Last7Days => ( + now.checked_sub(time::Duration::days(7)) + .ok_or_else(|| ApiError::internal("failed to compute usage period"))?, + UsageBucket::Day, + ), + UsagePeriod::Last30Days => ( + now.checked_sub(time::Duration::days(30)) + .ok_or_else(|| ApiError::internal("failed to compute usage period"))?, + UsageBucket::Week, + ), + UsagePeriod::Last90Days => ( + now.checked_sub(time::Duration::days(90)) + .ok_or_else(|| ApiError::internal("failed to compute usage period"))?, + UsageBucket::Month, + ), + UsagePeriod::ThisMonth => ( + OffsetDateTime::from_unix_timestamp( + now.unix_timestamp() - i64::from(now.day() - 1) * 24 * 60 * 60, + ) + .map_err(|error| ApiError::internal(error.to_string()))? + .replace_time(time::Time::MIDNIGHT), + UsageBucket::Week, + ), + }; + + Ok(( + period, + start + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string()))?, + bucket, + )) +} + +fn today_start_utc() -> Result { + OffsetDateTime::now_utc() + .replace_time(time::Time::MIDNIGHT) + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string())) +} + +fn default_usage_summary() -> OperationUsageSummaryView { + OperationUsageSummaryView { + calls_today: 0, + error_rate_pct: 0.0, + avg_latency_ms: 0, + } +} + +fn usage_map(items: Vec) -> BTreeMap { + items + .into_iter() + .map(|item| { + ( + item.operation_id.as_str().to_owned(), + OperationUsageSummaryView { + calls_today: item.calls_today, + error_rate_pct: item.error_rate_pct, + avg_latency_ms: item.avg_latency_ms, + }, + ) + }) + .collect() +} + +fn agent_ref_map(items: Vec) -> BTreeMap> { + let mut map = BTreeMap::>::new(); + for item in items { + map.entry(item.operation_id.as_str().to_owned()) + .or_default() + .push(OperationAgentRefView { + agent_id: item.agent_id.as_str().to_owned(), + agent_slug: item.agent_slug, + display_name: item.display_name, + }); + } + map +} + +fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView { + AgentSummaryView { + id: summary.id.as_str().to_owned(), + workspace_id: summary.workspace_id.as_str().to_owned(), + slug: summary.slug, + display_name: summary.display_name, + description: summary.description, + 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), + operation_count: 0, + operation_ids: Vec::new(), + key_count: 0, + calls_today: 0, + mcp_endpoint: String::new(), + } +} + +fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String { + format!("/mcp/v1/{workspace_slug}/{agent_slug}") +} + +fn validate_streaming_policy( + execution_config: &crank_core::ExecutionConfig, +) -> Result<(), ApiError> { + if execution_config.streaming.is_some() { + return Err(ApiError::validation_with_context( + "streaming execution is not supported in Community".to_owned(), + json!({ + "field": "execution_config.streaming", + "edition": "community", + }), + )); + } + + Ok(()) +} + +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", + }), + )); + } + + if execution_config.streaming.is_some() { + return Err(ApiError::validation_with_context( + "response cache is supported only for unary operations".to_owned(), + json!({ + "field": "execution_config.response_cache", + }), + )); + } + + 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(()) +} + +#[cfg(test)] +#[allow(clippy::items_after_test_module)] +mod tests { + use std::collections::BTreeMap; + + use crank_core::{ExecutionConfig, HttpMethod, ResponseCachePolicy, RestTarget, Target}; + + use super::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 }), + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + } + } + + #[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_response_cache_for_non_rest_target() { + let target = Target::Graphql(crank_core::GraphqlTarget { + endpoint: "http://example.invalid/graphql".to_owned(), + operation_type: crank_core::GraphqlOperationType::Query, + operation_name: "LookupLead".to_owned(), + query_template: + "query LookupLead($email: String!) { lookupLead(email: $email) { id } }".to_owned(), + response_path: "$.response.body.data.lookupLead".to_owned(), + }); + + 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" + ); + } +} + +fn enrich_operation_summary( + summary: OperationSummary, + usage_summary: OperationUsageSummaryView, + agent_refs: Vec, +) -> OperationSummaryView { + OperationSummaryView { + 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, + target_url: summary.target_url, + target_action: summary.target_action, + 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), + usage_summary, + agent_refs, + } +} diff --git a/apps/admin-api/src/state.rs b/apps/admin-api/src/state.rs new file mode 100644 index 0000000..99b5165 --- /dev/null +++ b/apps/admin-api/src/state.rs @@ -0,0 +1,8 @@ +use crate::service::AdminService; +use crank_runtime::RequestRateLimiter; + +#[derive(Clone)] +pub struct AppState { + pub service: AdminService, + pub api_rate_limiter: RequestRateLimiter, +} diff --git a/apps/admin-api/src/storage.rs b/apps/admin-api/src/storage.rs new file mode 100644 index 0000000..0925947 --- /dev/null +++ b/apps/admin-api/src/storage.rs @@ -0,0 +1,84 @@ +use std::path::{Path, PathBuf}; + +use crank_core::{OperationId, SampleId}; +use crank_registry::SampleKind; +use serde_json::Value; +use thiserror::Error; +use tokio::fs; + +#[derive(Clone)] +pub struct LocalArtifactStorage { + root: PathBuf, +} + +#[derive(Debug, Error)] +pub enum StorageError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Serialization(#[from] serde_json::Error), + #[error("invalid storage reference: {details}")] + InvalidStorageRef { details: String }, +} + +impl LocalArtifactStorage { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + pub async fn write_json_sample( + &self, + operation_id: &OperationId, + version: u32, + sample_kind: SampleKind, + sample_id: &SampleId, + payload: &Value, + ) -> Result { + let file_name = match sample_kind { + SampleKind::InputJson => "input.json", + SampleKind::OutputJson => "output.json", + SampleKind::YamlImportSource => "source.json", + }; + let path = self + .root + .join("samples") + .join(operation_id.as_str()) + .join(format!("v{version}")) + .join(format!("{}_{}", sample_id.as_str(), file_name)); + + write_json_file(&path, payload).await?; + + Ok(to_storage_ref(&path)) + } + + pub async fn read_json(&self, storage_ref: &str) -> Result { + let path = from_storage_ref(storage_ref)?; + let bytes = fs::read(path).await?; + + Ok(serde_json::from_slice(&bytes)?) + } +} + +async fn write_json_file(path: &Path, payload: &Value) -> Result<(), StorageError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + + let bytes = serde_json::to_vec_pretty(payload)?; + fs::write(path, bytes).await?; + Ok(()) +} + +fn to_storage_ref(path: &Path) -> String { + format!("file://{}", path.display()) +} + +fn from_storage_ref(storage_ref: &str) -> Result { + let Some(path) = storage_ref.strip_prefix("file://") else { + return Err(StorageError::InvalidStorageRef { + details: storage_ref.to_owned(), + }); + }; + + Ok(PathBuf::from(path)) +} diff --git a/apps/mcp-server/Cargo.toml b/apps/mcp-server/Cargo.toml new file mode 100644 index 0000000..4885c23 --- /dev/null +++ b/apps/mcp-server/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "mcp-server" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[[bin]] +name = "mcp-server" +path = "src/main.rs" + +[dependencies] +async-trait = "0.1" +axum.workspace = true +base64.workspace = true +crank-community-mcp = { path = "../../crates/crank-community-mcp" } +crank-core = { path = "../../crates/crank-core" } +crank-registry = { path = "../../crates/crank-registry" } +crank-runtime = { path = "../../crates/crank-runtime" } +crank-schema = { path = "../../crates/crank-schema" } +futures-util = "0.3" +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +sqlx.workspace = true +thiserror.workspace = true +time.workspace = true +tokio = { workspace = true, features = ["sync"] } +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true + +[dev-dependencies] +crank-mapping = { path = "../../crates/crank-mapping" } +crank-schema = { path = "../../crates/crank-schema" } +reqwest.workspace = true diff --git a/apps/mcp-server/Dockerfile b/apps/mcp-server/Dockerfile new file mode 100644 index 0000000..7db7650 --- /dev/null +++ b/apps/mcp-server/Dockerfile @@ -0,0 +1,68 @@ +FROM rust:1.85-bookworm AS deps + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY .sqlx ./.sqlx +COPY apps/admin-api/Cargo.toml apps/admin-api/Cargo.toml +COPY apps/mcp-server/Cargo.toml apps/mcp-server/Cargo.toml +COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml +COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml +COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml +COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml +COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml +COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml + +RUN mkdir -p \ + apps/admin-api/src \ + apps/mcp-server/src \ + crates/crank-core/src \ + crates/crank-schema/src \ + crates/crank-mapping/src \ + crates/crank-registry/src \ + crates/crank-runtime/src \ + crates/crank-adapter-rest/src \ + && printf 'fn main() {}\n' > apps/admin-api/src/main.rs \ + && printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \ + && printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git/db \ + --mount=type=cache,target=/app/target \ + SQLX_OFFLINE=true cargo build --release -p mcp-server + +FROM rust:1.85-bookworm AS builder + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY .sqlx ./.sqlx +COPY apps ./apps +COPY crates ./crates + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git/db \ + --mount=type=cache,target=/app/target \ + SQLX_OFFLINE=true cargo build --release -p mcp-server \ + && cp /app/target/release/mcp-server /tmp/mcp-server + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /tmp/mcp-server /usr/local/bin/mcp-server + +ENV CRANK_MCP_BIND=0.0.0.0:3002 + +EXPOSE 3002 + +CMD ["mcp-server"] diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs new file mode 100644 index 0000000..3d33054 --- /dev/null +++ b/apps/mcp-server/src/main.rs @@ -0,0 +1,3507 @@ +use std::{env, net::SocketAddr, time::Duration}; + +use crank_community_mcp::{ + auth::CommunityMachineCredentialVerifier, build_app, session::PostgresTransportSessionStore, +}; +use crank_registry::{PostgresPoolConfig, PostgresRegistry}; +use crank_runtime::{ + RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores, + RuntimeLimits, SecretCrypto, community_default, +}; +use sqlx::postgres::PgConnectOptions; +use tokio::net::TcpListener; +use tracing::info; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + env::var("CRANK_LOG_LEVEL") + .unwrap_or_else(|_| "mcp_server=info,tower_http=info".into()), + ) + .init(); + + let bind_addr = env::var("CRANK_MCP_BIND").unwrap_or_else(|_| "0.0.0.0:3002".into()); + let base_url = env::var("CRANK_BASE_URL").ok(); + let refresh_interval = env::var("CRANK_MCP_REFRESH_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + .unwrap_or_else(|| Duration::from_secs(5)); + let socket_addr: SocketAddr = bind_addr.parse()?; + let pool_config = PostgresPoolConfig::from_env()?; + let runtime_limits = RuntimeLimits::from_env()?; + let cache_config = RuntimeCacheConfig::from_env()?; + let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?; + let api_rate_limit = mcp_api_rate_limit_config_from_env()?; + let database_options = database_options_from_env()?; + let registry = PostgresRegistry::connect_with_options_and_pool_config( + database_options.clone(), + pool_config, + ) + .await?; + let session_store = PostgresTransportSessionStore::connect_with_options_and_pool_config( + database_options, + pool_config, + ) + .await?; + let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; + let runtime = community_default() + .with_limits(runtime_limits) + .with_response_cache(cache_stores.response.clone()) + .build(); + let app = build_app( + registry, + refresh_interval, + base_url, + secret_crypto, + runtime, + if cache_config.backend.is_external() { + RequestRateLimiter::new_shared(api_rate_limit, cache_stores.rate_limit.clone()) + } else { + RequestRateLimiter::new(api_rate_limit) + }, + cache_stores.coordination.clone(), + std::sync::Arc::new(session_store), + std::sync::Arc::new(CommunityMachineCredentialVerifier), + ); + let listener = TcpListener::bind(socket_addr).await?; + + info!( + runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary, + runtime_max_concurrent_window = runtime_limits.max_concurrent_window, + runtime_max_concurrent_sessions = runtime_limits.max_concurrent_sessions, + runtime_max_concurrent_jobs = runtime_limits.max_concurrent_jobs, + mcp_rate_limit_rps = api_rate_limit.requests_per_second, + mcp_rate_limit_burst = api_rate_limit.burst, + cache_backend = %cache_config.backend, + max_connections = pool_config.max_connections, + min_connections = pool_config.min_connections, + acquire_timeout_ms = pool_config.acquire_timeout_ms, + idle_timeout_ms = pool_config.idle_timeout_ms, + max_lifetime_ms = pool_config.max_lifetime_ms, + "postgres pool configured" + ); + info!("mcp-server listening on {}", socket_addr); + + axum::serve(listener, app).await?; + + Ok(()) +} + +fn database_options_from_env() -> Result> { + if let Ok(database_url) = env::var("CRANK_DATABASE_URL") { + return Ok(database_url.parse::()?); + } + + let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into()); + let port = env::var("POSTGRES_PORT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(5432); + let database = env::var("POSTGRES_DB").unwrap_or_else(|_| "crank".into()); + let username = env::var("POSTGRES_USER").unwrap_or_else(|_| "crank".into()); + let password = env::var("POSTGRES_PASSWORD").unwrap_or_else(|_| "crank".into()); + + Ok(PgConnectOptions::new() + .host(&host) + .port(port) + .database(&database) + .username(&username) + .password(&password)) +} + +fn mcp_api_rate_limit_config_from_env() -> Result> +{ + let requests_per_second = env::var("CRANK_MCP_RATE_LIMIT_RPS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(60); + let burst = env::var("CRANK_MCP_RATE_LIMIT_BURST") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(120); + + Ok(RequestRateLimitConfig::new(requests_per_second, burst)?) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + env, 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}; + #[cfg(any())] + use crank_adapter_grpc::test_support as grpc_test_support; + use crank_core::{ + Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, + HttpMethod, Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, + PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, + WorkspaceId, + }; + #[cfg(any())] + use crank_core::{ + AggregationMode, AsyncJobHandle, AsyncJobId, DescriptorId, ExecutionMode, + GraphqlOperationType, GraphqlTarget, GrpcTarget, JobStatus, StreamingConfig, + ToolFamilyConfig, TransportBehavior, + }; + use crank_mapping::{MappingRule, MappingSet}; + #[cfg(any())] + use crank_registry::CreateAsyncJobRequest; + 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 sqlx::{Executor, postgres::PgPoolOptions}; + 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, MachineCredentialVerifier, + MachineCredentialVerifierError, SharedMachineCredentialVerifier, + VerifiedMachineCredential, + }, + build_app, + catalog::PublishedToolCatalog, + session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore}, + }; + + fn test_workspace_id() -> WorkspaceId { + WorkspaceId::new("ws_default") + } + + #[derive(Clone, Default)] + struct SharedLogWriter { + buffer: Arc>>, + } + + 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>>, + } + + impl io::Write for SharedLogGuard { + fn write(&mut self, bytes: &[u8]) -> io::Result { + 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, + ) -> 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, + 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, + 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(), + RuntimeExecutor::new(), + RequestRateLimiter::new(rate_limit_config), + std::sync::Arc::new(InMemoryCoordinationStateStore::default()), + sessions, + credential_verifier, + ) + } + + #[derive(Clone)] + struct StubMachineCredentialVerifier { + token: String, + credential: VerifiedMachineCredential, + } + + #[async_trait::async_trait] + impl MachineCredentialVerifier for StubMachineCredentialVerifier { + async fn verify_bearer_token( + &self, + _workspace_slug: &str, + _agent_slug: &str, + token: &str, + ) -> Result, MachineCredentialVerifierError> { + if token == self.token { + return Ok(Some(self.credential.clone())); + } + + Ok(None) + } + } + + #[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::().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::().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 dispatch = tracing::Dispatch::new(subscriber); + + let _guard = tracing::dispatcher::set_default(&dispatch); + 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")); + assert!(logs.contains("req_mcp_trace_123")); + assert!(logs.contains("sales-request-trace")); + assert!(logs.contains("default")); + assert!(logs.contains("initialize")); + } + + #[cfg(any())] + #[tokio::test] + async fn initializes_and_calls_published_graphql_tool_via_mcp() { + let registry = test_registry().await; + let endpoint = spawn_graphql_server().await; + let operation = test_graphql_operation(&endpoint, "crm_create_lead_graphql"); + + 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-graphql").await; + let api_key = create_platform_api_key( + ®istry, + "sales-graphql", + "mcp-graphql", + &[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-graphql"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).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_graphql", + "arguments": { + "email": "user@example.com" + } + } + }), + ) + .await; + + assert_eq!( + call_result["result"]["structuredContent"], + json!({ "id": "lead_123" }) + ); + assert_eq!(call_result["result"]["isError"], false); + } + + #[cfg(any())] + #[tokio::test] + async fn initializes_and_calls_published_grpc_tool_via_mcp() { + let registry = test_registry().await; + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let operation = test_grpc_operation(&server_addr, "echo_unary_grpc"); + + 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-grpc").await; + let api_key = create_platform_api_key( + ®istry, + "sales-grpc", + "mcp-grpc", + &[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-grpc"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).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": "echo_unary_grpc", + "arguments": { + "message": "hello" + } + } + }), + ) + .await; + + assert_eq!( + call_result["result"]["structuredContent"], + json!({ "message": "hello" }) + ); + assert_eq!(call_result["result"]["isError"], false); + } + + #[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::() + .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", + 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); + } + + #[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 accepts_initialize_with_verified_bearer_token_seam() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-token-seam", vec![]).await; + + let verifier = Arc::new(StubMachineCredentialVerifier { + token: "issued_token_demo".to_owned(), + credential: VerifiedMachineCredential { + machine_access_mode: crank_core::MachineAccessMode::ShortLivedToken, + max_security_level: crank_core::OperationSecurityLevel::Elevated, + scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + }, + }); + + 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(), + std::sync::Arc::new(InMemorySessionStore::default()), + verifier, + )) + .await; + let client = reqwest::Client::new(); + let response = client + .post(agent_mcp_url(&base_url, "sales-token-seam")) + .header(header::ACCEPT, "application/json, text/event-stream") + .header(header::AUTHORIZATION, "Bearer issued_token_demo") + .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!(response.headers().get("MCP-Session-Id").is_some()); + } + + #[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_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_elevated_operation_with_static_agent_key() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let mut operation = test_operation(&upstream_base_url, "crm_elevated_static"); + operation.security_level = crank_core::OperationSecurityLevel::Elevated; + + 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-elevated-static").await; + let api_key = create_platform_api_key( + ®istry, + "sales-elevated-static", + "mcp-elevated-static", + &[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-elevated-static"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + let call_result = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "crm_elevated_static", + "arguments": { + "email": "alice@example.com" + } + } + }), + ) + .await; + + assert_eq!(call_result["result"]["isError"], json!(true)); + assert_eq!( + call_result["result"]["structuredContent"]["error"]["code"], + "machine_access_insufficient" + ); + assert_eq!( + call_result["result"]["structuredContent"]["error"]["context"]["machine_access_mode"], + "static_agent_key" + ); + assert_eq!( + call_result["result"]["structuredContent"]["error"]["context"]["required_security_level"], + "elevated" + ); + } + + #[tokio::test] + async fn allows_elevated_operation_with_verified_short_lived_token() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let mut operation = test_operation(&upstream_base_url, "crm_elevated_token"); + operation.security_level = crank_core::OperationSecurityLevel::Elevated; + + 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-elevated-token").await; + + let verifier = Arc::new(StubMachineCredentialVerifier { + token: "issued_token_elevated".to_owned(), + credential: VerifiedMachineCredential { + machine_access_mode: crank_core::MachineAccessMode::ShortLivedToken, + max_security_level: crank_core::OperationSecurityLevel::Elevated, + scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + }, + }); + + 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(), + std::sync::Arc::new(InMemorySessionStore::default()), + verifier, + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-elevated-token"); + let initialized_session = + initialize_session(&client, &mcp_url, "issued_token_elevated").await; + let call_result = post_jsonrpc( + &client, + &mcp_url, + "issued_token_elevated", + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "crm_elevated_token", + "arguments": { + "email": "alice@example.com" + } + } + }), + ) + .await; + + assert_eq!(call_result["result"]["isError"], json!(false)); + assert_eq!(call_result["result"]["structuredContent"]["id"], "lead_123"); + } + + #[cfg(any())] + #[tokio::test] + async fn exposes_and_runs_session_tool_family_via_mcp() { + let registry = test_registry().await; + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let operation = test_grpc_session_operation(&server_addr, "echo_stream_session"); + + 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-session").await; + let api_key = create_platform_api_key( + ®istry, + "sales-session", + "mcp-session", + &[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-session"); + 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 tool_names = tools["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap().to_owned()) + .collect::>(); + assert!(!tool_names.contains(&operation.name)); + assert!(tool_names.contains(&"echo_stream_session_start".to_owned())); + assert!(tool_names.contains(&"echo_stream_session_poll".to_owned())); + assert!(tool_names.contains(&"echo_stream_session_stop".to_owned())); + + let start_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "echo_stream_session_start", + "arguments": { + "message": "hello" + } + } + }), + ) + .await; + let session_id = start_response["result"]["structuredContent"]["session_id"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!( + start_response["result"]["structuredContent"]["status"], + json!("running") + ); + + let poll_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "echo_stream_session_poll", + "arguments": { + "session_id": session_id + } + } + }), + ) + .await; + assert_eq!( + poll_response["result"]["structuredContent"]["session_id"], + json!(session_id) + ); + assert!( + poll_response["result"]["structuredContent"]["items"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + + let stop_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "echo_stream_session_stop", + "arguments": { + "session_id": session_id + } + } + }), + ) + .await; + assert_eq!( + stop_response["result"]["structuredContent"], + json!({ + "session_id": session_id, + "status": "stopped" + }) + ); + } + + #[cfg(any())] + #[tokio::test] + async fn rejects_cross_agent_session_poll() { + let registry = test_registry().await; + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let operation_a = test_grpc_session_operation(&server_addr, "echo_stream_session_a"); + let operation_b = test_grpc_session_operation(&server_addr, "echo_stream_session_b"); + + 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_for_operation(®istry, &operation_a, "sales-session-a").await; + publish_agent_for_operation(®istry, &operation_b, "sales-session-b").await; + let api_key_a = create_platform_api_key( + ®istry, + "sales-session-a", + "mcp-cross-session-a", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let api_key_b = create_platform_api_key( + ®istry, + "sales-session-b", + "mcp-cross-session-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-session-a"); + let agent_b_url = agent_mcp_url(&base_url, "sales-session-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 start_response = post_jsonrpc( + &client, + &agent_a_url, + &api_key_a, + Some(&session_a), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "echo_stream_session_a_start", + "arguments": { "message": "hello" } + } + }), + ) + .await; + let foreign_session_id = start_response["result"]["structuredContent"]["session_id"] + .as_str() + .unwrap() + .to_owned(); + + let poll_response = post_jsonrpc( + &client, + &agent_b_url, + &api_key_b, + Some(&session_b), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "echo_stream_session_b_poll", + "arguments": { "session_id": foreign_session_id } + } + }), + ) + .await; + + assert_eq!( + poll_response["result"]["structuredContent"]["error"]["code"], + json!("stream_session_not_found") + ); + assert!( + poll_response["result"]["structuredContent"]["error"]["message"] + .as_str() + .is_some_and(|message| message.starts_with("stream session ")) + ); + } + + #[cfg(any())] + #[tokio::test] + async fn rejects_rapid_repeat_session_poll() { + let registry = test_registry().await; + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let operation = test_grpc_session_operation(&server_addr, "echo_stream_session_rate"); + + 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-session-rate").await; + let api_key = create_platform_api_key( + ®istry, + "sales-session-rate", + "mcp-session-rate", + &[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-session-rate"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let start_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "echo_stream_session_rate_start", + "arguments": { "message": "hello" } + } + }), + ) + .await; + let session_id = start_response["result"]["structuredContent"]["session_id"] + .as_str() + .unwrap() + .to_owned(); + + let first_poll = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "echo_stream_session_rate_poll", + "arguments": { "session_id": session_id } + } + }), + ) + .await; + assert_eq!(first_poll["result"]["isError"], false); + + let second_poll = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "echo_stream_session_rate_poll", + "arguments": { "session_id": session_id } + } + }), + ) + .await; + + assert_eq!( + second_poll["result"]["structuredContent"]["error"]["code"], + json!("stream_session_poll_rate_limited") + ); + let poll_after_ms = + second_poll["result"]["structuredContent"]["error"]["context"]["poll_after_ms"] + .as_u64() + .unwrap(); + assert!((1..=250).contains(&poll_after_ms)); + } + + #[cfg(any())] + #[cfg(any())] + #[tokio::test] + async fn rejects_rapid_repeat_async_job_status_poll() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_rest_async_job_operation(&upstream_base_url, "crm_async_rate"); + + 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-async-rate").await; + let api_key = create_platform_api_key( + ®istry, + "sales-async-rate", + "mcp-async-rate", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let now = OffsetDateTime::now_utc(); + let job_id = AsyncJobId::new("job_async_rate".to_owned()); + registry + .create_async_job(CreateAsyncJobRequest { + job: &AsyncJobHandle { + id: job_id.clone(), + workspace_id: test_workspace_id(), + agent_id: Some(AgentId::new("agent_sales-async-rate")), + operation_id: operation.id.clone(), + status: JobStatus::Running, + progress: json!({ "pct": 25 }), + result: None, + error: None, + expires_at: Some(now + time::Duration::minutes(5)), + last_poll_at: None, + created_at: now, + updated_at: now, + finished_at: None, + }, + }) + .await + .unwrap(); + + 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-async-rate"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let first_status = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "crm_async_rate_status", + "arguments": { + "job_id": job_id.as_str() + } + } + }), + ) + .await; + assert_eq!(first_status["result"]["isError"], false); + + let second_status = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "crm_async_rate_status", + "arguments": { + "job_id": job_id.as_str() + } + } + }), + ) + .await; + + assert_eq!( + second_status["result"]["structuredContent"]["error"]["code"], + json!("async_job_poll_rate_limited") + ); + let poll_after_ms = + second_status["result"]["structuredContent"]["error"]["context"]["poll_after_ms"] + .as_u64() + .unwrap(); + assert!((1..=250).contains(&poll_after_ms)); + } + + #[cfg(any())] + #[cfg(any())] + #[tokio::test(flavor = "multi_thread")] + async fn allows_completed_async_job_result_without_poll_delay() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_rest_async_job_operation(&upstream_base_url, "crm_async_completed"); + + 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-async-completed").await; + let api_key = create_platform_api_key( + ®istry, + "sales-async-completed", + "mcp-async-completed", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let now = OffsetDateTime::now_utc(); + let job_id = AsyncJobId::new("job_async_completed".to_owned()); + registry + .create_async_job(CreateAsyncJobRequest { + job: &AsyncJobHandle { + id: job_id.clone(), + workspace_id: test_workspace_id(), + agent_id: Some(AgentId::new("agent_sales-async-completed")), + operation_id: operation.id.clone(), + status: JobStatus::Completed, + progress: json!({ "pct": 100 }), + result: Some(json!({ "id": "lead_123" })), + error: None, + expires_at: Some(now + time::Duration::minutes(5)), + last_poll_at: Some(now), + created_at: now, + updated_at: now, + finished_at: Some(now), + }, + }) + .await + .unwrap(); + + 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-async-completed"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let result_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "crm_async_completed_result", + "arguments": { + "job_id": job_id.as_str() + } + } + }), + ) + .await; + assert_eq!( + result_response["result"]["structuredContent"]["id"], + "lead_123" + ); + } + + #[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::().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)); + } + + #[cfg(any())] + #[cfg(any())] + #[tokio::test] + async fn rejects_cross_agent_async_job_access() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation_a = test_rest_async_job_operation(&upstream_base_url, "crm_async_a"); + let operation_b = test_rest_async_job_operation(&upstream_base_url, "crm_async_b"); + + 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_for_operation(®istry, &operation_a, "sales-async-a").await; + publish_agent_for_operation(®istry, &operation_b, "sales-async-b").await; + let api_key_a = create_platform_api_key( + ®istry, + "sales-async-a", + "mcp-cross-async-a", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let api_key_b = create_platform_api_key( + ®istry, + "sales-async-b", + "mcp-cross-async-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-async-a"); + let agent_b_url = agent_mcp_url(&base_url, "sales-async-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 start_response = post_jsonrpc( + &client, + &agent_a_url, + &api_key_a, + Some(&session_a), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "crm_async_a_start", + "arguments": { "email": "user@example.com" } + } + }), + ) + .await; + let foreign_job_id = start_response["result"]["structuredContent"]["job_id"] + .as_str() + .unwrap() + .to_owned(); + + let status_response = post_jsonrpc( + &client, + &agent_b_url, + &api_key_b, + Some(&session_b), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "crm_async_b_status", + "arguments": { "job_id": foreign_job_id } + } + }), + ) + .await; + + assert_eq!( + status_response["result"]["structuredContent"]["error"]["code"], + json!("async_job_not_found") + ); + assert!( + status_response["result"]["structuredContent"]["error"]["message"] + .as_str() + .is_some_and(|message| message.starts_with("async job ")) + ); + } + + #[cfg(any())] + #[cfg(any())] + #[tokio::test] + async fn exposes_and_runs_async_job_tool_family_via_mcp() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_rest_async_job_operation(&upstream_base_url, "crm_async_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-async").await; + let api_key = create_platform_api_key( + ®istry, + "sales-async", + "mcp-async", + &[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-async"); + 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 tool_names = tools["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap().to_owned()) + .collect::>(); + assert!(!tool_names.contains(&operation.name)); + assert!(tool_names.contains(&"crm_async_create_lead_start".to_owned())); + assert!(tool_names.contains(&"crm_async_create_lead_status".to_owned())); + assert!(tool_names.contains(&"crm_async_create_lead_result".to_owned())); + assert!(tool_names.contains(&"crm_async_create_lead_cancel".to_owned())); + + let start_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "crm_async_create_lead_start", + "arguments": { + "email": "user@example.com" + } + } + }), + ) + .await; + let job_id = start_response["result"]["structuredContent"]["job_id"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!( + start_response["result"]["structuredContent"]["status"], + json!("running") + ); + + let mut status_response = Value::Null; + for _ in 0..20 { + status_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "crm_async_create_lead_status", + "arguments": { + "job_id": job_id + } + } + }), + ) + .await; + + if status_response["result"]["structuredContent"]["status"] == json!("completed") { + break; + } + + sleep(Duration::from_millis(250)).await; + } + + sleep(Duration::from_millis(250)).await; + + assert_eq!( + status_response["result"]["structuredContent"]["status"], + json!("completed") + ); + + let result_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "crm_async_create_lead_result", + "arguments": { + "job_id": job_id + } + } + }), + ) + .await; + assert_eq!( + result_response["result"]["structuredContent"], + json!({ "id": "lead_123" }) + ); + } + + #[cfg(any())] + #[tokio::test] + async fn executes_window_operation_via_mcp() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_rest_window_operation(&upstream_base_url, "crm_window_logs"); + + 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-window").await; + let api_key = create_platform_api_key( + ®istry, + "sales-window", + "mcp-window", + &[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-window"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).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_window_logs", + "arguments": {} + } + }), + ) + .await; + + assert_eq!(call_result["result"]["isError"], false); + assert_eq!( + call_result["result"]["structuredContent"]["items"][0]["message"], + json!("billing started") + ); + assert_eq!( + call_result["result"]["structuredContent"]["window_complete"], + json!(true) + ); + } + + async fn initialize_session(client: &reqwest::Client, mcp_url: &str, api_key: &str) -> 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" + } + })) + .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 + } + + 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::() + .await + .unwrap() + } + + 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() + } + + async fn create_platform_api_key( + registry: &PostgresRegistry, + agent_slug: &str, + name: &str, + scopes: &[PlatformApiKeyScope], + ) -> String { + let secret = format!("crk_{}_{}", 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)), + 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, + }; + + registry + .create_platform_api_key(CreatePlatformApiKeyRequest { + api_key: &api_key, + secret_hash: &hash_access_secret(&secret), + }) + .await + .unwrap(); + + secret + } + + fn hash_access_secret(secret: &str) -> String { + let digest = Sha256::digest(secret.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) + } + + 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) + } + + #[cfg(any())] + async fn spawn_graphql_server() -> String { + let app = Router::new().route("/", post(graphql_handler)); + 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 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) + } + + fn agent_mcp_url(base_url: &str, agent_slug: &str) -> String { + format!("{base_url}/v1/{}/{}", test_workspace_slug(), agent_slug) + } + + async fn publish_agent_for_operation( + registry: &PostgresRegistry, + operation: &Operation, + agent_slug: &str, + ) { + publish_agent_with_bindings(registry, agent_slug, vec![binding_for_operation(operation)]) + .await; + } + + async fn publish_agent_with_bindings( + registry: &PostgresRegistry, + agent_slug: &str, + bindings: Vec, + ) { + 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(); + } + + fn binding_for_operation(operation: &Operation) -> 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, + } + } + + async fn create_lead(Json(payload): Json) -> Json { + Json(json!({ + "id": "lead_123", + "email": payload["email"] + })) + } + + async fn create_slow_lead(Json(payload): Json) -> Json { + sleep(Duration::from_millis(250)).await; + + Json(json!({ + "id": "lead_123", + "email": payload["email"] + })) + } + + async fn stream_logs() + -> Sse>> { + let events = vec![ + json!({ "level": "info", "message": "billing started" }), + json!({ "level": "warn", "message": "cache warmup slow" }), + json!({ "level": "error", "message": "invoice 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))) + } + + #[cfg(any())] + async fn graphql_handler(Json(payload): Json) -> Json { + let email = payload + .get("variables") + .and_then(|variables| variables.get("email")) + .and_then(Value::as_str) + .unwrap_or_default(); + + Json(json!({ + "data": { + "createLead": { + "id": "lead_123", + "email": email + } + } + })) + } + + async fn test_registry() -> PostgresRegistry { + let database_url = env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:15432/crank".to_owned()); + let admin_pool = PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .unwrap(); + let schema = format!("test_mcp_server_{}", uuid::Uuid::now_v7().simple()); + + admin_pool + .execute(sqlx::query(&format!("create schema {schema}"))) + .await + .unwrap(); + + PostgresRegistry::connect(&format!("{database_url}?options=-csearch_path%3D{schema}")) + .await + .unwrap() + } + + fn test_operation(base_url: &str, name: &str) -> Operation { + 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, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + 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, + 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()), + } + } + + #[cfg(any())] + fn test_graphql_operation(endpoint: &str, name: &str) -> Operation { + Operation { + id: OperationId::new(format!("op_{name}")), + name: name.to_owned(), + display_name: "Create Lead GraphQL".to_owned(), + category: "sales".to_owned(), + protocol: Protocol::Graphql, + security_level: crank_core::OperationSecurityLevel::Standard, + status: OperationStatus::Published, + version: 1, + target: Target::Graphql(GraphqlTarget { + endpoint: endpoint.to_owned(), + operation_type: GraphqlOperationType::Mutation, + operation_name: "CreateLead".to_owned(), + query_template: + "mutation CreateLead($email: String!) { createLead(email: $email) { id email } }" + .to_owned(), + response_path: "$.response.body.data.createLead".to_owned(), + }), + input_schema: object_schema("email"), + output_schema: object_schema("id"), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.variables.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.data.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, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Create Lead GraphQL".to_owned(), + description: "Creates a CRM lead through GraphQL".to_owned(), + tags: vec!["graphql".to_owned()], + examples: Vec::new(), + }, + samples: None, + generated_draft: None, + config_export: 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(), + ), + } + } + + #[cfg(any())] + fn test_grpc_operation(server_addr: &str, name: &str) -> Operation { + Operation { + id: OperationId::new(format!("op_{name}")), + name: name.to_owned(), + display_name: "Unary Echo gRPC".to_owned(), + category: "support".to_owned(), + protocol: Protocol::Grpc, + security_level: crank_core::OperationSecurityLevel::Standard, + status: OperationStatus::Published, + version: 1, + target: Target::Grpc(GrpcTarget { + server_addr: server_addr.to_owned(), + package: "echo".to_owned(), + service: "EchoService".to_owned(), + method: "UnaryEcho".to_owned(), + read_only: false, + descriptor_ref: DescriptorId::new("desc_echo"), + descriptor_set_b64: grpc_test_support::descriptor_set_b64(), + }), + input_schema: object_schema("message"), + output_schema: object_schema("message"), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.message".to_owned(), + target: "$.request.grpc.message".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.data.message".to_owned(), + target: "$.output.message".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, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Unary Echo gRPC".to_owned(), + description: "Echoes a unary gRPC payload".to_owned(), + tags: vec!["grpc".to_owned()], + examples: Vec::new(), + }, + samples: None, + generated_draft: None, + config_export: 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()), + } + } + + #[cfg(any())] + fn test_grpc_session_operation(server_addr: &str, name: &str) -> Operation { + let mut operation = test_grpc_operation(server_addr, name); + operation.target = Target::Grpc(GrpcTarget { + server_addr: server_addr.to_owned(), + package: "echo".to_owned(), + service: "EchoService".to_owned(), + method: "ServerEcho".to_owned(), + read_only: false, + descriptor_ref: DescriptorId::new("desc_echo"), + descriptor_set_b64: grpc_test_support::descriptor_set_b64(), + }); + operation.display_name = "Server Echo Session".to_owned(); + operation.tool_description = ToolDescription { + title: "Server Echo Session".to_owned(), + description: "Streams echo messages through a bounded session".to_owned(), + tags: vec!["grpc".to_owned(), "streaming".to_owned()], + examples: Vec::new(), + }; + operation.execution_config.streaming = Some(StreamingConfig { + mode: ExecutionMode::Session, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(1_000), + poll_interval_ms: Some(250), + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: Some(5_000), + max_session_lifetime_ms: Some(60_000), + max_items: Some(1), + max_bytes: Some(16 * 1024), + aggregation_mode: AggregationMode::SummaryPlusSamples, + summary_path: None, + items_path: Some("$.items".to_owned()), + cursor_path: None, + status_path: None, + done_path: Some("$.done".to_owned()), + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig { + start_tool_name: Some(format!("{name}_start")), + poll_tool_name: Some(format!("{name}_poll")), + stop_tool_name: Some(format!("{name}_stop")), + status_tool_name: None, + result_tool_name: None, + cancel_tool_name: None, + }, + }); + operation + } + + #[cfg(any())] + fn test_rest_async_job_operation(base_url: &str, name: &str) -> Operation { + let mut operation = test_operation(base_url, name); + operation.display_name = "Create Lead Async".to_owned(); + operation.tool_description = ToolDescription { + title: "Create Lead Async".to_owned(), + description: "Creates a CRM lead through an async job tool family".to_owned(), + tags: vec!["rest".to_owned(), "async_job".to_owned()], + examples: Vec::new(), + }; + operation.execution_config.streaming = Some(StreamingConfig { + mode: ExecutionMode::AsyncJob, + transport_behavior: TransportBehavior::DeferredResult, + window_duration_ms: None, + poll_interval_ms: Some(250), + upstream_timeout_ms: Some(2_000), + idle_timeout_ms: None, + max_session_lifetime_ms: Some(300_000), + max_items: None, + max_bytes: Some(16 * 1024), + aggregation_mode: AggregationMode::SummaryOnly, + summary_path: None, + items_path: None, + cursor_path: None, + status_path: None, + done_path: None, + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig { + start_tool_name: Some(format!("{name}_start")), + poll_tool_name: None, + stop_tool_name: None, + status_tool_name: Some(format!("{name}_status")), + result_tool_name: Some(format!("{name}_result")), + cancel_tool_name: Some(format!("{name}_cancel")), + }, + }); + operation + } + + #[cfg(any())] + fn test_rest_window_operation(base_url: &str, name: &str) -> Operation { + let mut operation = test_operation(base_url, name); + operation.display_name = "Window Logs".to_owned(); + operation.target = Target::Rest(RestTarget { + base_url: base_url.to_owned(), + method: HttpMethod::Get, + path_template: "/sse/logs".to_owned(), + static_headers: BTreeMap::new(), + }); + operation.input_schema = optional_object_schema("window"); + operation.output_schema = empty_object_schema(); + operation.input_mapping = MappingSet { + rules: vec![MappingRule { + source: "$.mcp.window".to_owned(), + target: "$.request.query.window".to_owned(), + required: false, + default_value: Some(json!("recent")), + transform: None, + condition: None, + notes: None, + }], + }; + operation.output_mapping = MappingSet { rules: Vec::new() }; + operation.tool_description = ToolDescription { + title: "Window Logs".to_owned(), + description: "Collects a bounded SSE log window".to_owned(), + tags: vec![ + "rest".to_owned(), + "streaming".to_owned(), + "window".to_owned(), + ], + examples: Vec::new(), + }; + operation.execution_config.streaming = Some(StreamingConfig { + mode: ExecutionMode::Window, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(1_000), + poll_interval_ms: None, + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: None, + max_session_lifetime_ms: None, + max_items: Some(3), + max_bytes: Some(16 * 1024), + aggregation_mode: AggregationMode::SummaryPlusSamples, + summary_path: None, + items_path: Some("$.items".to_owned()), + cursor_path: None, + status_path: None, + done_path: Some("$.done".to_owned()), + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig::default(), + }); + operation + } + + 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(), + } + } + + #[cfg(any())] + fn empty_object_schema() -> Schema { + Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } + } + + #[cfg(any())] + fn optional_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: false, + 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(), + } + } +} diff --git a/apps/ui/Dockerfile b/apps/ui/Dockerfile new file mode 100644 index 0000000..8130a9e --- /dev/null +++ b/apps/ui/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +COPY apps/ui/package.json apps/ui/package-lock.json ./ +RUN npm ci + +COPY apps/ui ./ +COPY Crank.png ./Crank.png + +RUN npm run build + +FROM nginx:1.27-alpine + +COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 3000 diff --git a/apps/ui/css/catalog.css b/apps/ui/css/catalog.css new file mode 100644 index 0000000..20003fd --- /dev/null +++ b/apps/ui/css/catalog.css @@ -0,0 +1,579 @@ +/* ── Page header ── */ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 32px; +} +.page-heading { + font-size: 24px; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--text-primary); + margin-bottom: 4px; +} +.page-subheading { font-size: 14px; color: var(--text-muted); } + +.btn-new { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 9px 18px; + border-radius: var(--radius); + background: var(--accent); + color: #fff; + font-size: 13.5px; + font-weight: 600; + border: 1px solid var(--accent-dim); + box-shadow: 0 1px 4px rgba(13,148,136,0.4); + transition: all 0.15s; + white-space: nowrap; + text-decoration: none; +} +.btn-new:hover { + background: var(--accent-dim); + box-shadow: 0 2px 10px rgba(13,148,136,0.5); + transform: translateY(-1px); +} + +/* ── Stats ── */ +.stats-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 28px; +} +.stat-card { background: var(--bg-surface); padding: 18px 22px; } +.stat-label { + font-size: 11px; + color: var(--text-muted); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 6px; +} +.stat-value { + font-size: 26px; + font-weight: 700; + letter-spacing: -1px; + color: var(--text-primary); +} +.stat-delta { font-size: 12px; color: var(--text-muted); margin-top: 3px; } +.stat-delta.up { color: var(--green); } +.stat-delta.down { color: var(--red); } + +/* ── Tabs ── */ +.tab-row { + display: flex; + align-items: center; + border-bottom: 1px solid var(--border); + margin-bottom: 20px; +} +.tab-item { + display: flex; + align-items: center; + gap: 7px; + padding: 10px 18px; + font-size: 14px; + font-weight: 450; + color: var(--text-muted); + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.1s; +} +.tab-item:hover { color: var(--text-secondary); } +.tab-item.active { + color: var(--text-primary); + font-weight: 600; + border-bottom-color: var(--accent-bright); +} +.tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0 6px; + height: 18px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + min-width: 20px; +} +.tab-item.active .tab-count { + background: var(--accent); + color: #fff; + border-color: var(--accent-dim); +} + +/* ── Filter bar ── */ +.filter-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.search-box { + display: flex; + align-items: center; + gap: 8px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 7px 12px; + width: 220px; + transition: border-color 0.15s, box-shadow 0.15s; +} +.search-box:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); +} +.search-box input { + border: none; + background: none; + outline: none; + font-size: 13.5px; + color: var(--text-primary); + width: 100%; + font-family: inherit; +} +.search-box input::placeholder { color: var(--text-muted); } +.search-box svg { color: var(--text-muted); flex-shrink: 0; } + +/* ── Dropdown filter chip ── */ +.filter-dropdown { position: relative; } + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-surface); + font-size: 13px; + color: var(--text-secondary); + transition: all 0.1s; + white-space: nowrap; +} +.filter-chip:hover { border-color: var(--bg-muted); background: var(--bg-overlay); color: var(--text-primary); } +.filter-chip.has-value { + border-color: var(--accent); + color: var(--accent-bright); + background: var(--accent-glow); +} +.filter-chip .chevron { + opacity: 0.6; + transition: transform 0.15s; +} +.filter-chip.open .chevron { transform: rotate(180deg); } + +.dropdown-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 180px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + z-index: 150; + overflow: hidden; + padding: 4px; +} +.dropdown-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: var(--radius-sm); + font-size: 13.5px; + color: var(--text-secondary); + border: none; + background: none; + width: 100%; + text-align: left; + cursor: pointer; + transition: all 0.1s; +} +.dropdown-item:hover { background: var(--bg-overlay); color: var(--text-primary); } +.dropdown-item.selected { color: var(--accent-bright); } +.dropdown-item .check { + opacity: 0; + color: var(--accent); +} +.dropdown-item.selected .check { opacity: 1; } +.dropdown-divider { + height: 1px; + background: var(--border-subtle); + margin: 4px 0; +} +.dropdown-clear { + display: flex; + align-items: center; + padding: 7px 10px; + font-size: 12px; + color: var(--text-muted); + border: none; + background: none; + width: 100%; + text-align: left; + cursor: pointer; + border-radius: var(--radius-sm); + transition: all 0.1s; +} +.dropdown-clear:hover { color: var(--red); background: var(--red-bg); } + +/* ── Filter right side ── */ +.filter-right { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; +} +.results-count { font-size: 13px; color: var(--text-muted); white-space: nowrap; } + +.sort-dropdown { position: relative; } +.sort-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-surface); + font-size: 13px; + color: var(--text-secondary); + transition: all 0.1s; + white-space: nowrap; +} +.sort-chip:hover { border-color: var(--bg-muted); background: var(--bg-overlay); color: var(--text-primary); } +.sort-chip .chevron { opacity: 0.6; transition: transform 0.15s; } +.sort-chip.open .chevron { transform: rotate(180deg); } + +/* ── Table ── */ +.table-wrap { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} +.table-wrap table { + width: 100%; + border-collapse: collapse; +} +.table-wrap thead tr { + background: var(--bg-canvas); + border-bottom: 1px solid var(--border); +} +.table-wrap th { + padding: 10px 16px; + text-align: left; + font-size: 11.5px; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + user-select: none; +} +.th-sortable { cursor: pointer; } +.th-sortable:hover { color: var(--text-secondary); } +.th-sortable.sort-active { color: var(--text-secondary); } +.sort-arrow { margin-left: 4px; opacity: 0.5; font-size: 11px; } +.sort-arrow.active { opacity: 1; color: var(--accent-bright); } + +.table-wrap tbody tr { + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; +} +.table-wrap tbody tr:last-child { border-bottom: none; } +.table-wrap tbody tr:hover { background: var(--bg-overlay); } +.table-wrap td { padding: 14px 16px; vertical-align: middle; } + +.op-name { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 13px; font-weight: 500; color: var(--text-primary); } +.op-display { font-size: 12px; color: var(--text-muted); margin-top: 3px; } + +/* ── Badges ── */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 11.5px; + font-weight: 700; + white-space: nowrap; + border: 1px solid; + letter-spacing: 0.02em; +} +.badge-rest { color: var(--blue); background: var(--blue-bg); border-color: var(--blue-border); } +.badge-graphql { color: var(--purple); background: var(--purple-bg); border-color: var(--purple-border); } +.badge-grpc { color: #14b8a6; background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); } + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: var(--radius-sm); + font-size: 12.5px; + font-weight: 500; +} +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.status-active { color: var(--green); background: var(--green-bg); } +.status-active .status-dot { background: var(--green); box-shadow: 0 0 0 2px rgba(63,185,80,0.2); } +.status-draft { color: var(--amber); background: var(--amber-bg); } +.status-draft .status-dot { background: var(--amber); } +.status-error { color: var(--red); background: var(--red-bg); } +.status-error .status-dot { background: var(--red); box-shadow: 0 0 0 2px rgba(248,81,73,0.2); } +.status-inactive { color: var(--text-muted); background: var(--bg-overlay); } +.status-inactive .status-dot { background: var(--text-muted); } + +.target-url-cell { + display: flex; + align-items: center; + gap: 6px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 12px; + color: var(--text-secondary); + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.target-url-cell a { color: inherit; text-decoration: none; } +.target-url-cell svg { flex-shrink: 0; opacity: 0.5; } + +.date-cell { font-size: 13px; color: var(--text-muted); white-space: nowrap; } + +.row-actions { display: flex; align-items: center; gap: 4px; justify-content: flex-end; } +.row-btn { + padding: 5px 11px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-surface); + font-size: 12.5px; + color: var(--text-secondary); + transition: all 0.1s; +} +.row-btn:hover { border-color: var(--bg-muted); color: var(--text-primary); background: var(--bg-overlay); } +.row-btn.danger:hover { border-color: var(--red-border); color: var(--red); background: var(--red-bg); } + +.row-btn-edit { + width: 34px; + height: 34px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); +} + +/* ── Empty state ── */ +.empty-state { + padding: 60px 20px; + text-align: center; + color: var(--text-muted); +} +.empty-state h3 { font-size: 16px; color: var(--text-secondary); margin-bottom: 8px; } +.empty-state p { font-size: 13.5px; } + +/* ── Table footer / pagination ── */ +.table-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-top: 1px solid var(--border); + background: var(--bg-canvas); +} +.table-footer-info { font-size: 13px; color: var(--text-muted); } + +.pagination { display: flex; align-items: center; gap: 4px; } +.page-btn { + width: 32px; + height: 32px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-surface); + font-size: 13px; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s; +} +.page-btn:hover:not(:disabled) { border-color: var(--bg-muted); color: var(--text-primary); } +.page-btn.active { background: var(--accent); color: #fff; border-color: var(--accent-dim); font-weight: 600; } +.page-btn:disabled { opacity: 0.3; cursor: not-allowed; } + +/* ── Banner ── */ +.banner { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 16px; + border-radius: var(--radius); + background: rgba(13,148,136,0.07); + border: 1px solid rgba(13,148,136,0.2); + color: var(--accent-bright); + font-size: 13.5px; + margin-bottom: 24px; +} +.banner-close { + margin-left: auto; + background: none; + border: none; + color: var(--accent-bright); + font-size: 16px; + opacity: 0.6; + line-height: 1; +} +.banner-close:hover { opacity: 1; } + +/* ═══════════════ RESPONSIVE ═══════════════ */ + +/* Tablet: ≤ 960px */ +@media (max-width: 960px) { + .stats-row { grid-template-columns: repeat(2, 1fr); } + .col-url { display: none; } + .results-count { display: none; } +} + +/* Narrow / mobile: ≤ 720px */ +@media (max-width: 720px) { + /* --- Скрываем колонку CREATED --- */ + .col-created { display: none; } + + /* --- Filter bar → grid --- */ + .filter-bar { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + } + /* Search во всю ширину */ + .search-box { + grid-column: 1 / -1; + width: 100%; + } + /* Каждый dropdown занимает ячейку */ + .filter-dropdown { + width: 100%; + } + .filter-chip { + width: 100%; + justify-content: space-between; + } + /* Sort справа, во всю строку */ + .filter-right { + grid-column: 1 / -1; + margin-left: 0; + justify-content: flex-end; + } + .sort-chip { white-space: nowrap; } + + /* action column compact */ + .row-actions { justify-content: center; } + + /* --- Таблица --- */ + .table-wrap td { padding: 12px 10px; } + .table-wrap th { padding: 8px 10px; } +} + +/* Mobile: ≤ 480px */ +@media (max-width: 480px) { + .tab-item { padding: 10px 8px; font-size: 13px; gap: 5px; } + + .page-header { flex-direction: column; gap: 12px; align-items: flex-start; } + .btn-new { width: 100%; justify-content: center; } + +} + +/* ── Active filter chips ── */ +.active-filters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 8px 0 4px; +} +.active-filter-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px 3px 10px; + background: rgba(13,148,136,0.12); + border: 1px solid rgba(13,148,136,0.3); + border-radius: 20px; + font-size: 12px; + color: var(--accent); +} +.active-filter-chip-remove { + display: flex; + align-items: center; + background: none; + border: none; + padding: 1px; + cursor: pointer; + color: var(--accent); + opacity: 0.7; + border-radius: 50%; + transition: opacity 0.1s; +} +.active-filter-chip-remove:hover { opacity: 1; } +.active-filter-clear-all { + background: none; + border: none; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; + padding: 3px 6px; + border-radius: 4px; + transition: color 0.1s; +} +.active-filter-clear-all:hover { color: var(--text-secondary); } + +/* ── Operation agent badges ── */ +.op-agent-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} +.op-agent-badge { + display: inline-block; + padding: 1px 6px; + font-size: 10px; + font-weight: 500; + background: rgba(99,102,241,0.12); + border: 1px solid rgba(99,102,241,0.25); + border-radius: 3px; + color: #818cf8; + white-space: nowrap; +} + +/* ── Row delete button ── */ +.row-btn-delete { + color: var(--text-muted); + transition: color 0.1s, background 0.1s; +} +.row-btn-delete:hover { + color: var(--red, #f87171); + background: rgba(248,113,113,0.08); +} diff --git a/apps/ui/css/layout.css b/apps/ui/css/layout.css new file mode 100644 index 0000000..b1b2870 --- /dev/null +++ b/apps/ui/css/layout.css @@ -0,0 +1,218 @@ +/* ── Navbar ── */ +.navbar { + height: 60px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 40px; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 1px 0 var(--border-subtle), 0 4px 16px rgba(0,0,0,0.4); +} + +.nav-logo { + display: flex; + align-items: center; + gap: 9px; + margin-right: 36px; + text-decoration: none; +} +.nav-logo-mark { + width: 28px; + height: 28px; + background: linear-gradient(135deg, #0d9488, #0891b2); + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 13px; + font-weight: 800; + flex-shrink: 0; +} +.nav-logo-text { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.4px; + color: var(--text-primary); +} + +.nav-links { + display: flex; + align-items: center; + gap: 2px; + flex: 1; +} +.nav-link { + padding: 6px 14px; + border-radius: var(--radius); + font-size: 14px; + font-weight: 450; + color: var(--text-muted); + text-decoration: none; + border: none; + background: none; + transition: all 0.1s; +} +.nav-link:hover { background: var(--bg-overlay); color: var(--text-secondary); } +.nav-link.active { color: var(--text-primary); font-weight: 550; background: var(--bg-overlay); } + +.nav-right { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; +} +.nav-icon-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + border: none; + background: none; + color: var(--text-muted); + transition: all 0.1s; + position: relative; +} +.nav-icon-btn:hover { background: var(--bg-overlay); color: var(--text-secondary); } +.nav-divider { width: 1px; height: 20px; background: var(--border); } + +.nav-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, #0d9488, #6366f1); + color: #fff; + font-size: 11.5px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 2px solid transparent; + transition: border-color 0.15s; +} +.nav-avatar:hover { border-color: var(--accent); } + +/* ── User dropdown ── */ +.user-menu { + position: relative; +} +.user-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 180px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + overflow: hidden; + z-index: 200; +} +.user-dropdown-header { + padding: 12px 14px; + border-bottom: 1px solid var(--border-subtle); +} +.user-dropdown-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} +.user-dropdown-role { + font-size: 11.5px; + color: var(--text-muted); + margin-top: 2px; +} +.user-dropdown-item { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 14px; + font-size: 13.5px; + color: var(--text-secondary); + border: none; + background: none; + width: 100%; + text-align: left; + transition: all 0.1s; +} +.user-dropdown-item:hover { background: var(--bg-overlay); color: var(--text-primary); } +.user-dropdown-item.danger:hover { background: var(--red-bg); color: var(--red); } +.user-dropdown-item svg { flex-shrink: 0; opacity: 0.7; } + +/* ── Page container ── */ +.page { + max-width: 1160px; + margin: 0 auto; + padding: 36px 40px 60px; +} + +/* ── Hamburger button (hidden on desktop) ── */ +.nav-hamburger { + display: none; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 5px; + width: 32px; + height: 32px; + border: none; + background: none; + cursor: pointer; + padding: 4px; + border-radius: var(--radius); + transition: background 0.1s; + order: 10; /* after other right items */ +} +.nav-hamburger:hover { background: var(--bg-overlay); } +.nav-hamburger span { + display: block; + width: 18px; + height: 1.5px; + background: var(--text-muted); + border-radius: 1px; + transition: all 0.2s; + transform-origin: center; +} +.nav-hamburger.open span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); } +.nav-hamburger.open span:nth-child(2) { opacity: 0; } +.nav-hamburger.open span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); } + +/* ── Mobile nav dropdown ── */ +.mobile-nav { + display: none; + position: sticky; + top: 60px; + z-index: 99; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + padding: 8px 16px 12px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} +.mobile-nav-link { + display: block; + padding: 10px 12px; + border-radius: var(--radius); + font-size: 14px; + font-weight: 500; + color: var(--text-muted); + text-decoration: none; + transition: all 0.1s; +} +.mobile-nav-link:hover { background: var(--bg-overlay); color: var(--text-secondary); } +.mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); } + +/* ── Responsive breakpoints ── */ +@media (max-width: 720px) { + .navbar { padding: 0 16px; } + .nav-links { display: none; } + .nav-hamburger { display: flex; } + .mobile-nav { display: block; } + + .page { padding: 20px 16px 40px; } +} diff --git a/apps/ui/css/login.css b/apps/ui/css/login.css new file mode 100644 index 0000000..ad23017 --- /dev/null +++ b/apps/ui/css/login.css @@ -0,0 +1,265 @@ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient(ellipse 70% 50% at 50% -20%, rgba(13,148,136,0.12) 0%, transparent 70%), + var(--bg-canvas); +} + +.login-card { + width: 100%; + max-width: 380px; +} + +.login-logo { + display: flex; + align-items: center; + gap: 10px; + justify-content: center; + margin-bottom: 36px; +} + +.login-logo-mark { + width: 36px; + height: 36px; + background: linear-gradient(135deg, #0d9488, #0891b2); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 17px; + font-weight: 800; +} + +.login-logo-text { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--text-primary); +} + +.login-box { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 32px; + box-shadow: 0 8px 40px rgba(0,0,0,0.4); +} + +.login-heading { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.4px; + color: var(--text-primary); + margin-bottom: 4px; +} + +.login-sub { + font-size: 13.5px; + color: var(--text-muted); + margin-bottom: 12px; +} + +.login-note { + margin-bottom: 24px; + padding: 10px 12px; + border-radius: 9px; + background: rgba(59, 130, 246, 0.08); + border: 1px solid rgba(59, 130, 246, 0.18); + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.5; +} + +.field { + margin-bottom: 16px; +} + +.field-label { + display: block; + font-size: 12.5px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.field-input { + width: 100%; + padding: 9px 12px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 7px; + font-family: 'Inter', sans-serif; + font-size: 13.5px; + color: var(--text-primary); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.field-input::placeholder { color: var(--text-muted); } + +.field-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(13,148,136,0.25); +} + +.field-footer { + display: flex; + justify-content: flex-end; + margin-top: 5px; +} + +.field-link { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--accent); + text-decoration: none; + background: transparent; + border: none; + padding: 0; +} + +.field-link:hover { text-decoration: underline; } + +.field-link.is-disabled, +.field-link:disabled { + color: var(--text-muted); + cursor: not-allowed; + text-decoration: none; +} + +.field-link.is-disabled:hover, +.field-link:disabled:hover { + text-decoration: none; +} + +.btn-signin { + width: 100%; + padding: 10px; + margin-top: 8px; + background: var(--accent); + color: #fff; + border: 1px solid var(--accent-dim); + border-radius: 7px; + font-family: 'Inter', sans-serif; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + box-shadow: 0 1px 4px rgba(13,148,136,0.4); +} + +.btn-signin:hover { + background: var(--accent-dim); + box-shadow: 0 2px 10px rgba(13,148,136,0.5), 0 0 0 3px rgba(13,148,136,0.25); +} + +.btn-signin:active { transform: translateY(1px); } + +.login-error { + display: none; + background: var(--red-bg); + border: 1px solid var(--red-border); + border-radius: 7px; + padding: 10px 14px; + font-size: 13px; + color: var(--red); + margin-bottom: 16px; +} + +.login-error.is-visible { + display: block; +} + +.login-footer { + text-align: center; + margin-top: 20px; + font-size: 12.5px; + color: var(--text-muted); +} + +.login-footer a { color: var(--accent); text-decoration: none; } +.login-footer a:hover { text-decoration: underline; } + +.login-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 22px 0; +} + +.login-divider-line { flex: 1; height: 1px; background: var(--border); } +.login-divider-text { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; } + +.btn-sso { + width: 100%; + padding: 9px; + background: var(--bg-overlay); + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: 7px; + font-family: 'Inter', sans-serif; + font-size: 13.5px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; +} + +.btn-sso:hover { background: var(--bg-muted); color: var(--text-primary); } + +.btn-sso:disabled { + cursor: not-allowed; + opacity: 0.7; + background: var(--bg-overlay); + color: var(--text-muted); +} + +.btn-sso:disabled:hover { + background: var(--bg-overlay); + color: var(--text-muted); +} + +.login-inline-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.24); + background: rgba(148, 163, 184, 0.08); + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.version-badge { + text-align: center; + margin-top: 28px; + font-size: 11.5px; + color: var(--text-muted); +} + +.login-footer-action { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: 6px; + color: var(--text-muted); + background: transparent; + border: none; + padding: 0; + cursor: not-allowed; + font: inherit; +} diff --git a/apps/ui/css/logs.css b/apps/ui/css/logs.css new file mode 100644 index 0000000..a449de3 --- /dev/null +++ b/apps/ui/css/logs.css @@ -0,0 +1,136 @@ +.log-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + border-bottom: 1px solid var(--border-subtle); + flex-wrap: wrap; +} + +.live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--green); + animation: pulse 1.8s ease-in-out infinite; + flex-shrink: 0; + cursor: pointer; +} + +.live-dot.is-paused { + animation-play-state: paused; + opacity: 0.35; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(63,185,80,0.6); } + 50% { opacity: 0.7; box-shadow: 0 0 0 5px rgba(63,185,80,0); } +} + +.live-label { + font-size: 11.5px; + font-weight: 600; + color: var(--green); + letter-spacing: 0.2px; + cursor: pointer; +} + +.live-label.is-paused { + color: var(--text-muted); +} + +.toolbar-sep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; } + +.time-range-select { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 5px 28px 5px 10px; + font-family: 'Inter', sans-serif; + font-size: 12.5px; + color: var(--text-secondary); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + cursor: pointer; + outline: none; +} + +.time-range-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); +} + +.log-op-name { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + color: var(--text-muted); + background: var(--bg-overlay); + border: 1px solid var(--border-subtle); + padding: 1px 7px; + border-radius: 4px; + white-space: nowrap; +} + +.log-status { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + font-weight: 600; +} + +.log-status.ok { color: var(--green); } +.log-status.err { color: var(--red); } +.log-status.warn { color: var(--amber); } + +.log-entry-expanded { + padding: 10px 20px 16px calc(20px + 152px + 12px + 58px + 12px); + background: var(--bg-canvas); + border-bottom: 1px solid var(--border-subtle); + display: none; +} + +.log-entry-expanded.open { display: block; } + +.log-detail-block { + background: #161b22; + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 14px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: #c9d1d9; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-all; +} + +.log-detail-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; + margin-top: 12px; +} + +.log-detail-label:first-child { margin-top: 0; } + +.refresh-btn { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 12.5px; + color: var(--text-muted); + cursor: pointer; + font-family: 'Inter', sans-serif; + transition: all 0.12s; +} + +.refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); } diff --git a/apps/ui/css/pages.css b/apps/ui/css/pages.css new file mode 100644 index 0000000..2246a98 --- /dev/null +++ b/apps/ui/css/pages.css @@ -0,0 +1,1755 @@ +/* ══════════════════════════════════════════════════ + SHARED INNER-PAGE STYLES + Used by: api-keys, logs, usage, settings +══════════════════════════════════════════════════ */ + +/* ── Dropdown divider (used in navbar and elsewhere) ── */ +.dropdown-divider { + height: 1px; + background: var(--border-subtle); + margin: 4px 0; +} + +/* ══════════════════════════════════════════════════ + PAGE HEADER +══════════════════════════════════════════════════ */ +.page-header { + display: flex; + align-items: flex-start; + gap: 16px; + margin-bottom: 28px; +} + +.page-header-text { flex: 1; } + +.page-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.4px; + color: var(--text-primary); + line-height: 1.2; +} + +.page-subtitle { + font-size: 13.5px; + color: var(--text-muted); + margin-top: 4px; + line-height: 1.5; +} + +.page-header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* ══════════════════════════════════════════════════ + BUTTONS +══════════════════════════════════════════════════ */ +.btn-primary { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 16px; + background: var(--accent); + color: #fff; + border: 1px solid var(--accent-dim); + border-radius: var(--radius); + font-size: 13.5px; + font-weight: 500; + font-family: 'Inter', sans-serif; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.btn-primary:hover { + background: var(--accent-dim); + box-shadow: 0 0 0 3px var(--accent-ring); +} + +.btn-primary:disabled, +.btn-primary.is-disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; +} + +.btn-primary:disabled:hover, +.btn-primary.is-disabled:hover { + background: var(--accent); + box-shadow: none; +} + +.btn-secondary { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 16px; + background: var(--bg-overlay); + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13.5px; + font-weight: 500; + font-family: 'Inter', sans-serif; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.btn-secondary:hover { background: var(--bg-muted); color: var(--text-primary); } + +.btn-danger { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 16px; + background: transparent; + color: var(--red); + border: 1px solid rgba(248, 81, 73, 0.3); + border-radius: var(--radius); + font-size: 13.5px; + font-weight: 500; + font-family: 'Inter', sans-serif; + cursor: pointer; + transition: all 0.15s; +} + +.btn-danger:hover { background: var(--red-bg); border-color: var(--red-border); } + +.btn-icon { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; + flex-shrink: 0; +} + +.btn-icon:hover { background: var(--bg-muted); color: var(--text-secondary); } + +.btn-icon.danger:hover { background: var(--red-bg); color: var(--red); border-color: var(--red-border); } + +/* ══════════════════════════════════════════════════ + TOASTS +══════════════════════════════════════════════════ */ +.toast-stack { + position: fixed; + top: 84px; + right: 20px; + z-index: 1200; + width: min(360px, calc(100vw - 32px)); + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; +} + +.toast-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(15, 23, 34, 0.96); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32); + pointer-events: auto; + transition: opacity 0.18s ease, transform 0.18s ease; +} + +.toast-card.closing { + opacity: 0; + transform: translateY(-6px); +} + +.toast-info { border-color: rgba(56, 189, 248, 0.28); } +.toast-success { border-color: rgba(63, 185, 80, 0.32); } +.toast-error { border-color: rgba(248, 81, 73, 0.34); } + +.toast-copy { + min-width: 0; + flex: 1; +} + +.toast-title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--text-primary); + margin-bottom: 3px; +} + +.toast-message { + font-size: 12.5px; + line-height: 1.5; + color: var(--text-secondary); +} + +.toast-close { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; +} + +.toast-close:hover { color: var(--text-primary); } + +/* ══════════════════════════════════════════════════ + SECTION CARD +══════════════════════════════════════════════════ */ +.section-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 20px; +} + +.section-card-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 12px; +} + +.section-card-title { + font-size: 13.5px; + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.section-card-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-top: 1px; +} + +.section-card-body { padding: 20px; } + +/* ══════════════════════════════════════════════════ + CALLOUT / INFO BOX +══════════════════════════════════════════════════ */ +.callout { + display: flex; + gap: 12px; + padding: 13px 16px; + border-radius: var(--radius); + margin-bottom: 20px; + border: 1px solid; + font-size: 13px; + line-height: 1.55; +} + +.callout.info { + background: rgba(88, 166, 255, 0.07); + border-color: var(--blue-border); + color: var(--text-secondary); +} + +.callout.warning { + background: var(--amber-bg); + border-color: var(--amber-border); + color: var(--text-secondary); +} + +.callout.danger { + background: var(--red-bg); + border-color: var(--red-border); + color: var(--text-secondary); +} + +.callout-icon { flex-shrink: 0; margin-top: 1px; } +.callout strong { color: var(--text-primary); } + +/* ══════════════════════════════════════════════════ + STAT CARDS +══════════════════════════════════════════════════ */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px 18px; +} + +.stat-label { + font-size: 11.5px; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.stat-value { + font-size: 28px; + font-weight: 700; + letter-spacing: -1px; + color: var(--text-primary); + line-height: 1; + margin-bottom: 4px; + font-variant-numeric: tabular-nums; +} + +.stat-delta { + font-size: 12px; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 4px; +} + +.stat-delta.up { color: var(--green); } +.stat-delta.down { color: var(--red); } + +/* ══════════════════════════════════════════════════ + DATA TABLE +══════════════════════════════════════════════════ */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13.5px; +} + +.data-table th { + padding: 10px 16px; + text-align: left; + font-size: 11.5px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.4px; + background: var(--bg-canvas); + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.data-table td { + padding: 12px 16px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; +} + +.data-table tbody tr:last-child td { border-bottom: none; } + +.data-table tbody tr:hover td { background: var(--bg-overlay); } + +.data-table .col-name { color: var(--text-primary); font-weight: 500; } +.data-table .col-mono { + font-family: 'JetBrains Mono', monospace; + font-size: 12.5px; + color: var(--text-secondary); +} + +.data-table .col-actions { + text-align: right; + white-space: nowrap; +} + +.data-table .col-actions { display: flex; justify-content: flex-end; gap: 6px; } + +/* ══════════════════════════════════════════════════ + BADGES +══════════════════════════════════════════════════ */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 11.5px; + font-weight: 500; + border: 1px solid; + white-space: nowrap; +} + +.badge-active { background: var(--green-bg); border-color: var(--green-border); color: var(--green); } +.badge-revoked { background: var(--red-bg); border-color: var(--red-border); color: var(--red); } +.badge-draft { background: var(--amber-bg); border-color: var(--amber-border); color: var(--amber); } +.badge-scope { + background: var(--bg-overlay); + border-color: var(--border); + color: var(--text-muted); + font-size: 10.5px; + padding: 1px 6px; +} + +.log-level { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: uppercase; + border: 1px solid; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.log-level.info { background: rgba(88,166,255,0.08); border-color: var(--blue-border); color: var(--blue); } +.log-level.warn { background: var(--amber-bg); border-color: var(--amber-border); color: var(--amber); } +.log-level.error { background: var(--red-bg); border-color: var(--red-border); color: var(--red); } +.log-level.debug { background: var(--bg-overlay); border-color: var(--border); color: var(--text-muted); } + +/* ══════════════════════════════════════════════════ + PAGE TABS +══════════════════════════════════════════════════ */ +.page-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border); + margin-bottom: 20px; +} + +.page-tab { + padding: 8px 16px; + font-size: 13.5px; + font-weight: 450; + color: var(--text-muted); + border: none; + background: none; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: all 0.15s; + font-family: 'Inter', sans-serif; + display: flex; + align-items: center; + gap: 7px; +} + +.page-tab:hover { color: var(--text-secondary); } +.page-tab.active { color: var(--text-primary); border-bottom-color: var(--accent); font-weight: 550; } + +.page-tab-count { + font-size: 11px; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0 6px; + color: var(--text-muted); + font-weight: 500; + min-width: 18px; + text-align: center; +} + +.page-tab.active .page-tab-count { + background: var(--accent-glow); + border-color: rgba(13,148,136,0.2); + color: var(--accent); +} + +/* ══════════════════════════════════════════════════ + FILTER BAR +══════════════════════════════════════════════════ */ +.filter-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.filter-bar-search { + position: relative; + flex: 1; + min-width: 180px; + max-width: 320px; +} + +.filter-bar-search svg { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; +} + +.filter-bar-search input { + width: 100%; + padding: 7px 12px 7px 32px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + font-family: 'Inter', sans-serif; + font-size: 13px; + color: var(--text-primary); + outline: none; + transition: border-color 0.15s; +} + +.filter-bar-search input::placeholder { color: var(--text-muted); } +.filter-bar-search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); } + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 12.5px; + font-weight: 450; + color: var(--text-muted); + cursor: pointer; + transition: all 0.12s; + font-family: 'Inter', sans-serif; +} + +.filter-chip:hover { background: var(--bg-muted); color: var(--text-secondary); } +.filter-chip.active { background: var(--accent-glow); border-color: rgba(13,148,136,0.3); color: var(--accent); } + +/* ══════════════════════════════════════════════════ + LOG ENTRY +══════════════════════════════════════════════════ */ +.log-list { + display: flex; + flex-direction: column; +} + +.log-entry { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 20px; + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; + font-size: 13px; + cursor: pointer; +} + +.log-entry:last-child { border-bottom: none; } +.log-entry:hover { background: var(--bg-overlay); } + +.log-time { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + color: var(--text-muted); + white-space: nowrap; + padding-top: 2px; + min-width: 152px; + flex-shrink: 0; +} + +.log-msg { + flex: 1; + line-height: 1.5; + color: var(--text-secondary); + word-break: break-word; +} + +.log-msg strong { color: var(--text-primary); font-weight: 500; } + +.log-duration { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; + padding-top: 2px; + min-width: 54px; + text-align: right; +} + +.log-duration.slow { color: var(--amber); } +.log-duration.error { color: var(--red); } + +/* ══════════════════════════════════════════════════ + SETTINGS LAYOUT +══════════════════════════════════════════════════ */ +.settings-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 32px; + align-items: flex-start; +} + +.settings-nav { + position: sticky; + top: 80px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.settings-nav-item { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 12px; + border-radius: var(--radius); + font-size: 13.5px; + color: var(--text-muted); + text-decoration: none; + transition: all 0.1s; + cursor: pointer; + border: none; + background: none; + font-family: 'Inter', sans-serif; + width: 100%; + text-align: left; +} + +.settings-nav-item svg { flex-shrink: 0; opacity: 0.7; } +.settings-nav-item:hover { background: var(--bg-overlay); color: var(--text-secondary); } +.settings-nav-item.active { background: var(--bg-overlay); color: var(--text-primary); font-weight: 500; } +.settings-nav-item.danger { color: var(--red); opacity: 0.8; } +.settings-nav-item.danger:hover { background: var(--red-bg); opacity: 1; } + +.settings-nav-divider { height: 1px; background: var(--border-subtle); margin: 6px 4px; } + +/* ── Form elements (for settings forms) ── */ +.field-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; } +.field-label { + font-size: 12.5px; + font-weight: 500; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; +} +.field-hint { font-size: 12px; color: var(--text-muted); line-height: 1.55; } +.field-input, +.field-select, +.field-textarea { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + font-family: 'Inter', sans-serif; + font-size: 13.5px; + color: var(--text-primary); + width: 100%; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +.field-input::placeholder, .field-textarea::placeholder { color: var(--text-muted); } +.field-input:focus, .field-select:focus, .field-textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); +} +.field-select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 11px center; + padding-right: 32px; + cursor: pointer; +} +.field-textarea { resize: vertical; min-height: 80px; line-height: 1.6; } +.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } + +/* ── Section divider ── */ +.settings-section-divider { + height: 1px; + background: var(--border); + margin: 24px 0; +} + +/* ── API key display ── */ +.key-reveal { + display: flex; + align-items: center; + gap: 8px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--text-secondary); + letter-spacing: 0.2px; +} + +.key-reveal .key-mask { flex: 1; letter-spacing: 2px; color: var(--text-muted); } +.key-reveal .key-copy { flex-shrink: 0; cursor: pointer; color: var(--text-muted); } +.key-reveal .key-copy:hover { color: var(--accent); } + +/* ══════════════════════════════════════════════════ + EMPTY STATE +══════════════════════════════════════════════════ */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 24px; + text-align: center; +} + +.empty-state-icon { + width: 44px; + height: 44px; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + margin-bottom: 14px; +} + +.empty-state-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} + +.empty-state-text { + font-size: 13.5px; + color: var(--text-muted); + max-width: 320px; + line-height: 1.6; + margin-bottom: 20px; +} + +/* ══════════════════════════════════════════════════ + MODAL OVERLAY +══════════════════════════════════════════════════ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + z-index: 500; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + display: none; +} + +.modal-overlay.open { display: flex; } + +.modal { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + width: 100%; + max-width: 480px; + box-shadow: 0 8px 40px rgba(0,0,0,0.6); + overflow: hidden; +} + +.modal-header { + padding: 18px 20px 14px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 12px; +} + +.modal-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.modal-close { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + border-radius: var(--radius); + transition: all 0.1s; +} +.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; } + +/* ══════════════════════════════════════════════════ + RESPONSIVE +══════════════════════════════════════════════════ */ +@media (max-width: 960px) { + .stats-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 720px) { + .page-header { flex-direction: column; gap: 12px; } + .page-header-actions { width: 100%; } + .settings-layout { grid-template-columns: 1fr; } + .settings-nav { + position: sticky; + top: 108px; + z-index: 40; + flex-direction: row; + flex-wrap: nowrap; + overflow-x: auto; + gap: 8px; + margin: 0 -4px 8px; + padding: 4px; + background: var(--bg-canvas); + scrollbar-width: none; + } + .settings-nav::-webkit-scrollbar { display: none; } + .settings-nav-item { + flex: 0 0 auto; + width: auto; + white-space: nowrap; + } + .section-anchor { scroll-margin-top: 136px; } + .filter-bar { flex-direction: column; align-items: stretch; } + .filter-bar-search { max-width: none; } + .stats-grid { grid-template-columns: 1fr 1fr; } + .field-row { grid-template-columns: 1fr; } +} + +@media (max-width: 480px) { + .stats-grid { grid-template-columns: 1fr; } +} + +/* ── Language switcher ── */ +.lang-switcher { + display: flex; + gap: 8px; +} + +.lang-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 18px; + background: var(--bg-canvas); + border: 1.5px solid var(--border); + border-radius: 8px; + font-family: 'Inter', sans-serif; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} + +.lang-btn:hover { + border-color: var(--accent); + color: var(--text-primary); +} + +.lang-btn.active { + border-color: var(--accent); + background: var(--accent-glow); + color: #fff; + font-weight: 600; +} + +.lang-flag { + font-size: 16px; + line-height: 1; +} + + +/* ═══════════════════════════════════════════════════ + Workspace switcher +═══════════════════════════════════════════════════ */ +.ws-switcher { + position: relative; + display: flex; + align-items: center; + margin-left: 4px; +} + +.ws-switcher-trigger { + display: flex; + align-items: center; + gap: 7px; + padding: 4px 8px 4px 4px; + background: none; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + transition: border-color 0.15s, background 0.15s; + max-width: 200px; + white-space: nowrap; +} +.ws-switcher-trigger:hover { border-color: var(--border-muted, #444c56); background: rgba(255,255,255,0.03); } + +.ws-dot { + width: 22px; + height: 22px; + border-radius: 5px; + background: #0d9488; + color: #fff; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.ws-current-name { + overflow: hidden; + text-overflow: ellipsis; + max-width: 140px; +} + +.ws-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 230px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 8px 28px rgba(0,0,0,0.45); + z-index: 200; + overflow: hidden; +} + +.ws-dropdown-label { + padding: 10px 14px 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.ws-dropdown-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + cursor: pointer; + transition: background 0.1s; +} +.ws-dropdown-item:hover { background: rgba(255,255,255,0.04); } +.ws-dropdown-item.active { background: rgba(56,139,253,0.08); } + +.ws-item-dot { + width: 30px; + height: 30px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.ws-item-info { flex: 1; min-width: 0; } +.ws-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ws-item-role { font-size: 11px; color: var(--text-muted); } + +.ws-dropdown-footer { border-top: 1px solid var(--border-subtle); padding: 6px 8px; } +.ws-dropdown-create { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + background: none; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); + transition: background 0.1s, color 0.1s; +} +.ws-dropdown-create:hover { background: rgba(255,255,255,0.04); color: var(--text-primary); } + + +/* ═══════════════════════════════════════════════════ + Agents page — info callout +═══════════════════════════════════════════════════ */ +.agents-info-callout { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: rgba(56,139,253,0.07); + border: 1px solid rgba(56,139,253,0.2); + border-radius: 8px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 24px; +} +.agents-info-callout strong { color: var(--text-primary); } + + +/* ═══════════════════════════════════════════════════ + Agent cards grid +═══════════════════════════════════════════════════ */ +.agents-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 16px; + margin-bottom: 40px; +} + +.agent-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 14px; + transition: border-color 0.15s; +} +.agent-card:hover { border-color: var(--border-muted, #444c56); } + +.agent-card-header { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.agent-card-icon { + width: 38px; + height: 38px; + border-radius: 9px; + background: rgba(56,139,253,0.1); + border: 1px solid rgba(56,139,253,0.2); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--accent); +} + +.agent-card-title-wrap { flex: 1; min-width: 0; } +.agent-card-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.agent-card-slug { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.agent-status-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 20px; + white-space: nowrap; + flex-shrink: 0; +} +.agent-status-published { background: rgba(63,185,80,0.15); color: #3fb950; border: 1px solid rgba(63,185,80,0.3); } +.agent-status-draft { background: rgba(139,148,158,0.15); color: #8b949e; border: 1px solid rgba(139,148,158,0.3); } +.agent-status-archived { background: rgba(210,153,34,0.15); color: #d29922; border: 1px solid rgba(210,153,34,0.3); } + +.agent-card-desc { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.55; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.agent-card-stats { + display: flex; + gap: 16px; + flex-wrap: wrap; +} +.agent-stat { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--text-muted); +} +.agent-stat-value { font-weight: 600; color: var(--text-secondary); } + +.agent-endpoint-row { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 6px; +} +.agent-endpoint-url { + flex: 1; + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.agent-endpoint-copy { + background: none; + border: none; + padding: 2px 4px; + cursor: pointer; + color: var(--text-muted); + border-radius: 4px; + display: flex; + align-items: center; + transition: color 0.1s, background 0.1s; + flex-shrink: 0; +} +.agent-endpoint-copy:hover { color: var(--accent); background: rgba(56,139,253,0.1); } + +.agent-card-footer { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-subtle); +} +.agent-card-date { + font-size: 11px; + color: var(--text-muted); + line-height: 1.45; +} + +.agent-card-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + width: 100%; +} +.agent-action-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 4px 10px; + background: none; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; + min-width: 0; + white-space: normal; + text-align: center; +} +.agent-action-btn:hover { border-color: var(--border-muted, #444c56); color: var(--text-primary); background: rgba(255,255,255,0.03); } +.agent-action-btn.danger:hover { border-color: var(--error, #f87171); color: var(--error, #f87171); background: rgba(248,113,113,0.06); } + + +/* ═══════════════════════════════════════════════════ + Drawer (side panel) +═══════════════════════════════════════════════════ */ +.drawer-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.45); + z-index: 150; + backdrop-filter: blur(2px); +} + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 520px; + max-width: 100vw; + background: var(--bg-surface); + border-left: 1px solid var(--border); + z-index: 151; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} +.drawer.open { transform: translateX(0); } + +.drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 24px 24px 20px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} +.drawer-title { font-size: 16px; font-weight: 600; color: var(--text-primary); } +.drawer-subtitle{ font-size: 13px; color: var(--text-muted); margin-top: 3px; } +.drawer-close { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + transition: border-color 0.15s, color 0.15s; + flex-shrink: 0; +} +.drawer-close:hover { border-color: var(--error, #f87171); color: var(--error, #f87171); } + +.drawer-body { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 28px; +} + +.drawer-section {} +.drawer-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} +.drawer-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 14px; +} +.drawer-section-header .drawer-section-title { margin-bottom: 0; } +.drawer-section-count { + font-size: 12px; + font-weight: 500; + color: var(--accent); + background: rgba(56,139,253,0.1); + padding: 2px 8px; + border-radius: 20px; +} +.drawer-section-sub { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 12px; + line-height: 1.5; +} + +.drawer-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 16px 24px; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; + background: var(--bg-surface); +} + + +/* ═══════════════════════════════════════════════════ + Agent status toggle (in drawer) +═══════════════════════════════════════════════════ */ +.agent-status-toggle { + display: flex; + gap: 0; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px; +} +.agent-status-opt { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + background: none; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.agent-status-opt.active { + background: var(--bg-surface); + color: var(--text-primary); + box-shadow: 0 1px 3px rgba(0,0,0,0.25); +} +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + + +/* ═══════════════════════════════════════════════════ + Operations picker (in drawer) +═══════════════════════════════════════════════════ */ +.ops-picker { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.ops-picker-search { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-canvas); +} +.ops-picker-search-input { + flex: 1; + background: none; + border: none; + outline: none; + font-size: 13px; + color: var(--text-primary); + font-family: inherit; +} +.ops-picker-search-input::placeholder { color: var(--text-muted); } +.ops-picker-clear { + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + border-radius: 3px; +} +.ops-picker-clear:hover { color: var(--text-secondary); } + +.ops-picker-list { + max-height: 260px; + overflow-y: auto; +} + +.ops-picker-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + cursor: pointer; + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; +} +.ops-picker-item:last-child { border-bottom: none; } +.ops-picker-item:hover { background: rgba(255,255,255,0.03); } +.ops-picker-item.selected { background: rgba(56,139,253,0.07); } + +.ops-picker-check { + width: 16px; + height: 16px; + border: 1.5px solid var(--border); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: border-color 0.1s, background 0.1s; +} +.ops-picker-item.selected .ops-picker-check { + border-color: var(--accent); + background: rgba(56,139,253,0.12); +} + +.ops-picker-info { flex: 1; min-width: 0; } +.ops-picker-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ops-picker-slug { + font-family: var(--font-mono, monospace); + font-size: 10px; + color: var(--text-muted); + margin-top: 1px; +} + +.ops-picker-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.ops-status-active { background: #3fb950; } +.ops-status-draft { background: #8b949e; } +.ops-status-error { background: #f85149; } +.ops-status-inactive { background: #8b949e; } + +.ops-picker-empty { + padding: 20px; + text-align: center; + font-size: 13px; + color: var(--text-muted); +} + +.ops-picker-footer { + padding: 8px 12px; + border-top: 1px solid var(--border-subtle); + background: var(--bg-canvas); + font-size: 12px; + color: var(--text-secondary); + display: flex; + align-items: center; +} + + +/* ═══════════════════════════════════════════════════ + Agents — recommendation callout +═══════════════════════════════════════════════════ */ +.agents-rec-callout { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 10px; + padding: 10px 12px; + background: rgba(210,153,34,0.08); + border: 1px solid rgba(210,153,34,0.25); + border-radius: 7px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +} +.agents-rec-callout svg { flex-shrink: 0; color: #d2991f; margin-top: 1px; } +.agents-rec-callout strong { color: var(--text-primary); } + + +/* ═══════════════════════════════════════════════════ + Settings — Members enhanced +═══════════════════════════════════════════════════ */ +.member-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border-subtle); +} +.member-row:last-child { border-bottom: none; } + +.member-last-active { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + min-width: 80px; + text-align: right; +} + +.member-role-select { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + padding: 4px 8px; + cursor: pointer; + outline: none; + transition: border-color 0.15s; + min-width: 110px; +} +.member-role-select:hover { border-color: var(--accent); } +.member-role-select:focus { border-color: var(--accent); } + +.member-remove-btn { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 6px; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s, color 0.15s, background 0.15s; + flex-shrink: 0; +} +.member-remove-btn:hover { + border-color: var(--error, #f87171); + color: var(--error, #f87171); + background: rgba(248,113,113,0.07); +} + +.member-role-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 9px; + border-radius: 20px; + white-space: nowrap; + flex-shrink: 0; +} +.role-owner { background: rgba(56,139,253,0.15); color: #58a6ff; border: 1px solid rgba(56,139,253,0.3); } +.role-admin { background: rgba(139,92,246,0.15); color: #a78bfa; border: 1px solid rgba(139,92,246,0.3); } +.role-developer { background: rgba(63,185,80,0.12); color: #3fb950; border: 1px solid rgba(63,185,80,0.25); } +.role-viewer { background: rgba(139,148,158,0.15); color: #8b949e; border: 1px solid rgba(139,148,158,0.3); } + +.role-def-row { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 11px 20px; + border-bottom: 1px solid var(--border-subtle); +} +.role-def-row:last-child { border-bottom: none; } +.role-def-text { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* ── Workspace dropdown management links ── */ +.ws-dropdown-divider { + height: 1px; + background: var(--border-subtle); + margin: 4px 0; +} +.ws-dropdown-mgmt-link { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + font-size: 13px; + color: var(--text-secondary); + text-decoration: none; + transition: background 0.1s, color 0.1s; + cursor: pointer; +} +.ws-dropdown-mgmt-link:hover { + background: rgba(255,255,255,0.04); + color: var(--text-primary); +} +.ws-dropdown-mgmt-link svg { + flex-shrink: 0; + opacity: 0.7; +} + +@media (max-width: 900px) { + .toast-stack { + top: 72px; + right: 16px; + left: 16px; + width: auto; + } +} + +.list-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.list-toolbar-group { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.page-select { + min-height: 36px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-overlay); + color: var(--text-secondary); + font-size: 13px; + font-family: 'Inter', sans-serif; +} + +.resource-list { + display: grid; + gap: 14px; +} + +.resource-card { + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: 14px; + padding: 16px; +} + +.resource-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; +} + +.resource-card-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.resource-card-subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); + word-break: break-all; +} + +.resource-card-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.resource-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.resource-meta-item { + min-width: 0; +} + +.resource-meta-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 4px; +} + +.resource-meta-value { + font-size: 13px; + color: var(--text-primary); + line-height: 1.5; + word-break: break-word; +} + +.resource-detail-block { + margin-top: 12px; + border-top: 1px solid var(--border-subtle); + padding-top: 12px; +} + +.resource-detail-title { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.resource-detail-pre { + margin: 0; + padding: 12px 14px; + border-radius: 12px; + background: rgba(12, 17, 24, 0.8); + border: 1px solid var(--border-subtle); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.resource-pill-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} + +.resource-status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); +} + +.resource-status-pill.active, +.resource-status-pill.running, +.resource-status-pill.succeeded { + border-color: rgba(63, 185, 80, 0.28); + background: rgba(63, 185, 80, 0.12); + color: #71dd8a; +} + +.resource-status-pill.idle, +.resource-status-pill.pending { + border-color: rgba(110, 118, 129, 0.28); + background: rgba(110, 118, 129, 0.12); + color: var(--text-secondary); +} + +.resource-status-pill.failed, +.resource-status-pill.cancelled, +.resource-status-pill.stopped { + border-color: rgba(248, 81, 73, 0.28); + background: rgba(248, 81, 73, 0.12); + color: #ff8a83; +} + +.resource-status-pill.completed { + border-color: rgba(56, 189, 248, 0.28); + background: rgba(56, 189, 248, 0.12); + color: #79d8ff; +} diff --git a/apps/ui/css/settings.css b/apps/ui/css/settings.css new file mode 100644 index 0000000..339a2aa --- /dev/null +++ b/apps/ui/css/settings.css @@ -0,0 +1,205 @@ +.avatar-upload { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 4px; +} + +.avatar-large { + width: 52px; + height: 52px; + border-radius: 50%; + background: linear-gradient(135deg, #0d9488, #6366f1); + color: #fff; + font-size: 18px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: 2px solid var(--border); +} + +.avatar-upload-actions { flex: 1; } +.avatar-name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 3px; } +.avatar-sub { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; } + +.save-bar { + position: sticky; + bottom: 0; + background: rgba(13,17,23,0.9); + backdrop-filter: blur(12px); + border-top: 1px solid var(--border); + padding: 12px 20px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: auto; + display: none; +} + +.save-bar.visible { display: flex; } + +.section-anchor { scroll-margin-top: 80px; } + +.danger-zone-action { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.danger-zone-action:last-child { border-bottom: none; } + +.danger-zone-text { flex: 1; } +.danger-zone-title { font-size: 13.5px; font-weight: 500; color: var(--text-primary); margin-bottom: 3px; } +.danger-zone-desc { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; } + +.toggle-setting-row { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.toggle-setting-row:last-child { border-bottom: none; } + +.toggle-sm { + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--bg-muted); + border: 1px solid var(--border); + position: relative; + flex-shrink: 0; + cursor: pointer; + transition: background 0.2s; +} + +.toggle-sm.on { background: var(--accent); border-color: var(--accent-dim); } + +.toggle-sm::after { + content: ''; + position: absolute; + left: 2px; top: 1px; + width: 16px; height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + transition: transform 0.2s; +} + +.toggle-sm.on::after { transform: translateX(16px); } + +.toggle-setting-text { flex: 1; } +.toggle-setting-label { font-size: 13.5px; font-weight: 500; color: var(--text-primary); } +.toggle-setting-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; } + +.member-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.member-row:last-child { border-bottom: none; } + +.member-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--bg-muted); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + color: var(--text-secondary); + flex-shrink: 0; +} + +.member-info { flex: 1; } +.member-name { font-size: 13.5px; color: var(--text-primary); font-weight: 500; } +.member-email { font-size: 12px; color: var(--text-muted); } + +.member-role-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid; +} + +.role-admin { background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); color: var(--accent); } +.role-member { background: var(--bg-overlay); border-color: var(--border); color: var(--text-muted); } +.role-viewer { background: rgba(88,166,255,0.07); border-color: var(--blue-border); color: var(--blue); } + +.planned-section-title { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + font-size: 13.5px; + font-weight: 600; + color: var(--text-primary); +} + +.planned-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid rgba(245, 158, 11, 0.24); + background: rgba(245, 158, 11, 0.09); + color: var(--amber); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.planned-badge.small { + padding: 1px 7px; +} + +.planned-list { + display: grid; + gap: 12px; +} + +.planned-item { + padding: 14px 16px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); +} + +.planned-item-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} + +.planned-item-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.planned-item-desc { + font-size: 12.5px; + line-height: 1.5; + color: var(--text-muted); +} diff --git a/apps/ui/css/usage.css b/apps/ui/css/usage.css new file mode 100644 index 0000000..14fc42e --- /dev/null +++ b/apps/ui/css/usage.css @@ -0,0 +1,145 @@ +.period-select { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 7px 28px 7px 11px; + font-family: 'Inter', sans-serif; + font-size: 13px; + color: var(--text-secondary); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + cursor: pointer; + outline: none; + transition: border-color 0.15s; +} + +.period-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); +} + +.chart-placeholder { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + height: 248px; + display: flex; + align-items: stretch; + padding: 12px 16px; + gap: 4px; + overflow: hidden; + position: relative; +} + +.chart-col { + flex: 1; + min-width: 0; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.chart-col-bars { + flex: 1; + width: 100%; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 2px; +} + +.chart-bar { + flex: 0 0 auto; + border-radius: 3px 3px 0 0; + min-width: 0; + width: 100%; + transition: opacity 0.15s; + position: relative; + cursor: pointer; +} + +.chart-bar:hover { opacity: 0.8; } +.chart-bar:hover::after { + content: attr(data-tip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 5px; + padding: 4px 8px; + font-size: 11px; + color: var(--text-primary); + white-space: nowrap; + pointer-events: none; + margin-bottom: 4px; +} + +.chart-bar.success { background: linear-gradient(180deg, var(--accent) 0%, rgba(13,148,136,0.4) 100%); } +.chart-bar.error { background: linear-gradient(180deg, var(--red) 0%, rgba(248,81,73,0.3) 100%); } + +.chart-col-label { + flex: 0 0 auto; + width: 100%; + text-align: center; + font-size: 10.5px; + line-height: 1; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chart-wrap { + position: relative; + padding-bottom: 24px; +} + +.chart-y-grid { + position: absolute; + left: 0; right: 0; top: 0; bottom: 24px; + display: flex; + flex-direction: column; + justify-content: space-between; + pointer-events: none; +} + +.chart-y-line { + width: 100%; + height: 1px; + background: var(--border-subtle); +} + +.quota-bar-wrap { + height: 8px; + background: var(--bg-muted); + border-radius: 4px; + overflow: hidden; + margin: 8px 0 4px; +} + +.quota-bar-fill { + height: 100%; + border-radius: 4px; + background: linear-gradient(90deg, var(--accent), #7c3aed); +} + +.latency-bar { + display: flex; + height: 12px; + border-radius: 3px; + overflow: hidden; + gap: 2px; +} + +.latency-seg { border-radius: 2px; } +.latency-p50 { background: var(--accent); } +.latency-p95 { background: var(--amber); } +.latency-p99 { background: var(--red); } diff --git a/apps/ui/css/variables.css b/apps/ui/css/variables.css new file mode 100644 index 0000000..b48a1a7 --- /dev/null +++ b/apps/ui/css/variables.css @@ -0,0 +1,64 @@ +:root { + --bg-canvas: #0d1117; + --bg-surface: #161b22; + --bg-overlay: #21262d; + --bg-muted: #30363d; + --border: rgba(48, 54, 61, 0.9); + --border-subtle: rgba(33, 38, 45, 0.9); + + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + + --accent: #0d9488; + --accent-dim: #0f766e; + --accent-bright: #14b8a6; + --accent-glow: rgba(13, 148, 136, 0.15); + --accent-ring: rgba(13, 148, 136, 0.25); + + --red: #f85149; + --red-bg: rgba(248, 81, 73, 0.10); + --red-border: rgba(248, 81, 73, 0.25); + --amber: #d29922; + --amber-bg: rgba(210, 153, 34, 0.10); + --amber-border: rgba(210, 153, 34, 0.25); + --green: #3fb950; + --green-bg: rgba(63, 185, 80, 0.10); + --green-border: rgba(63, 185, 80, 0.25); + --blue: #58a6ff; + --blue-bg: rgba(88, 166, 255, 0.08); + --blue-border: rgba(88, 166, 255, 0.20); + --purple: #bc8cff; + --purple-bg: rgba(188, 140, 255, 0.08); + --purple-border: rgba(188, 140, 255, 0.20); + + --radius-sm: 5px; + --radius: 7px; + --radius-lg: 10px; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3); +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + min-width: 360px; + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg-canvas); + color: var(--text-primary); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { color: inherit; text-decoration: none; } +button { cursor: pointer; font-family: inherit; } + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg-muted); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #484f58; } +::selection { background: var(--accent-glow); color: var(--text-primary); } diff --git a/apps/ui/css/wizard.css b/apps/ui/css/wizard.css new file mode 100644 index 0000000..7e17c9d --- /dev/null +++ b/apps/ui/css/wizard.css @@ -0,0 +1,2000 @@ +/* ══════════════════════════════════════════════════ + WIZARD — shell +══════════════════════════════════════════════════ */ +.wizard-page { + padding-top: 60px; +} + +.wizard-page .navbar { + position: fixed; + top: 0; + left: 0; + right: 0; +} + +.wizard-page .progress-strip { + position: fixed; + top: 60px; + left: 0; + right: 0; +} + +.wizard-shell { + min-height: calc(100vh - 60px); + display: flex; + flex-direction: column; + padding-top: 56px; +} + +/* ══════════════════════════════════════════════════ + TOP PROGRESS STRIP +══════════════════════════════════════════════════ */ +.progress-strip { + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + padding: 0 40px; + display: flex; + align-items: center; + height: 56px; + gap: 20px; + z-index: 30; + box-shadow: 0 1px 0 var(--border-subtle), 0 4px 16px rgba(0, 0, 0, 0.4); +} + +.back-to-catalog-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + background: none; + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + font-family: 'Inter', sans-serif; + transition: all 0.15s; + white-space: nowrap; + text-decoration: none; +} + +.back-to-catalog-btn:hover { + background: var(--bg-overlay); + color: var(--text-primary); + border-color: var(--bg-muted); +} + +.progress-divider { + width: 1px; + height: 20px; + background: var(--border); + margin: 0 4px; +} + +.progress-label { + font-size: 13px; + color: var(--text-secondary); + white-space: nowrap; +} + +.progress-bar-wrap { + flex: 1; + height: 4px; + background: var(--bg-muted); + border-radius: 10px; + overflow: hidden; + min-width: 80px; +} + +.progress-bar-fill { + height: 100%; + width: 20%; + background: linear-gradient(90deg, var(--accent), #7c3aed); + border-radius: 10px; + transition: width 0.4s ease; + position: relative; +} + +.progress-bar-fill::after { + content: ''; + position: absolute; + right: -1px; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background: #7c3aed; + box-shadow: 0 0 0 2px var(--bg-surface), 0 0 8px rgba(124, 58, 237, 0.6); +} + +.progress-pct { + font-size: 12px; + font-weight: 600; + color: var(--accent); + white-space: nowrap; + min-width: 34px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.progress-close { + margin-left: 4px; + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; +} + +.progress-close:hover { + background: var(--bg-overlay); + color: var(--text-primary); + border-color: var(--bg-muted); +} + +/* ══════════════════════════════════════════════════ + WIZARD BODY +══════════════════════════════════════════════════ */ +.wizard-body { + flex: 1; + display: flex; + max-width: 1100px; + width: 100%; + margin: 0 auto; + padding: 40px 40px 140px; + gap: 36px; + align-items: flex-start; +} + +.checkbox-pill { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 38px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-overlay); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease; +} + +.checkbox-pill:hover { + border-color: var(--bg-muted); + color: var(--text-primary); +} + +.checkbox-pill input { + width: 15px; + height: 15px; + accent-color: var(--accent); +} + +.checkbox-pill:has(input:checked) { + border-color: rgba(13, 148, 136, 0.35); + background: rgba(13, 148, 136, 0.12); + color: var(--text-primary); +} + +/* ══════════════════════════════════════════════════ + STEP SIDEBAR +══════════════════════════════════════════════════ */ +.step-sidebar { + width: 248px; + flex-shrink: 0; + position: sticky; + top: 136px; +} + +.step-sidebar-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} + +.step-sidebar-header { + padding: 16px 18px 14px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 10px; +} + +.step-sidebar-logo-mark { + width: 24px; + height: 24px; + border-radius: 6px; + background: linear-gradient(135deg, var(--accent), #7c3aed); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 11px; + font-weight: 800; + flex-shrink: 0; +} + +.step-sidebar-brand { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.2px; +} + +.step-sidebar-brand span { + color: var(--text-muted); + font-weight: 400; +} + +.step-sidebar-body { + padding: 18px 14px 14px; +} + +.step-sidebar-section-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--text-muted); + padding: 0 4px; + margin-bottom: 14px; +} + +/* Step list */ +.steps-list { + display: flex; + flex-direction: column; + position: relative; +} + +.steps-list::before { + content: ''; + position: absolute; + left: 27px; + top: 32px; + bottom: 40px; + width: 1px; + background: var(--border); + z-index: 0; +} + +.step-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 9px 10px; + border-radius: 8px; + cursor: pointer; + position: relative; + z-index: 1; + transition: background 0.15s; + border: 1px solid transparent; +} + +.step-item:hover { background: var(--bg-overlay); } + +.step-item.active { + background: var(--accent-glow); + border-color: rgba(13, 148, 136, 0.2); +} + +.step-item.pending:hover { background: rgba(33, 38, 45, 0.6); } + +/* Step indicator circle */ +.step-indicator { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 12px; + font-weight: 700; + border: 2px solid var(--step-pending); + background: var(--bg-overlay); + color: var(--text-muted); + transition: all 0.2s; + position: relative; + z-index: 2; +} + +.step-item.done .step-indicator { + background: var(--step-done); + border-color: var(--step-done); + color: var(--bg-canvas); + box-shadow: 0 0 0 3px rgba(63, 185, 80, 0.15); +} + +.step-item.active .step-indicator { + background: var(--bg-surface); + border-color: var(--step-active); + color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-ring); + font-weight: 700; +} + +.step-item.pending .step-indicator { + background: var(--bg-overlay); + border-color: var(--step-pending); + color: var(--text-muted); +} + +/* Step text content */ +.step-content { + flex: 1; + min-width: 0; + padding-top: 3px; +} + +.step-number { + font-size: 10.5px; + color: var(--text-muted); + font-weight: 500; + margin-bottom: 1px; + letter-spacing: 0.2px; +} + +.step-name { + font-size: 13.5px; + font-weight: 500; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.step-item.done .step-name { color: var(--text-primary); } +.step-item.active .step-name { color: var(--accent-bright); font-weight: 600; } +.step-item.pending .step-name { color: var(--text-muted); } + +.step-status-text { + font-size: 11px; + margin-top: 2px; +} + +.step-item.done .step-status-text { color: var(--step-done); } +.step-item.active .step-status-text { color: var(--accent); } +.step-item.pending .step-status-text { color: var(--text-muted); } + +/* Sidebar footer help card */ +.sidebar-help { + margin-top: 4px; + background: var(--bg-overlay); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 14px 16px; +} + +.sidebar-help-title { + font-size: 12.5px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; +} + +.sidebar-help-title svg { opacity: 0.7; flex-shrink: 0; } + +.sidebar-help-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.6; +} + +.sidebar-help-link { + color: var(--accent); + text-decoration: none; + font-weight: 500; +} + +.sidebar-help-link:hover { text-decoration: underline; } + +/* ══════════════════════════════════════════════════ + MAIN STEP PANEL +══════════════════════════════════════════════════ */ +.step-panel { + flex: 1; + min-width: 0; +} + +.step-panel-header { + margin-bottom: 28px; +} + +.step-panel-eyebrow { + display: flex; + align-items: center; + gap: 8px; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 10px; +} + +.step-panel-eyebrow-line { + width: 20px; + height: 2px; + background: var(--accent); + border-radius: 1px; + flex-shrink: 0; +} + +.step-panel-title { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.6px; + color: var(--text-primary); + margin-bottom: 8px; + line-height: 1.2; +} + +.step-panel-subtitle { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.65; + max-width: 580px; +} + +/* ── Protocol cards ── */ +.protocol-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 24px; +} + +.protocol-card { + background: var(--bg-surface); + border: 1.5px solid var(--border); + border-radius: 10px; + padding: 20px 18px 18px; + cursor: pointer; + transition: all 0.18s ease; + position: relative; + overflow: hidden; +} + +.protocol-card:hover { + border-color: var(--accent); + background: rgba(13, 148, 136, 0.06); + transform: translateY(-1px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(13, 148, 136, 0.15); +} + +.protocol-card.selected { + border-color: var(--accent); + background: var(--accent-glow); + box-shadow: + 0 0 0 1px var(--accent), + 0 4px 24px rgba(0, 0, 0, 0.5), + 0 0 20px rgba(13, 148, 136, 0.12); +} + +.protocol-card-check { + position: absolute; + top: 12px; + right: 12px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: scale(0.6); + transition: all 0.18s ease; + flex-shrink: 0; +} + +.protocol-card.selected .protocol-card-check { + opacity: 1; + transform: scale(1); +} + +.protocol-card-check svg { + width: 10px; + height: 10px; + stroke: #fff; + stroke-width: 2.5; + fill: none; +} + +.protocol-icon-wrap { + width: 44px; + height: 44px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; + flex-shrink: 0; + transition: all 0.18s; +} + +.protocol-card.rest .protocol-icon-wrap { + background: rgba(88, 166, 255, 0.12); + border: 1px solid rgba(88, 166, 255, 0.2); +} + +.protocol-card.graphql .protocol-icon-wrap { + background: rgba(188, 140, 255, 0.12); + border: 1px solid rgba(188, 140, 255, 0.2); +} + +.protocol-card.grpc .protocol-icon-wrap { + background: rgba(13, 148, 136, 0.12); + border: 1px solid rgba(13, 148, 136, 0.2); +} + +.protocol-card.selected.rest .protocol-icon-wrap { + background: rgba(88, 166, 255, 0.18); + border-color: rgba(88, 166, 255, 0.35); +} + +.protocol-card.selected.graphql .protocol-icon-wrap { + background: rgba(188, 140, 255, 0.18); + border-color: rgba(188, 140, 255, 0.35); +} + +.protocol-card.selected.grpc .protocol-icon-wrap { + background: rgba(13, 148, 136, 0.2); + border-color: rgba(13, 148, 136, 0.4); +} + +.protocol-card-name { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; + letter-spacing: -0.2px; +} + +.protocol-card.selected .protocol-card-name { color: #fff; } + +.protocol-card-tagline { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 14px; + line-height: 1.5; +} + +.protocol-card.selected .protocol-card-tagline { color: var(--accent-bright); } + +.protocol-card-tags { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.protocol-tag { + font-size: 10.5px; + font-weight: 500; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg-overlay); + color: var(--text-muted); + letter-spacing: 0.1px; + transition: all 0.15s; +} + +.protocol-card.selected .protocol-tag { + background: rgba(13, 148, 136, 0.12); + border-color: rgba(13, 148, 136, 0.3); + color: var(--accent-bright); +} + +.protocol-card.rest.selected .protocol-tag { + background: rgba(88, 166, 255, 0.1); + border-color: rgba(88, 166, 255, 0.25); + color: #58a6ff; +} + +.protocol-card.graphql.selected .protocol-tag { + background: rgba(188, 140, 255, 0.1); + border-color: rgba(188, 140, 255, 0.25); + color: #bc8cff; +} + +/* ── REST method picker ── */ +.method-section { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + margin-bottom: 20px; +} + +.method-section-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 10px; +} + +.method-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.method-section-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-left: auto; +} + +.method-section-body { + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.method-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} + +.method-btn { + padding: 10px 8px; + border-radius: 7px; + border: 1.5px solid var(--border); + background: var(--bg-overlay); + cursor: pointer; + transition: all 0.15s; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + font-family: 'Inter', sans-serif; +} + +.method-btn:hover { border-color: var(--bg-muted); background: rgba(33, 38, 45, 0.8); } + +.method-btn.selected-get { border-color: #3fb950; background: rgba(63, 185, 80, 0.08); } +.method-btn.selected-post { border-color: #58a6ff; background: rgba(88, 166, 255, 0.08); } +.method-btn.selected-put { border-color: #d29922; background: rgba(210, 153, 34, 0.08); } +.method-btn.selected-patch { border-color: #bc8cff; background: rgba(188, 140, 255, 0.08); } +.method-btn.selected-delete { border-color: #f85149; background: rgba(248, 81, 73, 0.08); } + +.method-verb { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + font-variant-numeric: tabular-nums; + color: var(--text-muted); +} + +.method-btn.selected-get .method-verb { color: #3fb950; } +.method-btn.selected-post .method-verb { color: #58a6ff; } +.method-btn.selected-put .method-verb { color: #d29922; } +.method-btn.selected-patch .method-verb { color: #bc8cff; } +.method-btn.selected-delete .method-verb { color: #f85149; } + +.method-label { font-size: 10px; color: var(--text-muted); } + +/* ── Info callout ── */ +.info-callout { + display: flex; + gap: 12px; + padding: 14px 16px; + background: rgba(13, 148, 136, 0.07); + border: 1px solid rgba(13, 148, 136, 0.2); + border-radius: 8px; +} + +.info-callout.info-callout-subtle { + background: rgba(148, 163, 184, 0.08); + border-color: rgba(148, 163, 184, 0.18); +} + +.info-callout.is-success { + background: rgba(13, 148, 136, 0.07); + border-color: rgba(13, 148, 136, 0.28); +} + +.info-callout.is-error { + background: rgba(248, 113, 113, 0.08); + border-color: rgba(248, 113, 113, 0.28); +} + +.info-callout-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + margin-top: 1px; + color: var(--accent); +} + +.info-callout.info-callout-subtle .info-callout-icon { + color: var(--text-secondary); +} + +.info-callout.is-error .info-callout-icon, +.info-callout.is-error .info-callout-title { + color: #f87171; +} + +.info-callout.is-success .info-callout-title { + color: var(--accent-bright); +} + +.info-callout-body { flex: 1; } + +.info-callout-title { + font-size: 12.5px; + font-weight: 600; + color: var(--accent-bright); + margin-bottom: 4px; +} + +.info-callout-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.65; +} + +.info-callout-text a { + color: var(--accent); + text-decoration: none; + font-weight: 500; +} + +.info-callout-text a:hover { text-decoration: underline; } + +/* ── Config card ── */ +.config-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + margin-bottom: 20px; +} + +.config-card-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 12px; +} + +.config-card-header-icon { + width: 28px; + height: 28px; + border-radius: 7px; + background: var(--bg-overlay); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; +} + +.config-card-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.config-card-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-top: 1px; +} + +.config-card-optional { + margin-left: auto; + font-size: 10.5px; + font-weight: 500; + color: var(--text-muted); + background: var(--bg-overlay); + border: 1px solid var(--border-subtle); + padding: 2px 8px; + border-radius: 10px; +} + +.config-card-body { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Form fields */ +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.form-group { display: flex; flex-direction: column; gap: 6px; } + +.form-label { + font-size: 12.5px; + font-weight: 500; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; +} + +.form-label-required { + font-size: 10px; + font-weight: 600; + padding: 1px 5px; + border-radius: 4px; + background: rgba(248, 81, 73, 0.12); + color: var(--red); + border: 1px solid rgba(248, 81, 73, 0.2); + letter-spacing: 0.2px; +} + +.form-label-optional { + font-size: 11px; + color: var(--text-muted); + font-weight: 400; +} + +.form-hint { + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.55; +} + +.form-input, +.form-textarea, +.form-select { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 6px; + padding: 9px 12px; + font-family: 'Inter', sans-serif; + font-size: 13.5px; + color: var(--text-primary); + width: 100%; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + appearance: none; + -webkit-appearance: none; +} + +.form-input::placeholder, +.form-textarea::placeholder { color: var(--text-muted); } + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); +} + +.form-select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 11px center; + padding-right: 32px; + cursor: pointer; +} + +.form-textarea { + resize: vertical; + min-height: 90px; + line-height: 1.6; +} + +/* Toggle switch */ +.toggle-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 8px; + background: var(--bg-overlay); + border: 1px solid var(--border-subtle); + cursor: pointer; + transition: background 0.15s; +} + +.toggle-row:hover { background: rgba(33, 38, 45, 0.8); } + +.toggle { + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--bg-muted); + border: 1px solid var(--border); + position: relative; + flex-shrink: 0; + transition: background 0.2s; + cursor: pointer; +} + +.toggle.on { + background: var(--accent); + border-color: var(--accent-dim); +} + +.toggle::after { + content: ''; + position: absolute; + left: 2px; + top: 1px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + transition: transform 0.2s ease; +} + +.toggle.on::after { transform: translateX(16px); } + +.toggle-text { flex: 1; } +.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; } + +/* ══════════════════════════════════════════════════ + BOTTOM ACTION BAR — frosted dark glass +══════════════════════════════════════════════════ */ +.action-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: rgba(13, 17, 23, 0.82); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); + border-top: 1px solid rgba(48, 54, 61, 0.7); + z-index: 50; + box-shadow: + 0 -1px 0 rgba(255, 255, 255, 0.03), + 0 -8px 32px rgba(0, 0, 0, 0.6); +} + +.action-bar-inner { + max-width: 1100px; + margin: 0 auto; + padding: 0 40px; + height: 70px; + display: flex; + align-items: center; + gap: 12px; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 18px; + border-radius: 7px; + font-family: 'Inter', sans-serif; + font-size: 13.5px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + border: none; + white-space: nowrap; +} + +.btn-back { + background: var(--bg-overlay); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.btn-back:hover { + background: var(--bg-muted); + color: var(--text-primary); + border-color: var(--bg-muted); +} + +.btn-back:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.btn-back:disabled:hover { + background: var(--bg-overlay); + color: var(--text-secondary); + border-color: var(--border); +} + +.btn-save-draft { + background: none; + color: var(--text-muted); + border: none; + font-size: 13px; + font-weight: 400; + cursor: pointer; + font-family: 'Inter', sans-serif; + padding: 9px 12px; + border-radius: 6px; + transition: all 0.15s; +} + +.btn-save-draft:hover { + color: var(--text-secondary); + background: rgba(33, 38, 45, 0.6); +} + +.action-bar-spacer { flex: 1; } + +.step-counter { + font-size: 12.5px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.step-counter strong { + color: var(--text-secondary); + font-weight: 600; +} + +.btn-continue { + background: var(--accent); + color: #fff; + padding: 10px 24px; + font-size: 14px; + border: 1px solid var(--accent-dim); + box-shadow: 0 1px 4px rgba(13, 148, 136, 0.4); + transition: all 0.15s; +} + +.btn-continue:hover { + background: var(--accent-dim); + border-color: transparent; + box-shadow: + 0 2px 10px rgba(13, 148, 136, 0.5), + 0 0 0 3px var(--accent-ring); + transform: translateY(-1px); +} + +.btn-continue:active { + transform: translateY(0); + box-shadow: 0 1px 4px rgba(13, 148, 136, 0.3); +} + +.btn-continue.is-final-step { + background: #3fb950; + border-color: #2ea043; + box-shadow: 0 1px 4px rgba(63, 185, 80, 0.4); +} + +.btn-continue.is-final-step:hover { + background: #2ea043; + border-color: transparent; + box-shadow: + 0 2px 10px rgba(63, 185, 80, 0.45), + 0 0 0 3px rgba(63, 185, 80, 0.18); +} + +/* ── Code editor ── */ +.code-block { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.code-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + background: #1e2330; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.code-dots { display: flex; gap: 5px; } +.code-dots span { width: 10px; height: 10px; border-radius: 50%; } +.code-dots span:nth-child(1) { background: #ff5f56; } +.code-dots span:nth-child(2) { background: #ffbd2e; } +.code-dots span:nth-child(3) { background: #27c93f; } + +.code-toolbar-label { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #8b949e; +} + +.code-textarea { + background: #161b22 !important; + color: #c9d1d9 !important; + font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace !important; + font-size: 12.5px !important; + line-height: 1.65 !important; + border: none !important; + border-radius: 0 !important; + resize: vertical; + min-height: 160px; +} + +.code-textarea:focus { + border-color: transparent !important; + box-shadow: none !important; +} + +/* ── Section divider ── */ +.section-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 8px 0; +} + +.section-divider-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--text-muted); + white-space: nowrap; +} + +.section-divider-line { + flex: 1; + height: 1px; + background: var(--border); +} + +/* ── Step pane ── */ +.step-pane { display: flex; flex-direction: column; } +.step-pane[hidden] { display: none !important; } + +/* ── Monospace input variant ── */ +.input-mono { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 13px; + letter-spacing: -0.2px; +} + +/* ── Step-state colours (wizard-specific) ── */ +:root { + --step-done: var(--green); + --step-active: var(--accent); + --step-pending: var(--bg-muted); +} + +/* ══════════════════════════════════════════════ + Upstream selector (Step 3) + ══════════════════════════════════════════════ */ + +/* ── Combobox trigger ── */ +.upstream-combobox { + position: relative; +} + +.upstream-combobox-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 7px; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; + user-select: none; +} + +.upstream-combobox-trigger:hover { border-color: var(--accent); } + +.upstream-combobox.open .upstream-combobox-trigger { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.upstream-combobox-value { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} + +.upstream-combobox-placeholder { + font-size: 13.5px; + color: var(--text-muted); +} + +.upstream-combobox-chevron { + flex-shrink: 0; + transition: transform 0.2s; +} +.upstream-combobox.open .upstream-combobox-chevron { transform: rotate(180deg); } + +/* Value when upstream is selected (shown inside trigger) */ +.upstream-trigger-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; +} + +.upstream-trigger-url { + font-size: 11.5px; + color: var(--text-muted); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Dropdown panel ── */ +.upstream-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 200; + background: var(--bg-surface); + border: 1px solid var(--accent); + border-top: none; + border-bottom-left-radius: 7px; + border-bottom-right-radius: 7px; + box-shadow: 0 8px 24px rgba(0,0,0,0.35); + overflow: hidden; +} + +/* Search row */ +.upstream-search-wrap { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.upstream-search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-family: 'Inter', sans-serif; + font-size: 13px; + color: var(--text-primary); +} +.upstream-search-input::placeholder { color: var(--text-muted); } + +/* List of upstream items */ +.upstream-dropdown-list { + max-height: 220px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.upstream-dropdown-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + cursor: pointer; + transition: background 0.12s; +} +.upstream-dropdown-item:hover { background: rgba(13, 148, 136, 0.07); } +.upstream-dropdown-item.selected { background: rgba(13, 148, 136, 0.1); } + +.upstream-dropdown-item-info { flex: 1; min-width: 0; } + +.upstream-dropdown-item-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; +} + +.upstream-dropdown-item-url { + font-size: 11px; + color: var(--text-muted); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upstream-dropdown-item-check { + opacity: 0; + flex-shrink: 0; +} +.upstream-dropdown-item.selected .upstream-dropdown-item-check { opacity: 1; } + +/* Empty state */ +.upstream-dropdown-empty { + padding: 16px 14px; + font-size: 12.5px; + color: var(--text-muted); + text-align: center; +} + +/* Footer: Register new */ +/* ── Register new upstream trigger row ── */ +.upstream-new-trigger { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border: 1.5px dashed var(--border); + border-radius: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: var(--accent); + transition: border-color 0.15s, background 0.15s; + user-select: none; +} +.upstream-new-trigger:hover { + border-color: var(--accent); + background: rgba(13, 148, 136, 0.04); +} +.upstream-new-trigger.active { + border-style: solid; + border-color: var(--accent); + background: rgba(13, 148, 136, 0.06); +} + +.upstream-new-trigger-radio { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1.5px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: border-color 0.15s; +} +.upstream-new-trigger.active .upstream-new-trigger-radio { + border-color: var(--accent); +} +.upstream-new-trigger-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + opacity: 0; + transform: scale(0.5); + transition: opacity 0.15s, transform 0.15s; +} +.upstream-new-trigger.active .upstream-new-trigger-dot { + opacity: 1; + transform: scale(1); +} + +/* ── Auth badges ── */ +.upstream-auth-badge { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.2px; + padding: 2px 8px; + border-radius: 4px; + white-space: nowrap; + flex-shrink: 0; +} +.upstream-auth-badge.auth-bearer { + background: rgba(88, 166, 255, 0.1); + color: #58a6ff; + border: 1px solid rgba(88, 166, 255, 0.2); +} +.upstream-auth-badge.auth-basic { + background: rgba(240, 173, 78, 0.12); + color: #f0ad4e; + border: 1px solid rgba(240, 173, 78, 0.2); +} +.upstream-auth-badge.auth-apikey { + background: rgba(188, 140, 255, 0.1); + color: #bc8cff; + border: 1px solid rgba(188, 140, 255, 0.2); +} +.upstream-auth-badge.auth-none { + background: rgba(139, 148, 158, 0.08); + color: var(--text-muted); + border: 1px solid var(--border-subtle); +} + +/* ── Selected upstream preview (under combobox) ── */ +.upstream-preview { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--accent-glow); + border: 1px solid rgba(13, 148, 136, 0.3); + border-radius: 7px; +} + +.upstream-preview-name { + font-size: 13px; + font-weight: 600; + color: #fff; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; +} + +.upstream-preview-url { + font-size: 11.5px; + color: var(--accent-bright, var(--accent)); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upstream-preview-change { + font-size: 11.5px; + font-weight: 500; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 5px; + padding: 3px 10px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + flex-shrink: 0; +} +.upstream-preview-change:hover { border-color: var(--text-secondary); color: var(--text-primary); } + +/* ── Register new upstream inline form ── */ +.upstream-new-form { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.upstream-new-form-inner { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ── Small action buttons ── */ +.btn-primary-sm { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + font-family: 'Inter', sans-serif; + font-size: 12.5px; + font-weight: 600; + border-radius: 6px; + border: none; + cursor: pointer; + background: var(--accent); + color: #fff; + transition: opacity 0.15s; +} +.btn-primary-sm:hover { opacity: 0.88; } + +.btn-ghost-sm { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + font-family: 'Inter', sans-serif; + font-size: 12.5px; + font-weight: 500; + border-radius: 6px; + border: 1px solid var(--border); + cursor: pointer; + background: transparent; + color: var(--text-secondary); + transition: border-color 0.15s, color 0.15s; +} +.btn-ghost-sm:hover { border-color: var(--text-secondary); color: var(--text-primary); } + +.btn-primary-sm:disabled, +.btn-ghost-sm:disabled, +.btn-primary-sm.is-busy, +.btn-ghost-sm.is-busy { + opacity: 0.68; + cursor: wait; +} + +/* ══════════════════════════════════════════════ + HTTP method picker (Step 4 REST) + ══════════════════════════════════════════════ */ + +.method-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} + +.method-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 14px 8px; + background: var(--bg-canvas); + border: 1.5px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; + font-family: 'Inter', sans-serif; +} + +.method-card:hover { + border-color: var(--accent); + background: rgba(13, 148, 136, 0.04); +} + +.method-card.active { + border-color: var(--accent); + background: var(--accent-glow); + box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15); +} + +.method-name { + font-size: 14px; + font-weight: 700; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + color: var(--text-primary); + letter-spacing: 0.5px; +} + +.method-card.active .method-name { color: #fff; } + +.method-desc { + font-size: 11px; + color: var(--text-muted); + font-weight: 400; +} + +.method-card.active .method-desc { color: var(--accent-bright, var(--accent)); } + +.method-callout { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 11px 14px; + background: rgba(13, 148, 136, 0.07); + border: 1px solid rgba(13, 148, 136, 0.25); + border-radius: 7px; + font-size: 12.5px; + color: var(--text-secondary); + line-height: 1.55; + margin-top: 4px; +} + +/* ══════════════════════════════════════════════ + GraphQL operation type cards (Step 4 GraphQL) + ══════════════════════════════════════════════ */ + +.gql-type-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.gql-type-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 16px 18px; + background: var(--bg-canvas); + border: 1.5px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; + text-align: left; + font-family: 'Inter', sans-serif; +} + +.gql-type-card:hover { + border-color: var(--accent); + background: rgba(13, 148, 136, 0.04); +} + +.gql-type-card.active { + border-color: var(--accent); + background: var(--accent-glow); + box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15); +} + +.gql-type-keyword { + font-size: 15px; + font-weight: 700; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + color: var(--accent); + letter-spacing: -0.3px; +} + +.gql-type-card.active .gql-type-keyword { color: #5eead4; } + +.gql-type-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + +.gql-type-card.active .gql-type-desc { color: var(--text-secondary); } + + +/* ══════════════════════════════════════════════ + Protocol card — disabled tag (Streaming) +══════════════════════════════════════════════ */ +.protocol-tag-disabled { + text-decoration: line-through; + color: var(--error, #f87171); + opacity: 0.75; +} + + +/* ══════════════════════════════════════════════ + gRPC — Proto upload +══════════════════════════════════════════════ */ +.proto-dropzone { + border: 1.5px dashed var(--border); + border-radius: 10px; + padding: 36px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + background: var(--bg-canvas); +} +.proto-dropzone:hover, +.proto-dropzone.drag-over { + border-color: var(--accent); + background: rgba(56,139,253,0.05); +} +.proto-dropzone.drag-over { border-style: solid; } + +.proto-dropzone-title { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} +.proto-dropzone-sub { + font-size: 12px; + color: var(--text-muted); +} + +.proto-file-info { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: rgba(56,139,253,0.07); + border: 1px solid rgba(56,139,253,0.25); + border-radius: 8px; +} +.proto-file-name { + font-family: var(--font-mono, monospace); + font-size: 13px; + color: var(--accent); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.proto-file-size { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} +.proto-file-change { + background: none; + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text-secondary); + font-size: 11px; + padding: 3px 9px; + cursor: pointer; + white-space: nowrap; + transition: border-color 0.15s, color 0.15s; +} +.proto-file-change:hover { border-color: var(--accent); color: var(--accent); } + + +/* ══════════════════════════════════════════════ + gRPC — Service/method list +══════════════════════════════════════════════ */ +.proto-service-group { + padding-top: 16px; +} +.proto-service-group + .proto-service-group { + border-top: 1px solid var(--border-subtle); + margin-top: 4px; +} + +.proto-service-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: 10px; +} +.proto-service-label svg { opacity: 0.5; } + +.proto-method-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.proto-method-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 14px; + background: var(--bg-canvas); + border: 1.5px solid var(--border); + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: border-color 0.15s, background 0.15s; + min-width: 160px; +} +.proto-method-card:hover { + border-color: var(--border-muted, var(--accent)); + background: rgba(56,139,253,0.04); +} +.proto-method-card.active { + border-color: var(--accent); + background: rgba(56,139,253,0.08); +} + +.proto-method-name { + font-family: var(--font-mono, monospace); + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} +.proto-method-card.active .proto-method-name { color: var(--accent); } + +.proto-method-types { + font-size: 11px; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 4px; +} +.proto-method-types svg { opacity: 0.5; flex-shrink: 0; } + +.proto-empty { + padding: 24px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + + +/* ══════════════════════════════════════════════ + gRPC — Method signature detail +══════════════════════════════════════════════ */ +.proto-path-row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 6px; +} +.proto-path-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + white-space: nowrap; +} +.proto-path-value { + font-family: var(--font-mono, monospace); + font-size: 12px; + color: var(--accent); + word-break: break-all; +} + +.proto-sig-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +@media (max-width: 680px) { + .proto-sig-grid { grid-template-columns: 1fr; } +} + +.proto-sig-col { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border-subtle); + border-radius: 8px; + overflow: hidden; +} + +.proto-sig-col-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); +} +.proto-sig-col-header svg { opacity: 0.6; } + +.proto-type-badge { + font-family: var(--font-mono, monospace); + font-size: 11px; + background: rgba(56,139,253,0.12); + color: var(--accent); + padding: 1px 6px; + border-radius: 4px; + margin-left: auto; +} + +.proto-field-list { + padding: 6px 0; +} + +.proto-field-row { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 5px 12px; + font-size: 12px; + border-bottom: 1px solid var(--border-subtle); +} +.proto-field-row:last-child { border-bottom: none; } + +.proto-field-name { + font-family: var(--font-mono, monospace); + color: var(--text-primary); + font-size: 12px; +} +.proto-field-type { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-muted); +} + +.proto-field-empty { + padding: 16px 12px; + font-size: 12px; + color: var(--text-secondary); + text-align: center; + line-height: 1.5; +} + + +/* ══════════════════════════════════════════════ + gRPC — Source tabs +══════════════════════════════════════════════ */ +.grpc-source-tabs { + display: flex; + gap: 0; + margin-bottom: 20px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 9px; + padding: 3px; +} + +.grpc-source-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 14px; + background: none; + border: none; + border-radius: 7px; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} +.grpc-source-btn svg { flex-shrink: 0; opacity: 0.7; } +.grpc-source-btn:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); } +.grpc-source-btn.active { + background: var(--bg-surface); + color: var(--text-primary); + box-shadow: 0 1px 3px rgba(0,0,0,0.3); +} +.grpc-source-btn.active svg { opacity: 1; } + + +/* ══════════════════════════════════════════════ + gRPC — Reflection panel +══════════════════════════════════════════════ */ +.grpc-reflect-upstream { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 8px; +} +.grpc-reflect-upstream-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.grpc-reflect-upstream-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} +.grpc-reflect-upstream-url { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-muted); +} + +.grpc-reflect-status { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-secondary); +} +.grpc-reflect-spinner { + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: grpc-spin 0.7s linear infinite; + flex-shrink: 0; +} +@keyframes grpc-spin { to { transform: rotate(360deg); } } + +.grpc-reflect-error-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--error, #f87171); +} diff --git a/apps/ui/css/workspace-setup.css b/apps/ui/css/workspace-setup.css new file mode 100644 index 0000000..669ca11 --- /dev/null +++ b/apps/ui/css/workspace-setup.css @@ -0,0 +1,228 @@ +.ws-setup-page { + min-height: 100vh; + background: var(--bg-canvas); + display: flex; + flex-direction: column; +} +.ws-setup-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 32px; + border-bottom: 1px solid var(--border-subtle); +} +.ws-setup-logo { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: var(--text-primary); + font-size: 15px; + font-weight: 600; +} +.ws-setup-logo-mark { + width: 30px; + height: 30px; + background: #0d9488; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; +} +.ws-setup-back { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-muted); + text-decoration: none; + padding: 6px 10px; + border-radius: 6px; + transition: color 0.15s, background 0.15s; +} +.ws-setup-back:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); } + +.ws-setup-body { + flex: 1; + display: flex; + justify-content: center; + padding: 48px 24px 80px; +} +.ws-setup-container { + width: 100%; + max-width: 600px; +} +.ws-setup-header { margin-bottom: 32px; } +.ws-setup-title { + font-size: 24px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} +.ws-setup-subtitle { + font-size: 14px; + color: var(--text-muted); + line-height: 1.5; +} + +.ws-form-section { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + margin-bottom: 16px; +} +.ws-form-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 18px; +} + +/* Avatar */ +.ws-avatar-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} +.ws-avatar-preview { + width: 52px; + height: 52px; + border-radius: 12px; + background: #0d9488; + color: #fff; + font-size: 22px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.2s; +} +.ws-avatar-hint { font-size: 12px; color: var(--text-muted); line-height: 1.5; } +.ws-color-swatches { display: flex; gap: 6px; margin-top: 6px; } +.ws-color-swatch { + width: 20px; + height: 20px; + border-radius: 5px; + cursor: pointer; + border: 2px solid transparent; + transition: transform 0.1s, border-color 0.1s; +} +.ws-color-swatch:hover { transform: scale(1.15); } +.ws-color-swatch.active { border-color: var(--text-primary); } + +/* Invite rows */ +.invite-rows { display: flex; flex-direction: column; gap: 8px; } +.invite-row { display: flex; align-items: center; gap: 8px; } +.invite-row .form-input { margin-bottom: 0; } +.invite-row-remove { + background: none; + border: none; + padding: 6px; + cursor: pointer; + color: var(--text-muted); + border-radius: 5px; + display: flex; + align-items: center; + transition: color 0.1s, background 0.1s; + flex-shrink: 0; +} +.invite-row-remove:hover { color: var(--error, #f87171); background: rgba(248,113,113,0.08); } + +/* Members */ +.member-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border-subtle); +} +.member-row:last-child { border-bottom: none; } +.member-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--bg-muted); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + color: var(--text-secondary); + flex-shrink: 0; +} +.member-info { flex: 1; } +.member-name { font-size: 13.5px; color: var(--text-primary); font-weight: 500; } +.member-email { font-size: 12px; color: var(--text-muted); } +.member-last-active { font-size: 12px; color: var(--text-muted); white-space: nowrap; } + +/* Danger zone */ +.danger-zone-action { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 16px 24px; + border-bottom: 1px solid var(--border-subtle); +} +.danger-zone-action:last-child { border-bottom: none; } +.danger-zone-text { flex: 1; } +.danger-zone-title { font-size: 13.5px; font-weight: 500; color: var(--text-primary); margin-bottom: 3px; } +.danger-zone-desc { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; } + +/* Lang switcher */ +.lang-switcher { display: flex; gap: 6px; margin-top: 6px; } +.lang-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-canvas); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.lang-btn.active { + background: rgba(13,148,136,0.1); + border-color: rgba(13,148,136,0.35); + color: var(--accent); +} +.lang-btn:hover:not(.active) { background: rgba(255,255,255,0.04); color: var(--text-primary); } +.lang-flag { font-size: 14px; } + +/* Actions row */ +.ws-setup-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-top: 24px; +} + +.ws-submit-btn { + padding: 9px 22px; + font-size: 13px; +} +.ws-setup-footer-note { + font-size: 12px; + color: var(--text-muted); + text-align: center; + margin-top: 20px; + line-height: 1.5; +} + +/* Roles reference */ +.role-def-row { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 11px 0; + border-bottom: 1px solid var(--border-subtle); +} +.role-def-row:last-child { border-bottom: none; } +.role-def-text { font-size: 13px; color: var(--text-secondary); line-height: 1.5; } diff --git a/apps/ui/data/agents.json b/apps/ui/data/agents.json new file mode 100644 index 0000000..e78cea5 --- /dev/null +++ b/apps/ui/data/agents.json @@ -0,0 +1,58 @@ +[ + { + "id": "ag-001", + "slug": "customer-support", + "display_name": "Customer Support", + "description": "Handles ticket creation, status lookups, and customer data retrieval. Powers the support team's Claude integration.", + "status": "active", + "operation_count": 5, + "operation_ids": ["op_01", "op_02", "op_07", "op_09", "op_10"], + "key_count": 2, + "created_at": "2024-11-15T10:22:00Z", + "created_by": "AT", + "last_called_at": "2025-03-28T18:44:00Z", + "calls_today": 312 + }, + { + "id": "ag-002", + "slug": "sales-pipeline", + "display_name": "Sales Pipeline", + "description": "CRM operations for the sales team: lead creation, deal updates, contact lookup, and company enrichment.", + "status": "active", + "operation_count": 5, + "operation_ids": ["op_01", "op_06", "op_08", "op_11", "op_12"], + "key_count": 1, + "created_at": "2024-11-28T14:10:00Z", + "created_by": "AT", + "last_called_at": "2025-03-29T09:01:00Z", + "calls_today": 541 + }, + { + "id": "ag-003", + "slug": "internal-devops", + "display_name": "Internal DevOps", + "description": "Infrastructure tooling: deployment triggers, service health checks, and incident management workflows.", + "status": "draft", + "operation_count": 3, + "operation_ids": ["op_04", "op_05", "op_11"], + "key_count": 0, + "created_at": "2025-01-08T09:45:00Z", + "created_by": "AT", + "last_called_at": null, + "calls_today": 0 + }, + { + "id": "ag-004", + "slug": "product-analytics", + "display_name": "Product Analytics", + "description": "Queries usage metrics, feature adoption data, and user behaviour reports from the analytics stack.", + "status": "active", + "operation_count": 4, + "operation_ids": ["op_02", "op_03", "op_08", "op_12"], + "key_count": 3, + "created_at": "2025-02-01T11:30:00Z", + "created_by": "AT", + "last_called_at": "2025-03-29T11:22:00Z", + "calls_today": 87 + } +] diff --git a/apps/ui/data/keys.json b/apps/ui/data/keys.json new file mode 100644 index 0000000..146eb79 --- /dev/null +++ b/apps/ui/data/keys.json @@ -0,0 +1,15 @@ +{ + "acme-workspace": [ + { "id": "key_01", "name": "Production", "prefix": "mcp_prod_4f8a…", "scopes": ["read","write","deploy"], "created": "2025-11-15", "lastUsed": "Today", "status": "active" }, + { "id": "key_02", "name": "CI / CD pipeline", "prefix": "mcp_ci_b21c…", "scopes": ["read","deploy"], "created": "2025-12-01", "lastUsed": "2 days ago", "status": "active" }, + { "id": "key_03", "name": "Dev local", "prefix": "mcp_dev_7e3f…", "scopes": ["read","write"], "created": "2026-01-08", "lastUsed": "Never", "status": "active" }, + { "id": "key_04", "name": "Legacy integration", "prefix": "mcp_leg_c90d…", "scopes": ["read"], "created": "2025-09-22", "lastUsed": "45 days ago", "status": "revoked" } + ], + "startup-demo": [ + { "id": "key_11", "name": "Backend service", "prefix": "mcp_back_9a1b…", "scopes": ["read","write"], "created": "2026-01-20", "lastUsed": "Today", "status": "active" }, + { "id": "key_12", "name": "Analytics pipeline", "prefix": "mcp_anal_3c7d…", "scopes": ["read"], "created": "2026-02-05", "lastUsed": "3 days ago", "status": "active" } + ], + "personal": [ + { "id": "key_21", "name": "Local experiments", "prefix": "mcp_loc_f2e8…", "scopes": ["read","write","deploy"], "created": "2026-03-01", "lastUsed": "Yesterday", "status": "active" } + ] +} diff --git a/apps/ui/data/operations.json b/apps/ui/data/operations.json new file mode 100644 index 0000000..c0d4cd9 --- /dev/null +++ b/apps/ui/data/operations.json @@ -0,0 +1,134 @@ +[ + { + "id": "op_01", + "name": "create_crm_lead", + "display_name": "Create CRM Lead", + "protocol": "rest", + "method": "POST", + "status": "active", + "category": "CRM", + "target_url": "https://api.acme.com/v2/crm/leads", + "created_at": "2026-03-21" + }, + { + "id": "op_02", + "name": "get_user_profile", + "display_name": "Get User Profile", + "protocol": "rest", + "method": "GET", + "status": "active", + "category": "Users", + "target_url": "https://api.acme.com/v1/users/{id}", + "created_at": "2026-03-18" + }, + { + "id": "op_03", + "name": "search_knowledge_base", + "display_name": "Search Knowledge Base", + "protocol": "graphql", + "method": null, + "status": "active", + "category": "Search", + "target_url": "https://graph.notion-int.acme.com/graphql", + "created_at": "2026-03-14" + }, + { + "id": "op_04", + "name": "send_slack_message", + "display_name": "Send Slack Message", + "protocol": "rest", + "method": "POST", + "status": "error", + "category": "Notifications", + "target_url": "https://slack.com/api/chat.postMessage", + "created_at": "2026-03-10" + }, + { + "id": "op_05", + "name": "stream_telemetry", + "display_name": "Stream Telemetry Events", + "protocol": "grpc", + "method": null, + "status": "draft", + "category": "Observability", + "target_url": "grpc://telemetry.internal.acme.com:9090", + "created_at": "2026-03-05" + }, + { + "id": "op_06", + "name": "update_deal_stage", + "display_name": "Update Deal Stage", + "protocol": "rest", + "method": "PATCH", + "status": "inactive", + "category": "CRM", + "target_url": "https://api.acme.com/v2/crm/deals/{id}", + "created_at": "2026-02-28" + }, + { + "id": "op_07", + "name": "fetch_invoice", + "display_name": "Fetch Invoice", + "protocol": "rest", + "method": "GET", + "status": "active", + "category": "Billing", + "target_url": "https://billing.acme.com/v1/invoices/{id}", + "created_at": "2026-02-20" + }, + { + "id": "op_08", + "name": "list_products", + "display_name": "List Products", + "protocol": "graphql", + "method": null, + "status": "active", + "category": "Catalog", + "target_url": "https://shop.acme.com/graphql", + "created_at": "2026-02-15" + }, + { + "id": "op_09", + "name": "create_support_ticket", + "display_name": "Create Support Ticket", + "protocol": "rest", + "method": "POST", + "status": "active", + "category": "Support", + "target_url": "https://support.acme.com/api/tickets", + "created_at": "2026-02-10" + }, + { + "id": "op_10", + "name": "get_order_status", + "display_name": "Get Order Status", + "protocol": "rest", + "method": "GET", + "status": "draft", + "category": "Orders", + "target_url": "https://orders.acme.com/v2/status/{id}", + "created_at": "2026-02-05" + }, + { + "id": "op_11", + "name": "sync_contacts", + "display_name": "Sync Contacts", + "protocol": "grpc", + "method": null, + "status": "active", + "category": "CRM", + "target_url": "grpc://contacts.internal.acme.com:9091", + "created_at": "2026-01-28" + }, + { + "id": "op_12", + "name": "send_email_campaign", + "display_name": "Send Email Campaign", + "protocol": "rest", + "method": "POST", + "status": "active", + "category": "Marketing", + "target_url": "https://mail.acme.com/api/v3/campaigns/send", + "created_at": "2026-01-20" + } +] diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html new file mode 100644 index 0000000..830afb5 --- /dev/null +++ b/apps/ui/html/agents.html @@ -0,0 +1,394 @@ + + + + + + + Crank — Agents + + + + + + + + + + + + + + + + +
+ + + + + +
+
+
Total agents
+
+
across this workspace
+
+
+
Published
+
+
+
+
+
Operations exposed
+
+
+
+
+
Calls today
+
+
all agents combined
+
+
+ + +
+ +
+ + + +
+
+ + +
+ + Each agent gets its own MCP endpoint. +
+ + +
+
+ Loading agents… +
+ +
+
Failed to load agents
+
+
+ + +
+
+ +
+
No agents yet
+
Create your first agent to get a dedicated MCP endpoint with a curated set of tools.
+
After creation, issue separate machine-access keys for this endpoint on the API Keys page.
+ +
+ + +
+
No agents match
+
Try a different search term.
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html new file mode 100644 index 0000000..e6b6b3e --- /dev/null +++ b/apps/ui/html/api-keys.html @@ -0,0 +1,319 @@ + + + + + + + Crank — Agent Keys + + + + + + + + + + + + + + + +
+ + + +
+ + + + +
+ Keys are only shown once. + Copy and store them securely immediately after creation — Crank stores only a hash. `Last used` updates after successful MCP authentication. +
+
+ +
+
+
+
Agent access
+
Select the AI agent whose MCP endpoint should accept this key.
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
Machine access modes
+
This build exposes the stable machine-access contract and shows which modes are available now.
+
+
+
+
+ Community currently supports static AI-agent keys. +
+
+ + + + +
Short-lived and one-time token issuance uses the same public HTTP surface, but requires a commercial edition.
+
+
+
+ +
+
+
+
Active keys
+
3 active · 1 revoked
+
+ +
+ +
+ + + + + + + + + + + + + + +
NameKey prefixScopesCreatedLast usedStatus
+
+
+
+ +
+
+
Scope reference
+
+
+
+
+ read +
+
Initialize MCP sessions, ping the server, and list tools for a workspace agent.
+
+
+
+ write +
+
Execute `tools/call` requests against published agent toolsets.
+
+
+
+ deploy +
+
Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.
+
+
+
+ +
+ + + + + + + + + + + + + + diff --git a/apps/ui/html/fragments/lang-switcher.html b/apps/ui/html/fragments/lang-switcher.html new file mode 100644 index 0000000..96a8403 --- /dev/null +++ b/apps/ui/html/fragments/lang-switcher.html @@ -0,0 +1,13 @@ +
+ +
+ + +
+
diff --git a/apps/ui/html/login.html b/apps/ui/html/login.html new file mode 100644 index 0000000..24f3368 --- /dev/null +++ b/apps/ui/html/login.html @@ -0,0 +1,54 @@ + + + + + + + Crank — Sign in + + + + + + + + + + + + diff --git a/apps/ui/html/logs.html b/apps/ui/html/logs.html new file mode 100644 index 0000000..2f7df1a --- /dev/null +++ b/apps/ui/html/logs.html @@ -0,0 +1,142 @@ + + + + + + + Crank — Logs + + + + + + + + + + + + + +
+ + + +
+
+
+ Live +
+ + + +
+ + + + + + + + + +
+ +
+
+
+ +
+ + + + + diff --git a/apps/ui/html/secrets.html b/apps/ui/html/secrets.html new file mode 100644 index 0000000..defbeb3 --- /dev/null +++ b/apps/ui/html/secrets.html @@ -0,0 +1,242 @@ + + + + + + + Crank — Secrets + + + + + + + + + + + + +
+ + +
+ + + + +
+ Secret plaintext is write-only. + Crank encrypts secret values with the workspace master key. After create or rotate, the API returns only metadata, so this page focuses on lifecycle and usage references. +
+
+ +
+
+
+
Workspace secrets
+
+
+ +
+
+
+ + + + + + + + + + + + + +
NameKindVersionLast usedUsed byStatus
+
+
+
+
+ +
+
+
+
Auth profile references
+
Profiles resolve secrets at runtime before upstream execution.
+
+
+
+
+
+
+
+ + + + + + diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html new file mode 100644 index 0000000..f0ae884 --- /dev/null +++ b/apps/ui/html/settings.html @@ -0,0 +1,330 @@ + + + + + + + Crank — Settings + + + + + + + + + + + + + +
+ + + +
+ + + + + +
+ +
+
+
+
+
Workspace settings
+
General information about your workspace
+
+
+
+
+ + +
Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
This build limits the default protocol to the capabilities available in the current edition.
+
+
+ +
+ +
+ + +
+
Interface display language
+
+ +
+ +
+
+
+
+ + +
+
+
+
Profile
+
+
+
+
AT
+
+
Crank
+
operator@acme-workspace
+
Your avatar is generated from your display name and email.
+
+
+
Your name and email are stored on the backend and used across the console and session identity.
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
Changing email updates the login identity for the current account.
+
+ +
+
+ +
+
+
+
+ + +
+
+
+
Security
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+ +
+
+
+ More security options +
+
+ + + +
+ Available later. Two-factor authentication, passkeys, and multi-session controls will be added later. The current live flow uses password sign-in with one HttpOnly browser session. +
+
+
+ +
+
+
+
+
+ +
+
+
Current session
+
+
+
+
+
Browser sessioncurrent
+
Loading current session details…
+
+
+
+
+
+ + +
+
+
+
Notifications
+
+
+
+ + + + +
+ Not included in this build. Notification routing and personal preferences are outside the current Community surface. +
+
+
+
+
+ +
+
+ +
+ + + + + diff --git a/apps/ui/html/usage.html b/apps/ui/html/usage.html new file mode 100644 index 0000000..ea86e52 --- /dev/null +++ b/apps/ui/html/usage.html @@ -0,0 +1,234 @@ + + + + + + + Crank — Usage + + + + + + + + + + + + + + +
+ + + + +
+
+
Total invocations
+
28,341
+
+ + +12% vs prior period +
+
+
+
Success rate
+
98.6%
+
+ + +0.3pp vs prior period +
+
+
+
Median latency (p50)
+
124ms
+
+ + -18ms vs prior period +
+
+
+
p99 latency
+
2.1s
+
+ + +340ms vs prior period +
+
+
+ + +
+
+
Invocations over time
+
+ Success + Error +
+
+
+
+
+
+ + +
+
+
+
By operation
+
Breakdown for last 7 days
+
+
+
+ + + + + + + + + + + + + + +
OperationProtocolCallsErrorsError rateLatency (p50 / p95 / p99)Traffic share
+
+
+
+ +
+ + + + + + + + + + + diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html new file mode 100644 index 0000000..a69d71c --- /dev/null +++ b/apps/ui/html/wizard/index.html @@ -0,0 +1,267 @@ + + + + + + Crank — New Operation + + + + + + + + + + + + + + + +
+ + +
+ +
+ Create operation +
+
+
+ 20% + +
+ + +
+ + +
+
+ +
+
M
+
+ Create operation +
+
+ +
+ + +
+ +
+
1
+
+
Step 1
+
Protocol
+
In progress
+
+
+ +
+
2
+
+
Step 2
+
Upstream target
+
Not started
+
+
+ + + +
+
4
+
+
Step 4
+
Tool config
+
Not started
+
+
+ +
+
5
+
+
Step 5
+
Mapping
+
Not started
+
+
+ +
+ + +
+
+
+ + +
+
+ + + + +
+ + + + + + diff --git a/apps/ui/html/wizard/step1.html b/apps/ui/html/wizard/step1.html new file mode 100644 index 0000000..21d4483 --- /dev/null +++ b/apps/ui/html/wizard/step1.html @@ -0,0 +1,69 @@ + +
+
+
+
+ Step 1 of 5 +
+

Choose a protocol

+

Select the transport protocol your upstream service uses. This determines how Crank will communicate with your API and which configuration options are available in subsequent steps.

+
+ + +
+ + + +
+
+
+
+ +
+ + +
+ + + + +
+
You can change this later
+
+ Switching protocol after configuring subsequent steps will clear schema and mapping data. Save your operation as a draft before experimenting. See protocol migration guide for details. +
+
+
+ + + +
diff --git a/apps/ui/html/wizard/step2.html b/apps/ui/html/wizard/step2.html new file mode 100644 index 0000000..cb7c1c5 --- /dev/null +++ b/apps/ui/html/wizard/step2.html @@ -0,0 +1,224 @@ + +
+
+
+
+ Step 2 of 5 +
+

Select upstream & endpoint

+

Choose an existing upstream — a shared host with its auth config — or register a new one. Then define the specific endpoint path this operation will call.

+
+ + +
+
+
+ + + + + + +
+
+
Upstream
+
Shared host, base URL, and authentication
+
+ Required +
+
+ + +
+
+
+ Select an upstream… +
+ + + +
+ + + +
+ + + + + +
+
+
+
+ + + + Register new upstream +
+ + + + +
+
+ + +
+
+
+ + + +
+
+
Endpoint
+
Path and HTTP method for this specific operation
+
+ Required +
+
+
+ + +
Appended to the upstream base URL. Use {param} for path variables.
+
+
+
+ +
+ + + + + + diff --git a/apps/ui/html/wizard/step3-rest.html b/apps/ui/html/wizard/step3-rest.html new file mode 100644 index 0000000..0273706 --- /dev/null +++ b/apps/ui/html/wizard/step3-rest.html @@ -0,0 +1,93 @@ + +
+
+
+
+ Step 3 of 5 +
+

HTTP method & format

+

Choose the HTTP verb this operation sends and configure content negotiation headers. Crank will serialize MCP tool arguments into the appropriate request body format.

+
+ + +
+
+
+ + + +
+
+
HTTP method
+
Verb sent to the upstream on every tool invocation
+
+ Required +
+
+
+ + + + + +
+ +
+
+ + +
+
+
+ + + + +
+
+
Request format
+
Content negotiation and serialisation
+
+ Optional +
+
+
+
+ + +
How the request body is encoded when sent to the upstream.
+
+
+ + +
Accepted response content types from the upstream server.
+
+
+
+
+ +
diff --git a/apps/ui/html/wizard/step4.html b/apps/ui/html/wizard/step4.html new file mode 100644 index 0000000..2fb425c --- /dev/null +++ b/apps/ui/html/wizard/step4.html @@ -0,0 +1,158 @@ +
+
+
+
+ Step 4 of 5 +
+

Tool config

+

Name your tool, write the LLM-facing description, and define the input/output schemas. The description is the primary signal the model uses when deciding whether to call this tool.

+
+ +
+
+
+ + + +
+
+
Tool identity
+
Machine-readable name and LLM-facing description
+
+
+
+ +
+ + +
Machine-readable identifier used in MCP tool calls. Only lowercase letters, numbers and underscores. Cannot be changed after publishing.
+
+ +
+
+ + +
Human-readable name shown in the console and audit log.
+
+
+ + +
Short imperative sentence shown in the MCP tool manifest.
+
+
+ +
+ + +
LLM-facing description. Be precise — this is the primary signal the model uses to decide whether to invoke this tool.
+
+ +
+
+ +
+ + + + +
+
Writing effective descriptions
+
Start with a verb ("Creates", "Fetches", "Updates"). Mention key input requirements. Describe what a successful response looks like. Avoid vague phrasing like "handles" or "manages". See description best practices →
+
+
+ +
+
+
+ + + + +
+
+
Input schema
+
Parameters the LLM passes when calling this tool
+
+ JSON Schema draft-07 +
+
+
+
+
+ json / input-schema +
+ +
+
+
+ +
+
+
+ + + + +
+
+
Output schema
+
Shape of the data returned to the LLM after a successful call
+
+ JSON Schema draft-07 +
+
+
+
+
+ json / output-schema +
+ +
+
+
+ +
+ + + + +
+
Schemas are protocol-agnostic
+
These schemas describe the MCP contract, not the upstream wire format. Field mapping in Step 5 translates between the two. You can upload a sample JSON response to auto-generate the output schema.
+
+
+
diff --git a/apps/ui/html/wizard/step5.html b/apps/ui/html/wizard/step5.html new file mode 100644 index 0000000..445858e --- /dev/null +++ b/apps/ui/html/wizard/step5.html @@ -0,0 +1,265 @@ +
+
+
+
+ Step 5 of 5 +
+

Mapping and execution

+

Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 2.

+
+ +
+ Input → Request +
+
+ +
+
+
+
+
+ yaml / input-mapping +
+ +
+
+
+ +
+ Response → Output +
+
+ +
+
+
+
+
+ yaml / output-mapping +
+ +
+
+
+ +
+ Execution +
+
+ +
+
+
+ + + + +
+
+
Execution config
+
Timeouts, retry policy and auth profile reference
+
+ Optional +
+
+
+
+
+ yaml / exec-config +
+ +
+
+
+ + + +
+ Live validation and publishing +
+
+ + + +
+ + + + +
+
Live actions save the draft first
+
Sample uploads, test runs, YAML actions and publish all persist the current draft before calling the backend.
+
+
+ +
+
+
+ + + + +
+
+
Samples and draft generation
+
Persist input/output samples, then generate schemas and mappings from real payloads.
+
+
+
+
+
+ + +
+
+ + +
+
+
+ + + +
+
+
+ +
+
+
+ + + +
+
+
Test run
+
Run the current draft against the upstream before publishing it.
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+ + + +
+
+
YAML import and export
+
Move operation drafts between environments without leaving the wizard.
+
+
+
+
+ + + + +
+
+ + +
+
+
+ +
+
+
+ + + +
+
+
Publish
+
Save the current draft and expose it to published agents.
+
+
+
+
Publishing uses the current draft version from this wizard session.
+
+ +
+
+
+ +
+ + + + +
+
Operation stays editable in this wizard
+
Saving creates or updates the current draft in place. Use the live actions above to upload samples, test the draft, export or import YAML, and publish when the upstream contract is ready.
+
+
+
diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html new file mode 100644 index 0000000..a5bb8a9 --- /dev/null +++ b/apps/ui/html/workspace-setup.html @@ -0,0 +1,322 @@ + + + + + + + Crank — Workspace + + + + + + + + + +
+ + + + + +
+
+ +
+
Workspace settings
+
Configure your workspace identity, default settings, and team members.
+
+ + +
+
Workspace identity
+ +
+
A
+
+
Avatar is derived from your workspace name.
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+ +
+ +
+ mcp.crank.io/ + +
+
Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.
+
+ +
+ + +
+
+ + +
+
Default settings
+ +
+
+ + +
Pre-selected in the operation wizard.
+
+
+ + +
+
+ +
+ +
+ + +
+
+
+ + + + + + + + +
+ Cancel + +
+ + + + + + +
+
+ +
+ + + + diff --git a/apps/ui/icons/agents/agent.svg b/apps/ui/icons/agents/agent.svg new file mode 100644 index 0000000..7abbba5 --- /dev/null +++ b/apps/ui/icons/agents/agent.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/ui/icons/agents/eye.svg b/apps/ui/icons/agents/eye.svg new file mode 100644 index 0000000..f843b1b --- /dev/null +++ b/apps/ui/icons/agents/eye.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/agents/key.svg b/apps/ui/icons/agents/key.svg new file mode 100644 index 0000000..c208db3 --- /dev/null +++ b/apps/ui/icons/agents/key.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/arrow-down.svg b/apps/ui/icons/general/arrow-down.svg new file mode 100644 index 0000000..7577633 --- /dev/null +++ b/apps/ui/icons/general/arrow-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/arrow-left.svg b/apps/ui/icons/general/arrow-left.svg new file mode 100644 index 0000000..a34bc94 --- /dev/null +++ b/apps/ui/icons/general/arrow-left.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/arrow-right.svg b/apps/ui/icons/general/arrow-right.svg new file mode 100644 index 0000000..71e439f --- /dev/null +++ b/apps/ui/icons/general/arrow-right.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/arrow-up.svg b/apps/ui/icons/general/arrow-up.svg new file mode 100644 index 0000000..85d9676 --- /dev/null +++ b/apps/ui/icons/general/arrow-up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/bell.svg b/apps/ui/icons/general/bell.svg new file mode 100644 index 0000000..7ccd650 --- /dev/null +++ b/apps/ui/icons/general/bell.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/check-sm.svg b/apps/ui/icons/general/check-sm.svg new file mode 100644 index 0000000..3449e27 --- /dev/null +++ b/apps/ui/icons/general/check-sm.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/check.svg b/apps/ui/icons/general/check.svg new file mode 100644 index 0000000..250df56 --- /dev/null +++ b/apps/ui/icons/general/check.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/chevron-down.svg b/apps/ui/icons/general/chevron-down.svg new file mode 100644 index 0000000..07aaf73 --- /dev/null +++ b/apps/ui/icons/general/chevron-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/close.svg b/apps/ui/icons/general/close.svg new file mode 100644 index 0000000..648c2e9 --- /dev/null +++ b/apps/ui/icons/general/close.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/copy.svg b/apps/ui/icons/general/copy.svg new file mode 100644 index 0000000..3a586b9 --- /dev/null +++ b/apps/ui/icons/general/copy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/crank-logo.svg b/apps/ui/icons/general/crank-logo.svg new file mode 100644 index 0000000..b33e89f --- /dev/null +++ b/apps/ui/icons/general/crank-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/ui/icons/general/download.svg b/apps/ui/icons/general/download.svg new file mode 100644 index 0000000..494bbb5 --- /dev/null +++ b/apps/ui/icons/general/download.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/edit.svg b/apps/ui/icons/general/edit.svg new file mode 100644 index 0000000..0a2419e --- /dev/null +++ b/apps/ui/icons/general/edit.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/external.svg b/apps/ui/icons/general/external.svg new file mode 100644 index 0000000..893d2df --- /dev/null +++ b/apps/ui/icons/general/external.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/grid.svg b/apps/ui/icons/general/grid.svg new file mode 100644 index 0000000..98effa2 --- /dev/null +++ b/apps/ui/icons/general/grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/info-circle.svg b/apps/ui/icons/general/info-circle.svg new file mode 100644 index 0000000..cdc8fd5 --- /dev/null +++ b/apps/ui/icons/general/info-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/general/info.svg b/apps/ui/icons/general/info.svg new file mode 100644 index 0000000..1270118 --- /dev/null +++ b/apps/ui/icons/general/info.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/logout.svg b/apps/ui/icons/general/logout.svg new file mode 100644 index 0000000..a769622 --- /dev/null +++ b/apps/ui/icons/general/logout.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/general/plus-sm.svg b/apps/ui/icons/general/plus-sm.svg new file mode 100644 index 0000000..bd555c4 --- /dev/null +++ b/apps/ui/icons/general/plus-sm.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/plus.svg b/apps/ui/icons/general/plus.svg new file mode 100644 index 0000000..f192806 --- /dev/null +++ b/apps/ui/icons/general/plus.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/refresh.svg b/apps/ui/icons/general/refresh.svg new file mode 100644 index 0000000..a64b2de --- /dev/null +++ b/apps/ui/icons/general/refresh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/search.svg b/apps/ui/icons/general/search.svg new file mode 100644 index 0000000..ac71407 --- /dev/null +++ b/apps/ui/icons/general/search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/settings.svg b/apps/ui/icons/general/settings.svg new file mode 100644 index 0000000..556e1cd --- /dev/null +++ b/apps/ui/icons/general/settings.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/general/trash.svg b/apps/ui/icons/general/trash.svg new file mode 100644 index 0000000..658080d --- /dev/null +++ b/apps/ui/icons/general/trash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/user.svg b/apps/ui/icons/general/user.svg new file mode 100644 index 0000000..e9ef4cd --- /dev/null +++ b/apps/ui/icons/general/user.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/warning.svg b/apps/ui/icons/general/warning.svg new file mode 100644 index 0000000..3b96ca1 --- /dev/null +++ b/apps/ui/icons/general/warning.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/login/google.svg b/apps/ui/icons/login/google.svg new file mode 100644 index 0000000..19c999b --- /dev/null +++ b/apps/ui/icons/login/google.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/ui/icons/wizard/clock.svg b/apps/ui/icons/wizard/clock.svg new file mode 100644 index 0000000..2b3ba0b --- /dev/null +++ b/apps/ui/icons/wizard/clock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/wizard/code.svg b/apps/ui/icons/wizard/code.svg new file mode 100644 index 0000000..31327ef --- /dev/null +++ b/apps/ui/icons/wizard/code.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/wizard/file.svg b/apps/ui/icons/wizard/file.svg new file mode 100644 index 0000000..aee4e64 --- /dev/null +++ b/apps/ui/icons/wizard/file.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/wizard/graphql-protocol.svg b/apps/ui/icons/wizard/graphql-protocol.svg new file mode 100644 index 0000000..08a5fa5 --- /dev/null +++ b/apps/ui/icons/wizard/graphql-protocol.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ui/icons/wizard/grpc-protocol.svg b/apps/ui/icons/wizard/grpc-protocol.svg new file mode 100644 index 0000000..bde9d0a --- /dev/null +++ b/apps/ui/icons/wizard/grpc-protocol.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/ui/icons/wizard/reflection.svg b/apps/ui/icons/wizard/reflection.svg new file mode 100644 index 0000000..19f923f --- /dev/null +++ b/apps/ui/icons/wizard/reflection.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/wizard/rest-protocol.svg b/apps/ui/icons/wizard/rest-protocol.svg new file mode 100644 index 0000000..97bc44e --- /dev/null +++ b/apps/ui/icons/wizard/rest-protocol.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/wizard/server.svg b/apps/ui/icons/wizard/server.svg new file mode 100644 index 0000000..e58ca74 --- /dev/null +++ b/apps/ui/icons/wizard/server.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/ui/icons/wizard/upload.svg b/apps/ui/icons/wizard/upload.svg new file mode 100644 index 0000000..1dc7232 --- /dev/null +++ b/apps/ui/icons/wizard/upload.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/index.html b/apps/ui/index.html new file mode 100644 index 0000000..40fb800 --- /dev/null +++ b/apps/ui/index.html @@ -0,0 +1,418 @@ + + + + + + Crank — Operations + + + + + + + + + + + + + + + + +
+ + + + + +
+
+
Total operations
+
+
+
+
+
Active
+
+
+
+
+
Requests today
+
+
+
+
+
Avg. latency
+
+
+
+
+ + +
+ +
+ + +
+ + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + + +
+ + +
+
+ +
+ + +
+ + +
+ + +
+ + +
+

Loading operations…

+
+ + +
+

No operations found

+

Try adjusting your search or filters.

+
+ +
+

Backend connection failed

+

+
+ + + + + + + + + + + + + + + + +
+ NAME + + + + PROTOCOLSTATUS + CREATED + + + + TARGET URL
+ + + + +
+ +
+ + + + + diff --git a/apps/ui/js/agents.js b/apps/ui/js/agents.js new file mode 100644 index 0000000..b57ac55 --- /dev/null +++ b/apps/ui/js/agents.js @@ -0,0 +1,499 @@ +function agentUiStatus(status) { + if (status === 'published') return 'published'; + if (status === 'archived') return 'archived'; + return status || 'draft'; +} + +function mapAgent(agent) { + var mapped = { + id: agent.id, + slug: agent.slug, + display_name: agent.display_name, + description: agent.description || '', + status: agentUiStatus(agent.status), + raw_status: agent.status, + operation_count: agent.operation_count || 0, + operation_ids: agent.operation_ids || [], + key_count: agent.key_count || 0, + calls_today: agent.calls_today || 0, + created_at: agent.created_at, + current_draft_version: agent.current_draft_version || 1, + latest_published_version: agent.latest_published_version, + mcp_endpoint: agent.mcp_endpoint || '', + }; + + return window.localizeDemoAgent ? window.localizeDemoAgent(mapped) : mapped; +} + +function mapOperation(operation) { + var mapped = { + id: operation.id, + name: operation.name, + display_name: operation.display_name, + protocol: operation.protocol, + status: operation.status, + current_draft_version: operation.current_draft_version || 1, + latest_published_version: operation.latest_published_version, + }; + + return window.localizeDemoOperation ? window.localizeDemoOperation(mapped) : mapped; +} + +function currentLocale() { + return localStorage.getItem('crank_lang') === 'ru' ? 'ru-RU' : 'en-US'; +} + +document.addEventListener('alpine:init', function() { + Alpine.data('agents', function() { + return { + agents: [], + operations: [], + capabilities: null, + loading: true, + loadError: '', + workspaceId: null, + + agentSearch: '', + openDropdown: null, + drawerOpen: false, + drawerMode: 'create', + editingId: null, + saving: false, + + form: { + display_name: '', + slug: '', + description: '', + status: 'published', + selectedOps: [], + }, + + opSearch: '', + slugManuallyEdited: false, + + async init() { + var self = this; + + await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve()); + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + this.workspaceId = workspace ? workspace.id : null; + await this.loadCapabilities(); + await this.reload(); + + document.addEventListener('keydown', function(event) { + if (event.key === 'Escape' && self.drawerOpen) { + self.closeDrawer(); + } + }); + + document.addEventListener('click', function() { + self.openDropdown = null; + }); + + window.addEventListener('crank:workspacechange', async function(event) { + self.workspaceId = event.detail ? event.detail.id : null; + await self.loadCapabilities(); + await self.reload(); + }); + }, + + async loadCapabilities() { + if (!window.CrankApi || typeof window.CrankApi.getCapabilities !== 'function') { + this.capabilities = null; + return; + } + try { + this.capabilities = await window.CrankApi.getCapabilities(); + } catch (_error) { + this.capabilities = null; + } + }, + + async reload() { + this.loading = true; + this.loadError = ''; + + if (!this.workspaceId || !window.CrankApi) { + this.agents = []; + this.operations = []; + this.loading = false; + this.loadError = this.tKey('agents.error.api'); + return; + } + + try { + var responses = await Promise.all([ + window.CrankApi.listAgents(this.workspaceId), + window.CrankApi.listOperations(this.workspaceId), + ]); + this.agents = ((responses[0] && responses[0].items) || []).map(mapAgent); + this.operations = ((responses[1] && responses[1].items) || []).map(mapOperation); + } catch (error) { + this.agents = []; + this.operations = []; + this.loadError = error.message || this.tKey('agents.error.load'); + } + + this.loading = false; + }, + + get filteredAgents() { + var query = this.agentSearch.toLowerCase().trim(); + if (!query) return this.agents; + return this.agents.filter(function(agent) { + return agent.display_name.toLowerCase().includes(query) + || agent.slug.toLowerCase().includes(query) + || (agent.description || '').toLowerCase().includes(query); + }); + }, + + get totalCalls() { + return this.agents.reduce(function(sum, agent) { + return sum + (agent.calls_today || 0); + }, 0); + }, + + get activeCount() { + return this.agents.filter(function(agent) { + return agent.status === 'published'; + }).length; + }, + + get totalOpsExposed() { + return this.agents.reduce(function(sum, agent) { + return sum + (agent.operation_count || 0); + }, 0); + }, + + get isCommunityBuild() { + return !!(this.capabilities && this.capabilities.edition === 'community'); + }, + + agentCalloutText() { + if (this.isCommunityBuild) { + return this.tfKey('agents.callout.community', { count: this.operations.length }); + } + return this.tfKey('agents.callout.default', { count: this.operations.length }); + }, + + subtitleText() { + return this.tKey(this.isCommunityBuild ? 'agents.subtitle.community' : 'agents.subtitle'); + }, + + emptyStateText() { + return this.tKey(this.isCommunityBuild ? 'agents.empty.initial.sub_community' : 'agents.empty.initial.sub'); + }, + + drawerSubText() { + if (this.drawerMode === 'edit') { + return this.tKey('agents.drawer.edit_sub'); + } + return this.tKey(this.isCommunityBuild ? 'agents.drawer.new_sub_community' : 'agents.drawer.new_sub'); + }, + + operationsSubText() { + return this.tKey(this.isCommunityBuild ? 'agents.drawer.operations_sub_community' : 'agents.drawer.operations_sub'); + }, + + get filteredOps() { + var query = this.opSearch.toLowerCase().trim(); + if (!query) return this.operations; + return this.operations.filter(function(operation) { + return operation.name.toLowerCase().includes(query) + || operation.display_name.toLowerCase().includes(query); + }); + }, + + openCreate() { + this.drawerMode = 'create'; + this.editingId = null; + this.form = { + display_name: '', + slug: '', + description: '', + status: 'published', + selectedOps: [], + }; + this.opSearch = ''; + this.slugManuallyEdited = false; + this.drawerOpen = true; + }, + + openEdit(agent) { + this.drawerMode = 'edit'; + this.editingId = agent.id; + this.form = { + display_name: agent.display_name, + slug: agent.slug, + description: agent.description, + status: agent.raw_status || agent.status || 'draft', + selectedOps: [].concat(agent.operation_ids || []), + }; + this.opSearch = ''; + this.slugManuallyEdited = true; + this.drawerOpen = true; + }, + + closeDrawer() { + this.drawerOpen = false; + this.saving = false; + }, + + onNameInput(value) { + this.form.display_name = value; + if (!this.slugManuallyEdited) { + this.form.slug = value + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + } + }, + + onSlugInput(value) { + this.form.slug = value.toLowerCase().replace(/[^a-z0-9-]/g, ''); + this.slugManuallyEdited = true; + }, + + toggleOp(operationId) { + var index = this.form.selectedOps.indexOf(operationId); + if (index === -1) { + this.form.selectedOps.push(operationId); + } else { + this.form.selectedOps.splice(index, 1); + } + }, + + isOpSelected(operationId) { + return this.form.selectedOps.includes(operationId); + }, + + async saveAgent() { + var self = this; + if ( + this.saving + || !this.workspaceId + || !this.form.display_name.trim() + || !this.form.slug.trim() + ) { + return; + } + + this.saving = true; + + try { + var agentId = this.editingId; + var currentVersion = 1; + var existingAgent = this.drawerMode === 'edit' + ? this.agents.find(function(item) { return item.id === agentId; }) + : null; + var previousStatus = existingAgent ? (existingAgent.raw_status || 'draft') : 'draft'; + + if (this.drawerMode === 'create') { + var created = await window.CrankApi.createAgent(this.workspaceId, { + slug: this.form.slug, + display_name: this.form.display_name, + description: this.form.description, + instructions: {}, + tool_selection_policy: {}, + }); + agentId = created.agent_id; + currentVersion = created.version || 1; + } else { + await window.CrankApi.updateAgent(this.workspaceId, this.editingId, { + slug: this.form.slug, + display_name: this.form.display_name, + description: this.form.description, + }); + var agent = await window.CrankApi.getAgent(this.workspaceId, this.editingId); + currentVersion = agent.current_draft_version || 1; + } + + await window.CrankApi.saveAgentBindings( + this.workspaceId, + agentId, + this.form.selectedOps.map(function(operationId) { + var operation = self.operations.find(function(item) { + return item.id === operationId; + }); + return { + operation_id: operationId, + operation_version: operation && operation.latest_published_version + ? operation.latest_published_version + : operation && operation.current_draft_version + ? operation.current_draft_version + : 1, + tool_name: operation ? operation.name : operationId, + tool_title: operation ? (operation.display_name || operation.name) : operationId, + tool_description_override: null, + enabled: true, + }; + }), + ); + + if (this.form.status === 'published') { + await window.CrankApi.publishAgent(this.workspaceId, agentId, { + version: currentVersion, + }); + } else if (this.form.status === 'archived') { + await window.CrankApi.archiveAgent(this.workspaceId, agentId); + } else if (this.drawerMode === 'edit' && previousStatus !== 'draft') { + await window.CrankApi.unpublishAgent(this.workspaceId, agentId); + } + + await this.reload(); + if (window.CrankUi) { + window.CrankUi.success( + this.tfKey('agents.toast.saved_message', { + name: this.form.display_name, + count: this.form.selectedOps.length + }), + this.drawerMode === 'create' + ? this.tKey('agents.toast.saved_title_create') + : this.tKey('agents.toast.saved_title_update') + ); + } + this.closeDrawer(); + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || this.tKey('agents.toast.save_error_message'), + this.tKey('agents.toast.save_error_title') + ); + } + this.saving = false; + } + }, + + async deleteAgent(id) { + if (!confirm(this.tKey('agents.toast.delete_confirm'))) return; + + try { + var agent = this.agents.find(function(item) { return item.id === id; }); + await window.CrankApi.deleteAgent(this.workspaceId, id); + await this.reload(); + if (window.CrankUi) { + window.CrankUi.success( + this.tfKey('agents.toast.delete_message', { name: agent ? agent.display_name : '' }), + this.tKey('agents.toast.delete_title') + ); + } + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || this.tKey('agents.toast.delete_error_message'), + this.tKey('agents.toast.delete_error_title') + ); + } + } + }, + + async applyLifecycle(agent, action) { + if (!this.workspaceId || !window.CrankApi) { + return; + } + + try { + if (action === 'publish') { + await window.CrankApi.publishAgent(this.workspaceId, agent.id, { + version: agent.current_draft_version || 1, + }); + } else if (action === 'unpublish') { + await window.CrankApi.unpublishAgent(this.workspaceId, agent.id); + } else if (action === 'archive') { + await window.CrankApi.archiveAgent(this.workspaceId, agent.id); + } + await this.reload(); + if (window.CrankUi) { + window.CrankUi.success( + action === 'publish' + ? this.tfKey('agents.toast.lifecycle_publish', { name: agent.display_name }) + : action === 'unpublish' + ? this.tfKey('agents.toast.lifecycle_unpublish', { name: agent.display_name }) + : this.tfKey('agents.toast.lifecycle_archive', { name: agent.display_name }), + this.tKey('agents.toast.lifecycle_title') + ); + } + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || this.tKey('agents.toast.lifecycle_error_message'), + this.tKey('agents.toast.lifecycle_error_title') + ); + } + } + }, + + lifecycleAction(agent) { + if (agent.raw_status === 'published') { + return { key: 'unpublish', label: this.tKey('agents.lifecycle.unpublish') }; + } + if (agent.raw_status === 'archived') { + return { key: 'unpublish', label: this.tKey('agents.lifecycle.restore_draft') }; + } + return { key: 'publish', label: this.tKey('agents.lifecycle.publish') }; + }, + + lifecycleLabel(agent) { + if (agent.raw_status === 'published') return this.tKey('agents.lifecycle.published'); + if (agent.raw_status === 'archived') return this.tKey('agents.lifecycle.archived'); + return this.tKey('agents.lifecycle.draft'); + }, + + mcpEndpoint(agent) { + if (agent.mcp_endpoint) return agent.mcp_endpoint; + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + var workspaceSlug = workspace ? workspace.slug : 'default'; + return '/mcp/v1/' + workspaceSlug + '/' + agent.slug; + }, + + endpointHelpText(agent) { + return this.tfKey( + this.isCommunityBuild ? 'agents.card.endpoint_help_community' : 'agents.card.endpoint_help', + { count: agent.key_count || 0 } + ); + }, + + copyEndpoint(agent) { + var endpoint = this.mcpEndpoint(agent); + navigator.clipboard.writeText(endpoint).catch(function() {}); + if (window.CrankUi) { + window.CrankUi.info(endpoint, this.tKey('agents.toast.endpoint_title')); + } + }, + + statusClass(status) { + return 'agent-status-badge agent-status-' + status; + }, + + protocolBadge(operation) { + return 'badge badge-rest'; + }, + + protocolLabel(operation) { + return 'REST'; + }, + + formatDate(dateStr) { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString(currentLocale(), { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }, + + formatCalls(value) { + if (!value) return '0'; + return value >= 1000 ? (value / 1000).toFixed(1) + 'k' : String(value); + }, + + tKey(key) { + return window.t ? t(key) : key; + }, + + tfKey(key, vars) { + return window.tf ? tf(key, vars) : this.tKey(key); + }, + }; + }); +}); diff --git a/apps/ui/js/api-keys.js b/apps/ui/js/api-keys.js new file mode 100644 index 0000000..b3d1ccd --- /dev/null +++ b/apps/ui/js/api-keys.js @@ -0,0 +1,620 @@ +var KEYS = []; +var AGENTS = []; +var capabilities = null; +var currentWorkspaceId = null; +var currentAgentId = null; +var selectedScopes = new Set(['read']); +var search = ''; + +var SCOPES = ['read', 'write', 'deploy']; +var SCOPE_DESC = { + read: 'apikeys.scope.read', + write: 'apikeys.scope.write', + deploy: 'apikeys.scope.deploy', +}; + +function tKey(key) { + return window.t ? t(key) : key; +} + +function tfKey(key, vars) { + return window.tf ? tf(key, vars) : tKey(key); +} + +function capabilityLabel(key) { + return tKey('settings.capability.' + key); +} + +function buildIconSvg(href, width, height) { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', String(width)); + svg.setAttribute('height', String(height)); + var use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + use.setAttribute('href', href); + svg.appendChild(use); + return svg; +} + +function mapKeyRecord(record) { + var apiKey = record.api_key || {}; + return { + id: apiKey.id, + agentId: apiKey.agent_id || null, + name: apiKey.name, + prefix: apiKey.prefix || '', + scopes: apiKey.scopes || [], + created: apiKey.created_at ? apiKey.created_at.slice(0, 10) : '—', + lastUsed: apiKey.last_used_at ? apiKey.last_used_at.slice(0, 10) : tKey('apikeys.last_used.never'), + status: apiKey.status || 'active', + }; +} + +function mapAgentRecord(record) { + return { + id: record.id, + slug: record.slug, + displayName: record.display_name || record.slug || record.id, + mcpEndpoint: record.mcp_endpoint || '', + }; +} + +function currentWorkspace() { + return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; +} + +async function loadKeys() { + var workspace = currentWorkspace(); + currentWorkspaceId = workspace ? workspace.id : null; + + setTableLoading(true); + + if (!currentWorkspaceId || !window.CrankApi) { + AGENTS = []; + KEYS = []; + currentAgentId = null; + renderAgentPicker(); + setCreateButtonState(); + setTableLoading(false); + renderTable(tKey('apikeys.error.api')); + return; + } + + try { + var response = await window.CrankApi.listAgents(currentWorkspaceId); + AGENTS = ((response && response.items) || []).map(mapAgentRecord); + if (!AGENTS.length) { + currentAgentId = null; + KEYS = []; + renderAgentPicker(); + setCreateButtonState(); + setTableLoading(false); + renderTable(); + return; + } + if (!currentAgentId || !AGENTS.some(function(agent) { return agent.id === currentAgentId; })) { + currentAgentId = AGENTS[0].id; + } + renderAgentPicker(); + var keysResponse = await window.CrankApi.listAgentPlatformApiKeys( + currentWorkspaceId, + currentAgentId + ); + KEYS = ((keysResponse && keysResponse.items) || []).map(mapKeyRecord); + setCreateButtonState(); + setTableLoading(false); + renderTable(); + } catch (error) { + AGENTS = []; + KEYS = []; + currentAgentId = null; + renderAgentPicker(); + setCreateButtonState(); + setTableLoading(false); + renderTable(error.message || tKey('apikeys.error.load')); + } +} + +async function loadCapabilities() { + if (!window.CrankApi || typeof window.CrankApi.getCapabilities !== 'function') { + return; + } + + try { + capabilities = await window.CrankApi.getCapabilities(); + } catch (_error) { + capabilities = null; + } + + renderMachineAccessSummary(); +} + +function renderMachineAccessSummary() { + var title = document.getElementById('machine-access-title'); + var subtitle = document.getElementById('machine-access-subtitle'); + var summary = document.getElementById('machine-access-summary'); + var note = document.getElementById('machine-access-note'); + if (!title || !subtitle || !summary || !note) { + return; + } + + if (!capabilities) { + summary.textContent = tKey('apikeys.machine_access.summary_default'); + note.textContent = tKey('apikeys.machine_access.note'); + return; + } + + var accessModes = (capabilities.machine_access_modes || []).map(capabilityLabel).join(', '); + var securityLevels = (capabilities.supported_security_levels || []).map(capabilityLabel).join(', '); + + title.textContent = tKey('apikeys.machine_access.title_' + capabilities.edition); + subtitle.textContent = tKey('apikeys.machine_access.subtitle_' + capabilities.edition); + summary.textContent = tfKey('apikeys.machine_access.summary', { + access_modes: accessModes || capabilityLabel('none'), + security_levels: securityLevels || capabilityLabel('none'), + }); + note.textContent = tKey('apikeys.machine_access.note_' + capabilities.edition); +} + +function setTableLoading(on) { + var tbody = document.getElementById('keys-tbody'); + if (!on) return; + tbody.innerHTML = ''; + var tr = document.createElement('tr'); + var td = document.createElement('td'); + td.colSpan = 7; + td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);'; + td.textContent = tKey('apikeys.loading'); + tr.appendChild(td); + tbody.appendChild(tr); +} + +async function revokeKey(id) { + if (!confirm(tKey('apikeys.confirm.revoke'))) return; + if (!currentAgentId) return; + + try { + var key = KEYS.find(function(item) { return item.id === id; }); + await window.CrankApi.revokeAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id); + await loadKeys(); + if (window.CrankUi) { + window.CrankUi.success( + tfKey('apikeys.toast.revoke_message', { name: key ? key.name : '' }), + tKey('apikeys.toast.revoke_title') + ); + } + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || tKey('apikeys.toast.revoke_error_message'), + tKey('apikeys.toast.revoke_error_title') + ); + } + } +} + +async function deleteKey(id) { + if (!confirm(tKey('apikeys.confirm.delete'))) return; + if (!currentAgentId) return; + + try { + var key = KEYS.find(function(item) { return item.id === id; }); + await window.CrankApi.deleteAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id); + await loadKeys(); + if (window.CrankUi) { + window.CrankUi.success( + tfKey('apikeys.toast.delete_message', { name: key ? key.name : '' }), + tKey('apikeys.toast.delete_title') + ); + } + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || tKey('apikeys.toast.delete_error_message'), + tKey('apikeys.toast.delete_error_title') + ); + } + } +} + +async function createKey(name, scopes) { + var created = await window.CrankApi.createAgentPlatformApiKey(currentWorkspaceId, currentAgentId, { + name: name, + scopes: scopes, + }); + return { + rawKey: created.secret, + record: mapKeyRecord(created.api_key), + }; +} + +function currentAgent() { + return AGENTS.find(function(agent) { return agent.id === currentAgentId; }) || null; +} + +function renderAgentPicker() { + var select = document.getElementById('agent-select'); + var hint = document.getElementById('agent-endpoint-hint'); + if (!select || !hint) return; + + select.innerHTML = ''; + + if (!AGENTS.length) { + var emptyOption = document.createElement('option'); + emptyOption.value = ''; + emptyOption.textContent = tKey('apikeys.agent.empty'); + select.appendChild(emptyOption); + select.disabled = true; + hint.textContent = tKey('apikeys.agent.empty_hint'); + return; + } + + AGENTS.forEach(function(agent) { + var option = document.createElement('option'); + option.value = agent.id; + option.textContent = agent.displayName; + if (agent.id === currentAgentId) option.selected = true; + select.appendChild(option); + }); + select.disabled = false; + + var agent = currentAgent(); + hint.textContent = agent && agent.mcpEndpoint + ? tfKey('apikeys.agent.endpoint', { endpoint: agent.mcpEndpoint }) + : tKey('apikeys.agent.endpoint_missing'); +} + +function setCreateButtonState() { + var button = document.getElementById('btn-create-key'); + if (!button) return; + button.disabled = !currentAgentId; +} + +function renderScopes() { + var el = document.getElementById('scope-checkboxes'); + var tmpl = document.getElementById('tmpl-scope-checkbox'); + el.innerHTML = ''; + SCOPES.forEach(function(scope) { + var node = tmpl.content.cloneNode(true); + var input = node.querySelector('input'); + input.dataset.scope = scope; + if (selectedScopes.has(scope)) input.checked = true; + node.querySelector('.scope-checkbox-name').textContent = scope; + node.querySelector('.scope-checkbox-desc').textContent = tKey(SCOPE_DESC[scope]); + input.addEventListener('change', function() { + if (this.checked) selectedScopes.add(this.dataset.scope); + else selectedScopes.delete(this.dataset.scope); + }); + el.appendChild(node); + }); +} + +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) { + return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q); + }); + var subtitle = document.getElementById('keys-summary-subtitle'); + + tbody.innerHTML = ''; + if (cardList) { + cardList.innerHTML = ''; + } + + if (subtitle) { + var active = KEYS.filter(function(key) { return key.status === 'active'; }).length; + var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).length; + subtitle.textContent = currentAgentId + ? tfKey('apikeys.active.subtitle', { active: active, revoked: revoked }) + : tKey('apikeys.agent.empty_hint'); + } + + if (errorMessage) { + var errorRow = document.createElement('tr'); + var errorCell = document.createElement('td'); + errorCell.colSpan = 7; + errorCell.style.cssText = 'text-align:center;padding:36px;color:var(--danger,#f85149);'; + errorCell.textContent = errorMessage; + errorRow.appendChild(errorCell); + tbody.appendChild(errorRow); + renderKeyCards([], errorMessage); + return; + } + + if (!rows.length) { + var empty = document.createElement('tr'); + var td = document.createElement('td'); + 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')) + : tKey('apikeys.agent.empty_hint'); + empty.appendChild(td); + tbody.appendChild(empty); + renderKeyCards(rows); + return; + } + + var tmpl = document.getElementById('tmpl-key-row'); + rows.forEach(function(key) { + var node = tmpl.content.cloneNode(true); + node.querySelector('.col-name').textContent = key.name; + node.querySelector('.col-mono').textContent = key.prefix; + node.querySelector('.col-created').textContent = key.created; + node.querySelector('.col-last-used').textContent = key.lastUsed; + + var scopesEl = node.querySelector('.col-scopes'); + key.scopes.forEach(function(scope) { + var span = document.createElement('span'); + span.className = 'badge badge-scope'; + span.textContent = scope; + scopesEl.appendChild(span); + }); + + var badge = document.createElement('span'); + badge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked'; + badge.textContent = key.status === 'active' ? tKey('apikeys.status.active') : tKey('apikeys.status.revoked'); + node.querySelector('.col-status').appendChild(badge); + + var actionsActive = node.querySelector('.actions-active'); + var actionsRevoked = node.querySelector('.actions-revoked'); + if (key.status === 'active') { + actionsActive.querySelector('[title=\"Copy key prefix\"]').addEventListener('click', function() { + copyPrefix(key.prefix); + }); + actionsActive.querySelector('[title=\"Revoke key\"]').addEventListener('click', function() { + revokeKey(key.id); + }); + } else { + actionsActive.hidden = true; + actionsRevoked.hidden = false; + actionsRevoked.querySelector('[title=\"Delete\"]').addEventListener('click', function() { + deleteKey(key.id); + }); + } + + tbody.appendChild(node); + }); + + renderKeyCards(rows); +} + +function renderKeyCards(rows, errorMessage) { + var cardList = document.getElementById('keys-card-list'); + if (!cardList) return; + + cardList.innerHTML = ''; + + if (errorMessage) { + cardList.appendChild(buildKeyCardMessage(errorMessage, true)); + return; + } + + if (!rows.length) { + cardList.appendChild( + buildKeyCardMessage( + currentAgentId + ? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none')) + : tKey('apikeys.agent.empty_hint'), + false + ) + ); + return; + } + + rows.forEach(function(key) { + var card = document.createElement('div'); + card.className = 'resource-card'; + + var header = document.createElement('div'); + header.className = 'resource-card-header'; + var headerMain = document.createElement('div'); + var title = document.createElement('div'); + title.className = 'resource-card-title'; + title.textContent = key.name; + var subtitle = document.createElement('div'); + subtitle.className = 'resource-card-subtitle'; + subtitle.textContent = key.prefix; + headerMain.appendChild(title); + headerMain.appendChild(subtitle); + + var actions = document.createElement('div'); + actions.className = 'resource-card-actions'; + + var statusBadge = document.createElement('span'); + statusBadge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked'; + statusBadge.textContent = key.status === 'active' + ? tKey('apikeys.status.active') + : tKey('apikeys.status.revoked'); + actions.appendChild(statusBadge); + header.appendChild(headerMain); + header.appendChild(actions); + card.appendChild(header); + + var metaGrid = document.createElement('div'); + metaGrid.className = 'resource-meta-grid'; + metaGrid.appendChild(buildMetaItem('apikeys.th.scopes', key.scopes.join(', ') || '—')); + metaGrid.appendChild(buildMetaItem('apikeys.th.created', key.created)); + metaGrid.appendChild(buildMetaItem('apikeys.th.last', key.lastUsed)); + card.appendChild(metaGrid); + + var actionRow = document.createElement('div'); + actionRow.className = 'resource-card-actions'; + if (key.status === 'active') { + actionRow.appendChild(buildCardAction('apikeys.action.copy_prefix', function() { + copyPrefix(key.prefix); + })); + actionRow.appendChild(buildCardAction('apikeys.action.revoke', function() { + revokeKey(key.id); + }, true)); + } else { + actionRow.appendChild(buildCardAction('apikeys.action.delete', function() { + deleteKey(key.id); + }, true)); + } + card.appendChild(actionRow); + + cardList.appendChild(card); + }); +} + +function buildKeyCardMessage(text, isError) { + var card = document.createElement('div'); + card.className = 'resource-card'; + var value = document.createElement('div'); + value.className = 'resource-meta-value'; + value.textContent = text; + value.style.color = isError ? 'var(--danger,#f85149)' : 'var(--text-muted)'; + card.appendChild(value); + return card; +} + +function buildMetaItem(labelKey, valueText) { + var item = document.createElement('div'); + item.className = 'resource-meta-item'; + var label = document.createElement('div'); + label.className = 'resource-meta-label'; + label.textContent = tKey(labelKey); + var value = document.createElement('div'); + value.className = 'resource-meta-value'; + value.textContent = valueText; + item.appendChild(label); + item.appendChild(value); + return item; +} + +function buildCardAction(labelKey, onClick, isDanger) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = isDanger ? 'btn-secondary' : 'btn-secondary'; + button.textContent = tKey(labelKey); + button.addEventListener('click', onClick); + return button; +} + +var modal = document.getElementById('modal-create'); + +function openModal() { + if (!currentAgentId) return; + document.getElementById('modal-form-body').hidden = false; + document.getElementById('modal-reveal-body').hidden = true; + document.getElementById('modal-footer-create').hidden = false; + document.getElementById('modal-footer-done').hidden = true; + document.getElementById('new-key-name').value = ''; + selectedScopes = new Set(['read']); + renderScopes(); + modal.classList.add('open'); + setTimeout(function() { + document.getElementById('new-key-name').focus(); + }, 50); +} + +function closeModal() { + modal.classList.remove('open'); +} + +document.getElementById('btn-create-key').addEventListener('click', openModal); +document.getElementById('modal-close-btn').addEventListener('click', closeModal); +document.getElementById('modal-cancel-btn').addEventListener('click', closeModal); +modal.addEventListener('click', function(event) { + if (event.target === modal) closeModal(); +}); + +document.getElementById('modal-confirm-btn').addEventListener('click', async function() { + var button = this; + var name = document.getElementById('new-key-name').value.trim(); + if (!name) { + document.getElementById('new-key-name').focus(); + return; + } + if (!selectedScopes.size) { + if (window.CrankUi) { + window.CrankUi.info( + tKey('apikeys.toast.scope_missing_message'), + tKey('apikeys.toast.scope_missing_title') + ); + } + return; + } + + try { + button.disabled = true; + button.textContent = tKey('apikeys.creating'); + var created = await createKey(name, Array.from(selectedScopes)); + KEYS.unshift(created.record); + document.getElementById('reveal-key-value').textContent = created.rawKey; + document.getElementById('modal-form-body').hidden = true; + document.getElementById('modal-reveal-body').hidden = false; + document.getElementById('modal-footer-create').hidden = true; + document.getElementById('modal-footer-done').hidden = false; + renderTable(); + if (window.CrankUi) { + window.CrankUi.success( + tKey('apikeys.toast.create_message'), + tKey('apikeys.toast.create_title') + ); + } + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || tKey('apikeys.toast.create_error_message'), + tKey('apikeys.toast.create_error_title') + ); + } + } finally { + button.disabled = false; + button.textContent = tKey('apikeys.modal.create'); + } +}); + +document.getElementById('modal-done-btn').addEventListener('click', closeModal); + +document.getElementById('copy-key-btn').addEventListener('click', function() { + var value = document.getElementById('reveal-key-value').textContent; + if (navigator.clipboard) { + navigator.clipboard.writeText(value).catch(function() {}); + } + this.replaceChildren( + buildIconSvg( + (window.APP_BASE || '') + 'icons/general/check.svg#icon', + 13, + 13 + ) + ); + if (window.CrankUi) { + window.CrankUi.info( + tKey('apikeys.toast.copy_message'), + tKey('apikeys.toast.copy_title') + ); + } +}); + +document.getElementById('key-search').addEventListener('input', function() { + search = this.value; + renderTable(); +}); + +document.getElementById('agent-select').addEventListener('change', async function() { + currentAgentId = this.value || null; + await loadKeys(); +}); + +function copyPrefix(prefix) { + if (navigator.clipboard) { + navigator.clipboard.writeText(prefix).catch(function() {}); + } + if (window.CrankUi) { + window.CrankUi.info(prefix, tKey('apikeys.toast.prefix_title')); + } +} + +document.addEventListener('DOMContentLoaded', async function() { + await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve()); + await loadCapabilities(); + await loadKeys(); + window.addEventListener('crank:workspacechange', function() { + loadCapabilities(); + loadKeys(); + }); +}); diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js new file mode 100644 index 0000000..b76bc0c --- /dev/null +++ b/apps/ui/js/api.js @@ -0,0 +1,351 @@ +(function() { + var API_BASE = '/api/admin'; + var AUTH_BASE = '/api/auth'; + + function headers(extra) { + return Object.assign({ + 'Accept': 'application/json', + }, extra || {}); + } + + async function request(path, options) { + var response = await fetch(path, Object.assign({ + credentials: 'same-origin', + headers: headers(), + }, options || {})); + + if (response.status === 204) { + return null; + } + + var payload = null; + var text = await response.text(); + + if (text) { + try { + payload = JSON.parse(text); + } catch (_error) {} + } + + if (!response.ok) { + if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') { + window.CrankAuth.handleUnauthorized(); + } + var message = payload && payload.error + ? payload.error.message + : payload && payload.message + ? payload.message + : text + ? text + : ('HTTP ' + response.status); + var error = new Error(message); + error.status = response.status; + error.payload = payload; + throw error; + } + + return payload; + } + + async function requestText(path, options) { + var response = await fetch(path, Object.assign({ + credentials: 'same-origin', + headers: headers(), + }, options || {})); + + var text = await response.text(); + var payload = null; + + if (text) { + try { + payload = JSON.parse(text); + } catch (_error) {} + } + + if (!response.ok) { + if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') { + window.CrankAuth.handleUnauthorized(); + } + var message = payload && payload.error + ? payload.error.message + : payload && payload.message + ? payload.message + : text + ? text + : ('HTTP ' + response.status); + var error = new Error(message); + error.status = response.status; + error.payload = payload; + throw error; + } + + return text; + } + + function get(path) { + return request(API_BASE + path); + } + + function post(path, body) { + return request(API_BASE + path, { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(body), + }); + } + + function patch(path, body) { + return request(API_BASE + path, { + method: 'PATCH', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(body), + }); + } + + function del(path) { + return request(API_BASE + path, { method: 'DELETE' }); + } + + function query(params) { + var search = new URLSearchParams(); + Object.keys(params || {}).forEach(function(key) { + var value = params[key]; + if (value === undefined || value === null || value === '') { + return; + } + search.set(key, value); + }); + var encoded = search.toString(); + 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', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(payload), + }); + }, + logout: function() { + return request(AUTH_BASE + '/logout', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({}), + }); + }, + getSession: function() { + return request(AUTH_BASE + '/session'); + }, + getProfile: function() { + return request(AUTH_BASE + '/profile'); + }, + updateProfile: function(payload) { + return request(AUTH_BASE + '/profile', { + method: 'PATCH', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(payload), + }); + }, + changePassword: function(payload) { + return request(AUTH_BASE + '/password', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(payload), + }); + }, + setCurrentWorkspace: function(workspaceId) { + return request(AUTH_BASE + '/current-workspace', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ workspace_id: workspaceId }), + }); + }, + listWorkspaces: function() { + return get('/workspaces'); + }, + getCapabilities: function() { + return get('/capabilities'); + }, + createWorkspace: function(payload) { + return post('/workspaces', payload); + }, + getWorkspace: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId)); + }, + updateWorkspace: function(workspaceId, payload) { + return patch('/workspaces/' + encodeURIComponent(workspaceId), payload); + }, + listMemberships: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/members'); + }, + updateMembership: function(workspaceId, userId, payload) { + return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/members/' + encodeURIComponent(userId), payload); + }, + deleteMembership: function(workspaceId, userId) { + return del('/workspaces/' + encodeURIComponent(workspaceId) + '/members/' + encodeURIComponent(userId)); + }, + listInvitations: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations'); + }, + createInvitation: function(workspaceId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations', payload); + }, + deleteInvitation: function(workspaceId, invitationId) { + return del('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations/' + encodeURIComponent(invitationId)); + }, + exportWorkspace: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/export'); + }, + deleteWorkspace: function(workspaceId) { + return del('/workspaces/' + encodeURIComponent(workspaceId)); + }, + listOperations: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations'); + }, + getOperation: function(workspaceId, operationId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId)); + }, + getOperationVersion: function(workspaceId, operationId, version) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/versions/' + encodeURIComponent(version)); + }, + createOperation: function(workspaceId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations', payload); + }, + updateOperation: function(workspaceId, operationId, payload) { + return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId), payload); + }, + deleteOperation: function(workspaceId, operationId) { + return del('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId)); + }, + archiveOperation: function(workspaceId, operationId) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/archive', {}); + }, + publishOperation: function(workspaceId, operationId, version) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/publish', { + version: version, + }); + }, + createOperationVersion: function(workspaceId, operationId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/versions', payload); + }, + runOperationTest: function(workspaceId, operationId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/test-runs', payload); + }, + uploadInputSample: function(workspaceId, operationId, sample) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/samples/input-json', sample); + }, + uploadOutputSample: function(workspaceId, operationId, sample) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/samples/output-json', sample); + }, + generateDraft: function(workspaceId, operationId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/drafts/generate', payload || {}); + }, + exportOperation: function(workspaceId, operationId, params) { + return requestText( + API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/export' + query(params), + { + headers: headers({ 'Accept': 'application/yaml' }), + } + ); + }, + importOperation: function(workspaceId, yamlDocument, mode) { + return request( + API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/operations/import' + query({ mode: mode }), + { + method: 'POST', + headers: headers({ 'Content-Type': 'application/yaml' }), + body: yamlDocument, + } + ); + }, + listAgents: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents'); + }, + createAgent: function(workspaceId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents', payload); + }, + getAgent: function(workspaceId, agentId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId)); + }, + updateAgent: function(workspaceId, agentId, payload) { + return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId), payload); + }, + deleteAgent: function(workspaceId, agentId) { + return del('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId)); + }, + saveAgentBindings: function(workspaceId, agentId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/bindings', payload); + }, + publishAgent: function(workspaceId, agentId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload); + }, + unpublishAgent: function(workspaceId, agentId) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/unpublish', {}); + }, + archiveAgent: function(workspaceId, agentId) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/archive', {}); + }, + listAgentPlatformApiKeys: function(workspaceId, agentId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys'); + }, + createAgentPlatformApiKey: function(workspaceId, agentId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys', payload); + }, + revokeAgentPlatformApiKey: function(workspaceId, agentId, keyId) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys/' + encodeURIComponent(keyId) + '/revoke', {}); + }, + deleteAgentPlatformApiKey: function(workspaceId, agentId, keyId) { + return del('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys/' + encodeURIComponent(keyId)); + }, + listSecrets: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets'); + }, + createSecret: function(workspaceId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets', payload); + }, + getSecret: function(workspaceId, secretId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId)); + }, + rotateSecret: function(workspaceId, secretId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId) + '/rotate', payload); + }, + deleteSecret: function(workspaceId, secretId) { + return del('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId)); + }, + listAuthProfiles: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/auth-profiles'); + }, + listLogs: function(workspaceId, params) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs' + query(params)); + }, + getLog: function(workspaceId, logId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId)); + }, + getUsageOverview: function(workspaceId, params) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params)); + }, + getProtocolCapabilities: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/protocol-capabilities'); + }, + getOperationUsage: function(workspaceId, operationId, params) { + return get( + '/workspaces/' + encodeURIComponent(workspaceId) + '/usage/operations/' + encodeURIComponent(operationId) + query(params) + ); + }, + getAgentUsage: function(workspaceId, agentId, params) { + return get( + '/workspaces/' + encodeURIComponent(workspaceId) + '/usage/agents/' + encodeURIComponent(agentId) + query(params) + ); + }, + }; + +}()); diff --git a/apps/ui/js/auth.js b/apps/ui/js/auth.js new file mode 100644 index 0000000..06019a7 --- /dev/null +++ b/apps/ui/js/auth.js @@ -0,0 +1,253 @@ +(function() { + var STORAGE_KEY = 'crank_user'; + var sessionCache = null; + var sessionPromise = null; + function tKey(key) { + return typeof t === 'function' ? t(key) : key; + } + function tfKey(key, vars) { + return typeof tf === 'function' ? tf(key, vars) : key; + } + function roleLabel(role) { + var normalized = String(role || 'viewer').toLowerCase(); + if (normalized === 'operator') return tKey('workspace_setup.role.operator'); + if (normalized === 'owner') return tKey('workspace_setup.role.owner'); + if (normalized === 'admin') return tKey('workspace_setup.role.admin'); + if (normalized === 'viewer') return tKey('workspace_setup.role.viewer'); + return normalized.replace(/^\w/, function(char) { return char.toUpperCase(); }); + } + + function currentWorkspaceLabel() { + if (window.getCurrentWorkspace) { + var workspace = window.getCurrentWorkspace(); + if (workspace && workspace.name) { + return workspace.name; + } + } + try { + return localStorage.getItem('crank_workspace_slug') || tKey('settings.nav.workspace'); + } catch (_error) { + return tKey('settings.nav.workspace'); + } + } + + function isLoginPage() { + return /\/(?:html\/login\.html|login)\/?$/.test(window.location.pathname); + } + + function loginUrl() { + return (window.CrankRoutes && window.CrankRoutes.login) || '/login'; + } + + function homeUrl() { + return (window.CrankRoutes && window.CrankRoutes.home) || '/'; + } + + function clearUserMirror() { + sessionCache = null; + sessionPromise = null; + try { + localStorage.removeItem(STORAGE_KEY); + } catch (_error) {} + renderShellIdentity(null); + } + + function primaryMembership(session) { + if (!(session && session.memberships && session.memberships.length)) { + return null; + } + if (session.current_workspace_id) { + var currentMembership = session.memberships.find(function(item) { + return item.workspace && item.workspace.id === session.current_workspace_id; + }); + if (currentMembership) { + return currentMembership; + } + } + if (window.getCurrentWorkspace) { + var currentWorkspace = window.getCurrentWorkspace(); + if (currentWorkspace) { + var matched = session.memberships.find(function(item) { + return item.workspace && item.workspace.id === currentWorkspace.id; + }); + if (matched) { + return matched; + } + } + } + return session.memberships[0]; + } + + function initials(displayName, email) { + var source = displayName || email || 'Crank'; + return source + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map(function(part) { return part.charAt(0).toUpperCase(); }) + .join('') || 'CR'; + } + + function persistUserMirror(session) { + var membership = primaryMembership(session); + var user = { + id: session.user.id, + name: session.user.display_name, + email: session.user.email, + role: membership ? roleLabel(membership.role) : tKey('workspace_setup.role.viewer'), + workspace: membership ? membership.workspace.slug : '', + workspaceId: membership ? membership.workspace.id : '', + initials: initials(session.user.display_name, session.user.email), + }; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(user)); + } catch (_error) {} + } + + function mirroredUser() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)); + } catch (_error) { + return null; + } + } + + function renderShellIdentity(session) { + var membership = primaryMembership(session); + var fallbackUser = mirroredUser(); + var displayName = session && session.user + ? (session.user.display_name || session.user.email || 'Crank') + : (fallbackUser && fallbackUser.name) || 'Crank'; + var email = session && session.user + ? session.user.email + : fallbackUser && fallbackUser.email; + var role = membership + ? roleLabel(membership.role) + : (fallbackUser && fallbackUser.role) || tKey('workspace_setup.role.viewer'); + var workspace = membership && membership.workspace + ? (membership.workspace.display_name || membership.workspace.slug || membership.workspace.id) + : currentWorkspaceLabel(); + var avatarValue = initials(displayName, email); + + document.querySelectorAll('.nav-avatar').forEach(function(element) { + element.textContent = avatarValue; + }); + document.querySelectorAll('.user-dropdown-name').forEach(function(element) { + element.textContent = displayName; + }); + document.querySelectorAll('.user-dropdown-role').forEach(function(element) { + element.textContent = role + ' · ' + workspace; + }); + if (window.CrankDiagnostics && typeof window.CrankDiagnostics.ensureShellTestIds === 'function') { + window.CrankDiagnostics.ensureShellTestIds(); + } + } + + function replaceSession(session) { + sessionCache = session; + persistUserMirror(session); + renderShellIdentity(session); + window.dispatchEvent(new CustomEvent('crank:sessionchange', { detail: session })); + return session; + } + + async function fetchSession(force) { + if (!force && sessionCache) { + return sessionCache; + } + if (!force && sessionPromise) { + return sessionPromise; + } + + sessionPromise = window.CrankApi.getSession() + .then(function(session) { + return replaceSession(session); + }) + .catch(function(error) { + clearUserMirror(); + throw error; + }) + .finally(function() { + sessionPromise = null; + }); + + return sessionPromise; + } + + async function guardProtectedPage() { + try { + return await fetchSession(false); + } catch (error) { + if (error && error.status === 401) { + window.location.replace(loginUrl()); + return null; + } + throw error; + } + } + + async function guardLoginPage() { + try { + await fetchSession(false); + window.location.replace(homeUrl()); + } catch (error) { + if (!error || error.status !== 401) { + throw error; + } + } + } + + async function login(email, password) { + var session = await window.CrankApi.login({ + email: email, + password: password, + }); + replaceSession(session); + window.location.href = homeUrl(); + } + + async function logout() { + try { + await window.CrankApi.logout(); + } finally { + clearUserMirror(); + window.location.href = loginUrl(); + } + } + + function handleUnauthorized() { + if (isLoginPage()) { + clearUserMirror(); + return; + } + clearUserMirror(); + window.location.replace(loginUrl()); + } + + window.CrankAuth = { + fetchSession: fetchSession, + replaceSession: replaceSession, + getCachedSession: function() { return sessionCache; }, + renderShellIdentity: function() { + renderShellIdentity(sessionCache); + }, + guardProtectedPage: guardProtectedPage, + guardLoginPage: guardLoginPage, + login: login, + logout: logout, + handleUnauthorized: handleUnauthorized, + }; + + window.addEventListener('crank:workspacechange', function() { + renderShellIdentity(sessionCache); + }); + + window.addEventListener('crank:sessionchange', function(event) { + renderShellIdentity(event.detail || sessionCache); + }); + + document.addEventListener('DOMContentLoaded', function() { + renderShellIdentity(sessionCache); + }); +}()); diff --git a/apps/ui/js/catalog.js b/apps/ui/js/catalog.js new file mode 100644 index 0000000..279494d --- /dev/null +++ b/apps/ui/js/catalog.js @@ -0,0 +1,535 @@ +const PAGE_SIZE_DESKTOP = 6; +const PAGE_SIZE_MOBILE = 4; +const MOBILE_BREAKPOINT = 960; + +function getSortOptions() { + return [ + { value: 'created_desc', label: (window.t && t('sort.created_desc')) || 'Sort: Last created' }, + { value: 'created_asc', label: (window.t && t('sort.created_asc')) || 'Sort: Oldest first' }, + { value: 'name_asc', label: (window.t && t('sort.name_asc')) || 'Sort: Name A–Z' }, + { value: 'name_desc', label: (window.t && t('sort.name_desc')) || 'Sort: Name Z–A' }, + ]; +} + +function getTabDefs() { + return [ + { id: 'all', label: (window.t && t('tab.all')) || 'All' }, + { id: 'active', label: (window.t && t('tab.active')) || 'Active' }, + { id: 'draft', label: (window.t && t('tab.draft')) || 'Draft' }, + { id: 'error', label: (window.t && t('tab.error')) || 'Error' }, + { id: 'inactive', label: (window.t && t('tab.inactive')) || 'Inactive' }, + ]; +} + +function uiStatus(rawStatus) { + if (rawStatus === 'published') return 'active'; + if (rawStatus === 'archived') return 'inactive'; + if (rawStatus === 'testing') return 'active'; + return rawStatus || 'draft'; +} + +function currentLocale() { + return localStorage.getItem('crank_lang') === 'ru' ? 'ru-RU' : 'en-US'; +} + +function mapOperation(item) { + var mapped = { + id: item.id, + name: item.name, + display_name: item.display_name, + protocol: item.protocol, + status: uiStatus(item.status), + raw_status: item.status, + category: item.category || 'general', + created_at: item.created_at, + updated_at: item.updated_at, + published_at: item.published_at, + target_url: item.target_url || '', + method: item.target_action || '', + usage_summary: item.usage_summary || { + calls_today: 0, + error_rate_pct: 0, + avg_latency_ms: 0, + }, + agent_refs: item.agent_refs || [], + }; + + return window.localizeDemoOperation ? window.localizeDemoOperation(mapped) : mapped; +} + +function emptyStats() { + return { + total: 0, + active: 0, + requestsToday: 0, + averageLatencyMs: 0, + }; +} + +function computeStats(operations) { + if (!operations.length) return emptyStats(); + + var requestsToday = operations.reduce(function(sum, operation) { + return sum + (operation.usage_summary ? operation.usage_summary.calls_today : 0); + }, 0); + var latencyBase = operations.filter(function(operation) { + return operation.usage_summary && operation.usage_summary.calls_today > 0; + }); + var latencyWeight = latencyBase.reduce(function(sum, operation) { + return sum + operation.usage_summary.calls_today; + }, 0); + var weightedLatency = latencyBase.reduce(function(sum, operation) { + return sum + (operation.usage_summary.avg_latency_ms * operation.usage_summary.calls_today); + }, 0); + + return { + total: operations.length, + active: operations.filter(function(operation) { return operation.status === 'active'; }).length, + requestsToday: requestsToday, + averageLatencyMs: latencyWeight > 0 ? Math.round(weightedLatency / latencyWeight) : 0, + }; +} + +document.addEventListener('alpine:init', function() { + Alpine.data('catalog', function() { + return { + operations: [], + loading: true, + loadError: '', + pageSize: PAGE_SIZE_DESKTOP, + search: '', + tab: 'all', + filterProtocol: null, + filterCategory: null, + filterAgent: null, + sort: 'created_desc', + page: 1, + openDropdown: null, + navOpen: false, + categoryOptions: [], + workspaceId: null, + stats: emptyStats(), + _opsVersion: 0, + _nonStatusViewCache: null, + _nonStatusViewKey: '', + _filteredCache: null, + _filteredKey: '', + _agentsByOpIdCache: null, + _agentsByOpIdCacheVersion: -1, + _activeAgentsCache: null, + _activeAgentsCacheVersion: -1, + + async init() { + var self = this; + + await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve()); + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + this.workspaceId = workspace ? workspace.id : null; + + var updatePageSize = function() { + self.pageSize = window.innerWidth <= MOBILE_BREAKPOINT ? PAGE_SIZE_MOBILE : PAGE_SIZE_DESKTOP; + self.page = 1; + }; + + updatePageSize(); + window.addEventListener('resize', updatePageSize); + window.addEventListener('crank:langchange', function() { + self.sort = self.sort; + applyLang(); + }); + window.addEventListener('crank:workspacechange', async function(event) { + self.workspaceId = event.detail ? event.detail.id : null; + await self.reload(); + }); + document.addEventListener('click', function(event) { + if (!event.target.closest('.filter-dropdown, .sort-dropdown, .user-menu')) { + self.openDropdown = null; + } + if (!event.target.closest('.navbar')) { + self.navOpen = false; + } + }); + + await this.reload(); + }, + + async reload() { + this.loading = true; + this.loadError = ''; + + try { + var response = await window.CrankApi.listOperations(this.workspaceId); + this.replaceOperations((response && response.items ? response.items : []).map(mapOperation)); + this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) { + return operation.category; + }).filter(Boolean))).sort(); + this.stats = computeStats(this.operations); + } catch (error) { + this.replaceOperations([]); + this.categoryOptions = []; + this.stats = emptyStats(); + this.loadError = error.message || 'Failed to load operations'; + } + + this.loading = false; + this.page = 1; + }, + + replaceOperations(operations) { + this.operations = operations; + this._opsVersion += 1; + this._nonStatusViewCache = null; + this._nonStatusViewKey = ''; + this._filteredCache = null; + this._filteredKey = ''; + this._agentsByOpIdCache = null; + this._agentsByOpIdCacheVersion = -1; + this._activeAgentsCache = null; + this._activeAgentsCacheVersion = -1; + }, + + _applyNonStatusFilters(operations) { + var filtered = operations; + + if (this.search.trim()) { + var query = this.search.toLowerCase(); + filtered = filtered.filter(function(operation) { + return operation.name.toLowerCase().includes(query) + || operation.display_name.toLowerCase().includes(query) + || operation.category.toLowerCase().includes(query) + || (operation.target_url || '').toLowerCase().includes(query); + }); + } + + if (this.filterProtocol) { + filtered = filtered.filter(function(operation) { return operation.protocol === this.filterProtocol; }, this); + } + + if (this.filterCategory) { + filtered = filtered.filter(function(operation) { return operation.category === this.filterCategory; }, this); + } + + if (this.filterAgent) { + filtered = filtered.filter(function(operation) { + return (operation.agent_refs || []).some(function(agent) { return agent.agent_id === this.filterAgent; }, this); + }, this); + } + + return filtered; + }, + + _nonStatusView() { + var key = [ + this._opsVersion, + this.search.trim(), + this.filterProtocol || '', + this.filterCategory || '', + this.filterAgent || '', + ].join('|'); + + if (this._nonStatusViewCache && this._nonStatusViewKey === key) { + return this._nonStatusViewCache; + } + + var operations = this._applyNonStatusFilters(this.operations); + var tabCounts = { + all: operations.length, + active: 0, + draft: 0, + error: 0, + inactive: 0, + }; + + operations.forEach(function(operation) { + if (Object.prototype.hasOwnProperty.call(tabCounts, operation.status)) { + tabCounts[operation.status] += 1; + } + }); + + this._nonStatusViewKey = key; + this._nonStatusViewCache = { + operations: operations, + tabCounts: tabCounts, + }; + return this._nonStatusViewCache; + }, + + get filtered() { + var nonStatusView = this._nonStatusView(); + var key = [ + this._nonStatusViewKey, + this.tab, + this.sort, + ].join('|'); + + if (this._filteredCache && this._filteredKey === key) { + return this._filteredCache; + } + + var operations = nonStatusView.operations; + + if (this.tab !== 'all') { + operations = operations.filter(function(operation) { + return operation.status === this.tab; + }, this); + } + + operations = operations.slice().sort(function(left, right) { + if (this.sort === 'created_desc') return new Date(right.created_at) - new Date(left.created_at); + if (this.sort === 'created_asc') return new Date(left.created_at) - new Date(right.created_at); + if (this.sort === 'name_asc') return left.name.localeCompare(right.name); + if (this.sort === 'name_desc') return right.name.localeCompare(left.name); + return 0; + }.bind(this)); + + this._filteredKey = key; + this._filteredCache = operations; + return this._filteredCache; + }, + + get totalFiltered() { + return this.filtered.length; + }, + + get totalPages() { + return Math.max(1, Math.ceil(this.totalFiltered / this.pageSize)); + }, + + get paginated() { + var start = (this.page - 1) * this.pageSize; + return this.filtered.slice(start, start + this.pageSize); + }, + + get pageStart() { + return this.totalFiltered === 0 ? 0 : (this.page - 1) * this.pageSize + 1; + }, + + get pageEnd() { + return Math.min(this.page * this.pageSize, this.totalFiltered); + }, + + tabCount(tab) { + var tabCounts = this._nonStatusView().tabCounts; + return Object.prototype.hasOwnProperty.call(tabCounts, tab) ? tabCounts[tab] : 0; + }, + + get activeChips() { + var chips = []; + if (this.filterProtocol) chips.push({ key: 'protocol', label: this.tfKey('ops.filter.protocol', { value: this.filterProtocol.toUpperCase() }) }); + if (this.filterCategory) chips.push({ key: 'category', label: this.tfKey('ops.filter.category', { value: this.filterCategory }) }); + if (this.filterAgent) { + var agent = this.activeAgents.find(function(item) { return item.agent_id === this.filterAgent; }, this); + chips.push({ key: 'agent', label: this.tfKey('ops.filter.agent', { value: agent ? agent.display_name : '' }) }); + } + return chips; + }, + + get hasActiveFilters() { + return !!(this.filterProtocol || this.filterCategory || this.filterAgent || this.search.trim()); + }, + + clearChip(key) { + if (key === 'protocol') this.filterProtocol = null; + if (key === 'category') this.filterCategory = null; + if (key === 'agent') this.filterAgent = null; + this.page = 1; + }, + + clearAllFilters() { + this.filterProtocol = null; + this.filterCategory = null; + this.filterAgent = null; + this.search = ''; + this.tab = 'all'; + this.page = 1; + }, + + get agentsByOpId() { + if (this._agentsByOpIdCache && this._agentsByOpIdCacheVersion === this._opsVersion) { + return this._agentsByOpIdCache; + } + var map = {}; + this.operations.forEach(function(operation) { + map[operation.id] = operation.agent_refs || []; + }); + this._agentsByOpIdCacheVersion = this._opsVersion; + this._agentsByOpIdCache = map; + return this._agentsByOpIdCache; + }, + + get activeAgents() { + if (this._activeAgentsCache && this._activeAgentsCacheVersion === this._opsVersion) { + return this._activeAgentsCache; + } + var seen = {}; + var agents = []; + this.operations.forEach(function(operation) { + (operation.agent_refs || []).forEach(function(agent) { + if (!seen[agent.agent_id]) { + seen[agent.agent_id] = true; + agents.push(agent); + } + }); + }); + this._activeAgentsCacheVersion = this._opsVersion; + this._activeAgentsCache = agents.sort(function(left, right) { + return left.display_name.localeCompare(right.display_name); + }); + return this._activeAgentsCache; + }, + + get sortLabel() { + return getSortOptions().find(function(option) { return option.value === this.sort; }, this)?.label || 'Sort'; + }, + + setTab(tab) { + this.tab = tab; + this.page = 1; + }, + + setSearch(value) { + this.search = value; + this.page = 1; + }, + + setProtocol(value) { + this.filterProtocol = this.filterProtocol === value ? null : value; + this.openDropdown = null; + this.page = 1; + }, + + setCategory(value) { + this.filterCategory = this.filterCategory === value ? null : value; + this.openDropdown = null; + this.page = 1; + }, + + setSort(value) { + this.sort = value; + this.openDropdown = null; + }, + + setAgent(value) { + this.filterAgent = this.filterAgent === value ? null : value; + this.openDropdown = null; + this.page = 1; + }, + + clearProtocol() { + this.filterProtocol = null; + this.openDropdown = null; + this.page = 1; + }, + + clearCategory() { + this.filterCategory = null; + this.openDropdown = null; + this.page = 1; + }, + + clearAgent() { + this.filterAgent = null; + this.openDropdown = null; + this.page = 1; + }, + + toggleDropdown(name) { + this.openDropdown = this.openDropdown === name ? null : name; + }, + + goPage(page) { + if (page >= 1 && page <= this.totalPages) this.page = page; + }, + + editOperation(operation) { + window.location.href = ((window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/') + + '?mode=edit&operationId=' + encodeURIComponent(operation.id); + }, + + async deleteOperation(id) { + if (!confirm(this.tKey('ops.delete.confirm'))) return; + try { + var operation = this.operations.find(function(item) { return item.id === id; }); + await window.CrankApi.deleteOperation(this.workspaceId, id); + this.replaceOperations(this.operations.filter(function(operation) { return operation.id !== id; })); + this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) { + return operation.category; + }).filter(Boolean))).sort(); + this.stats = computeStats(this.operations); + if (window.CrankUi) { + window.CrankUi.success( + this.tfKey('ops.delete.success.message', { + name: operation ? (operation.display_name || operation.name) : '' + }), + this.tKey('ops.delete.success.title') + ); + } + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || this.tKey('ops.delete.error.message'), + this.tKey('ops.delete.error.title') + ); + } + } + }, + + protocolLabel(operation) { + return operation.method ? ('REST · ' + operation.method) : 'REST'; + }, + + protocolClass(operation) { + return 'badge badge-' + operation.protocol; + }, + + statusClass(operation) { + return 'status-badge status-' + operation.status; + }, + + statusLabel(operation) { + if (operation.status === 'active') return this.tKey('tab.active'); + if (operation.status === 'inactive') return this.tKey('tab.inactive'); + if (operation.status === 'error') return this.tKey('tab.error'); + return this.tKey('tab.draft'); + }, + + formatDate(dateStr) { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString(currentLocale(), { month: 'short', day: 'numeric', year: 'numeric' }); + }, + + truncateUrl(url) { + if (!url) return '—'; + return url.length > 42 ? url.slice(0, 42) + '…' : url; + }, + + formatMetricNumber(value) { + return new Intl.NumberFormat(currentLocale()).format(value || 0); + }, + + tKey(key) { + return window.t ? t(key) : key; + }, + + tfKey(key, vars) { + return window.tf ? tf(key, vars) : this.tKey(key); + }, + + get sortOptions() { + return getSortOptions(); + }, + + get tabDefs() { + return getTabDefs(); + }, + + handleNewOperation() { + window.location.href = (window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/'; + }, + + handleLogout() { + window.CrankAuth.logout(); + }, + }; + }); +}); diff --git a/apps/ui/js/config.js b/apps/ui/js/config.js new file mode 100644 index 0000000..4f6bf5a --- /dev/null +++ b/apps/ui/js/config.js @@ -0,0 +1,16 @@ +(function() { + window.APP_BASE = '/'; + window.DATA_URL = '/data/'; + window.CrankRoutes = { + home: '/', + login: '/login', + agents: '/agents', + apiKeys: '/api-keys', + secrets: '/secrets', + logs: '/logs', + usage: '/usage', + settings: '/settings', + workspaceSetup: '/workspace-setup', + wizard: '/wizard/' + }; +}()); diff --git a/apps/ui/js/diagnostics.js b/apps/ui/js/diagnostics.js new file mode 100644 index 0000000..e9833a5 --- /dev/null +++ b/apps/ui/js/diagnostics.js @@ -0,0 +1,111 @@ +(function() { + var currentPage = ''; + + function messageFor(error) { + if (!error) { + return 'unknown error'; + } + if (error.message) { + return error.message; + } + return String(error); + } + + function setPage(pageName) { + currentPage = pageName || ''; + document.documentElement.setAttribute('data-crank-page', currentPage || 'unknown'); + } + + function markBootstrapState(pageName, status, details) { + document.documentElement.setAttribute('data-crank-bootstrap-page', pageName || currentPage || 'unknown'); + document.documentElement.setAttribute('data-crank-bootstrap-state', status); + if (status === 'error' && details) { + document.documentElement.setAttribute('data-crank-bootstrap-error', details); + return; + } + document.documentElement.removeAttribute('data-crank-bootstrap-error'); + } + + function report(stage, error, pageName) { + var resolvedPage = pageName || currentPage || 'unknown'; + console.error('[CrankUI][' + resolvedPage + '][' + stage + ']', error); + window.dispatchEvent(new CustomEvent('crank:ui-error', { + detail: { + page: resolvedPage, + stage: stage, + message: messageFor(error), + }, + })); + } + + function bootstrap(pageName, init) { + function run() { + setPage(pageName); + markBootstrapState(pageName, 'pending'); + try { + var result = init(); + if (result && typeof result.then === 'function') { + result.then(function() { + markBootstrapState(pageName, 'ready'); + }).catch(function(error) { + markBootstrapState(pageName, 'error', messageFor(error)); + report('bootstrap', error, pageName); + }); + return; + } + markBootstrapState(pageName, 'ready'); + } catch (error) { + markBootstrapState(pageName, 'error', messageFor(error)); + report('bootstrap', error, pageName); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run, { once: true }); + return; + } + run(); + } + + function ensureTestId(element, value) { + if (!element || !value) { + return; + } + element.setAttribute('data-testid', value); + } + + function ensureShellTestIds() { + document.querySelectorAll('.nav-avatar').forEach(function(element) { + ensureTestId(element, 'shell-avatar'); + }); + document.querySelectorAll('.user-dropdown-name').forEach(function(element) { + ensureTestId(element, 'shell-user-name'); + }); + document.querySelectorAll('.user-dropdown-role').forEach(function(element) { + ensureTestId(element, 'shell-user-role'); + }); + ensureTestId(document.getElementById('ws-current-name'), 'shell-workspace-name'); + } + + window.addEventListener('error', function(event) { + if (!currentPage) { + return; + } + report('runtime', event.error || new Error(event.message || 'window error')); + }); + + window.addEventListener('unhandledrejection', function(event) { + if (!currentPage) { + return; + } + report('unhandledrejection', event.reason || new Error('unhandled rejection')); + }); + + window.CrankDiagnostics = { + bootstrap: bootstrap, + ensureTestId: ensureTestId, + ensureShellTestIds: ensureShellTestIds, + report: report, + setPage: setPage, + }; +}()); diff --git a/apps/ui/js/dom.js b/apps/ui/js/dom.js new file mode 100644 index 0000000..6bb4e95 --- /dev/null +++ b/apps/ui/js/dom.js @@ -0,0 +1,32 @@ +(function() { + function clear(element) { + if (!element) { + return; + } + while (element.firstChild) { + element.removeChild(element.firstChild); + } + } + + function createEmptyState(title, body) { + var root = document.createElement('div'); + root.className = 'empty-state'; + + var titleNode = document.createElement('div'); + titleNode.className = 'empty-state-title'; + titleNode.textContent = title; + root.appendChild(titleNode); + + var bodyNode = document.createElement('div'); + bodyNode.className = 'empty-state-text'; + bodyNode.textContent = body; + root.appendChild(bodyNode); + + return root; + } + + window.CrankDom = { + clear: clear, + createEmptyState: createEmptyState, + }; +}()); diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js new file mode 100644 index 0000000..b4781ff --- /dev/null +++ b/apps/ui/js/i18n.js @@ -0,0 +1,2099 @@ +/* ═══════════════════════════════════════════════ + Crank — i18n + Reads `crank_lang` from localStorage ('en'|'ru') + ═══════════════════════════════════════════════ */ + +var TRANSLATIONS = { + en: { + // Nav + 'nav.operations': 'Operations', + 'nav.agents': 'Agents', + 'nav.apikeys': 'API Keys', + 'nav.secrets': 'Secrets', + 'nav.logs': 'Logs', + 'nav.usage': 'Usage', + 'nav.settings': 'Settings', + 'nav.logout': 'Log out', + 'nav.workspaces': 'Your workspaces', + 'nav.create_workspace': 'Create workspace', + 'nav.notifications': 'Notifications', + 'nav.menu': 'Menu', + 'nav.account': 'Account', + 'nav.user_fallback': 'Crank', + 'nav.workspace_fallback': 'workspace', + + // Operations page + 'ops.title': 'Operations', + 'ops.subtitle': 'Tool contracts that expose your upstream APIs as MCP tools', + 'ops.new': 'New operation', + 'ops.loading': 'Loading operations…', + 'ops.empty.title': 'No operations found', + 'ops.empty.sub': 'Try adjusting your search or filters.', + 'ops.empty.initial.title': 'No operations yet', + 'ops.empty.initial.sub': 'Create the first operation in this workspace to expose a live MCP tool.', + 'ops.error.title': 'Backend connection failed', + 'ops.delete.confirm': 'Delete this operation? This cannot be undone.', + 'ops.delete.success.title': 'Operation deleted', + 'ops.delete.success.message': '{name} was deleted.', + 'ops.delete.error.title': 'Delete failed', + 'ops.delete.error.message': 'Failed to delete operation', + 'ops.action.edit': 'Edit operation', + 'ops.action.delete':'Delete operation', + 'ops.stats.synced': 'Catalog synced with backend', + 'ops.stats.none': 'No operations yet', + 'ops.stats.share': '{value}% of total', + 'ops.stats.no_published': 'No published tools yet', + 'ops.stats.requests_rollup': 'Summed from operation usage', + 'ops.stats.no_traffic': 'No traffic recorded today', + 'ops.stats.latency_weighted': "Weighted by today's calls", + 'ops.stats.latency_waiting': 'Waiting for runtime samples', + 'ops.results': 'Showing {shown} of {total}', + 'ops.range': 'Showing {from}–{to} of {total} operations', + 'ops.filter.protocol': 'Protocol: {value}', + 'ops.filter.category': 'Category: {value}', + 'ops.filter.agent': 'Agent: {value}', + 'ops.no_active_agents': 'No active agents', + + // Stats + 'stats.total': 'Total operations', + 'stats.active': 'Active', + 'stats.requests': 'Requests today', + 'stats.latency': 'Avg. latency', + + // Tabs + 'tab.all': 'All', + 'tab.active': 'Active', + 'tab.draft': 'Draft', + 'tab.error': 'Error', + 'tab.inactive': 'Inactive', + + // Filters & sort + 'filter.search': 'Search operations...', + 'filter.protocol': 'Protocol', + 'filter.status': 'Status', + 'filter.category': 'Category', + 'filter.agent': 'Agent', + 'filter.showing': 'Showing', + 'filter.of': 'of', + 'filter.clear': 'Clear filter', + 'filter.clear_all': 'Clear all', + 'filter.remove': 'Remove filter', + 'sort.created_desc':'Sort: Last created', + 'sort.created_asc': 'Sort: Oldest first', + 'sort.name_asc': 'Sort: Name A–Z', + 'sort.name_desc': 'Sort: Name Z–A', + + // Table headers + 'th.name': 'NAME', + 'th.protocol': 'PROTOCOL', + 'th.status': 'STATUS', + 'th.created': 'CREATED', + 'th.url': 'TARGET URL', + + // Pagination + 'page.showing': 'Showing', + 'page.of': 'of', + 'page.ops': 'operations', + + // API Keys page + 'apikeys.title': 'Agent Keys', + 'apikeys.subtitle': 'Keys authenticate external MCP clients against a specific AI agent endpoint.', + 'apikeys.new': 'Create key', + 'apikeys.agent.title': 'Agent access', + 'apikeys.agent.subtitle': 'Select the AI agent whose MCP endpoint should accept this key.', + 'apikeys.agent.label': 'AI agent', + 'apikeys.agent.empty': 'No agents available', + 'apikeys.agent.empty_hint': 'Create an AI agent first, then issue machine access keys for its MCP endpoint.', + 'apikeys.agent.endpoint': 'MCP endpoint: {endpoint}', + 'apikeys.agent.endpoint_missing': 'The selected agent does not have a published MCP endpoint yet.', + 'apikeys.machine_access.title': 'Machine access modes', + 'apikeys.machine_access.title_community': 'Community machine access', + 'apikeys.machine_access.subtitle': 'This build exposes the stable machine-access contract and shows which modes are available now.', + 'apikeys.machine_access.subtitle_community': 'Community exposes the stable MCP machine-access contract, but only static AI-agent keys are active in this build.', + 'apikeys.machine_access.summary_default': 'Community currently supports static AI-agent keys.', + 'apikeys.machine_access.summary': 'This build supports {access_modes} for operations marked with {security_levels}. Short-lived and one-time token issuance keeps the same public HTTP surface when a commercial edition is enabled.', + 'apikeys.machine_access.note': 'Short-lived and one-time token issuance uses the same public HTTP surface, but requires a commercial edition.', + 'apikeys.machine_access.note_community': 'Use static AI-agent keys in Community. Short-lived tokens and one-time tokens require a commercial edition.', + 'apikeys.th.name': 'NAME', + 'apikeys.th.scope': 'SCOPE', + 'apikeys.th.created':'CREATED', + 'apikeys.th.last': 'LAST USED', + 'apikeys.callout.title': 'Keys are only shown once.', + 'apikeys.callout.body': 'Copy and store it securely right after creation — Crank stores only a hash. Last used updates after a successful MCP call.', + 'apikeys.active.title': 'Active keys', + 'apikeys.active.subtitle': '{active} active · {revoked} revoked', + 'apikeys.search': 'Filter keys…', + 'apikeys.th.prefix': 'KEY PREFIX', + 'apikeys.th.scopes': 'SCOPES', + 'apikeys.th.status': 'STATUS', + 'apikeys.scope_ref': 'Scope reference', + '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.modal.title': 'Create agent 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', + 'apikeys.modal.scopes': 'Scopes', + 'apikeys.modal.reveal_title': 'Copy this key now.', + 'apikeys.modal.reveal_body': "It won't be shown again.", + 'apikeys.modal.copy': 'Copy to clipboard', + 'apikeys.modal.cancel': 'Cancel', + 'apikeys.modal.create': 'Create key', + 'apikeys.modal.done': "Done — I've copied the key", + 'apikeys.status.active': 'Active', + 'apikeys.status.revoked': 'Revoked', + 'apikeys.last_used.never': 'Never', + 'apikeys.empty.none': 'No API keys yet', + 'apikeys.empty.search': 'No keys match your search', + 'apikeys.loading': 'Loading…', + 'apikeys.error.api': 'Workspace or API is unavailable', + 'apikeys.error.load': 'Failed to load API keys', + 'apikeys.confirm.revoke': 'Revoke this key? All requests using it will fail immediately.', + 'apikeys.confirm.delete': 'Delete this key? This cannot be undone.', + 'apikeys.toast.revoke_title': 'Key revoked', + 'apikeys.toast.revoke_message': '{name} was revoked.', + 'apikeys.toast.revoke_error_title': 'Revoke failed', + 'apikeys.toast.revoke_error_message': 'Failed to revoke key', + 'apikeys.toast.delete_title': 'Key deleted', + 'apikeys.toast.delete_message': '{name} was deleted.', + 'apikeys.toast.delete_error_title': 'Delete failed', + 'apikeys.toast.delete_error_message': 'Failed to delete key', + 'apikeys.toast.scope_missing_title': 'Missing scope', + 'apikeys.toast.scope_missing_message': 'Select at least one scope before creating a key.', + 'apikeys.toast.create_title': 'Agent key created', + 'apikeys.toast.create_message': 'Copy the key now. It will not be shown again.', + 'apikeys.toast.create_error_title': 'Key creation failed', + 'apikeys.toast.create_error_message': 'Failed to create key', + 'apikeys.toast.copy_title': 'Agent key copied', + 'apikeys.toast.copy_message': 'Store the raw key securely. It cannot be revealed again.', + 'apikeys.toast.prefix_title': 'Key prefix copied', + 'apikeys.action.copy_prefix': 'Copy key prefix', + 'apikeys.action.revoke': 'Revoke key', + 'apikeys.action.delete': 'Delete', + 'apikeys.creating': 'Creating…', + + // Secrets page + 'secrets.title': 'Secrets', + 'secrets.subtitle': 'Encrypted upstream credentials for bearer tokens, basic auth, and API keys used by auth profiles.', + 'secrets.new': 'Create secret', + 'secrets.callout.title': 'Secret plaintext is write-only.', + 'secrets.callout.body': 'Crank encrypts secret values with the workspace master key. After creation or rotation, the API returns only metadata, so this page focuses on secret management and where each secret is used.', + 'secrets.active.title': 'Workspace secrets', + 'secrets.active.subtitle': '{active} active · {total} total', + 'secrets.search': 'Filter secrets…', + 'secrets.th.name': 'NAME', + 'secrets.th.kind': 'KIND', + 'secrets.th.version': 'VERSION', + 'secrets.th.last_used': 'LAST USED', + 'secrets.th.used_by': 'USED BY', + 'secrets.th.status': 'STATUS', + 'secrets.kind.token': 'Token', + 'secrets.kind.username_password': 'Username/password', + 'secrets.kind.header': 'Header value', + 'secrets.kind.generic': 'Generic JSON', + 'secrets.status.active': 'Active', + 'secrets.status.disabled': 'Disabled', + 'secrets.last_used.never': 'Never', + 'secrets.used_by_count.one': '{count} profile', + 'secrets.used_by_count.other': '{count} profiles', + 'secrets.loading': 'Loading…', + 'secrets.empty.none': 'No secrets yet', + 'secrets.empty.search': 'No secrets match your search', + 'secrets.error.workspace': 'Workspace is not selected', + 'secrets.error.load': 'Failed to load secrets', + 'secrets.action.rotate': 'Rotate', + 'secrets.action.delete': 'Delete', + 'secrets.confirm.delete': 'Delete secret {name}? This cannot be undone while the secret is still referenced by an auth profile.', + 'secrets.toast.create_title': 'Secret created', + 'secrets.toast.create_message': 'The secret was stored encrypted. Plaintext cannot be viewed again through the API.', + 'secrets.toast.create_error_title': 'Secret creation failed', + 'secrets.toast.create_error_message': 'Failed to create secret', + 'secrets.toast.rotate_title': 'Secret rotated', + 'secrets.toast.rotate_message': 'A new secret version is now active.', + 'secrets.toast.rotate_error_title': 'Secret rotation failed', + 'secrets.toast.rotate_error_message': 'Failed to rotate secret', + 'secrets.toast.delete_title': 'Secret deleted', + 'secrets.toast.delete_message': '{name} was deleted.', + 'secrets.toast.delete_error_title': 'Secret deletion failed', + 'secrets.toast.delete_error_message': 'Failed to delete secret', + 'secrets.modal.create_title': 'Create secret', + 'secrets.modal.rotate_title': 'Rotate secret', + 'secrets.modal.name': 'Secret name', + 'secrets.modal.name_hint': 'Use a stable operator-facing label. Plaintext values are never returned after storage.', + 'secrets.modal.kind': 'Secret kind', + 'secrets.modal.value': 'Secret value', + 'secrets.modal.header_value': 'Header value', + 'secrets.modal.username': 'Username', + 'secrets.modal.password': 'Password', + 'secrets.modal.json_payload': 'JSON payload', + 'secrets.modal.json_hint': 'Useful for generic secret payloads. The value must be valid JSON.', + 'secrets.modal.create_note': 'Creation stores encrypted plaintext and returns metadata only.', + 'secrets.modal.rotate_note': 'Rotation creates a new encrypted version and keeps the same secret id.', + 'secrets.modal.cancel': 'Cancel', + 'secrets.modal.create_action': 'Create secret', + 'secrets.modal.rotate_action': 'Rotate secret', + 'secrets.modal.creating': 'Creating…', + 'secrets.modal.rotating': 'Rotating…', + 'secrets.hint.token': 'Best for bearer tokens and simple API key values.', + 'secrets.hint.username_password': 'Stores username and password as one encrypted payload.', + 'secrets.hint.header': 'Stores a raw header value. Header name is configured in the auth profile.', + 'secrets.hint.generic': 'Stores arbitrary JSON for custom integrations that need a structured secret payload.', + 'secrets.validation.name_required': 'Secret name is required.', + 'secrets.validation.value_required': 'Secret value is required.', + 'secrets.validation.username_password_required': 'Username and password are both required.', + 'secrets.validation.json_invalid': 'Generic secret payload must be valid JSON.', + 'secrets.profiles.title': 'Auth profile references', + 'secrets.profiles.subtitle': 'Profiles resolve secrets at runtime before upstream execution.', + 'secrets.profiles.subtitle_count.one': '{count} auth profile', + 'secrets.profiles.subtitle_count.other': '{count} auth profiles', + 'secrets.profiles.error_title': 'Unable to load auth profile references', + 'secrets.profiles.loading_title': 'Loading auth profile references…', + 'secrets.profiles.loading_body': 'Fetching auth profiles for the current workspace.', + 'secrets.profiles.empty_title': 'No auth profiles yet', + 'secrets.profiles.empty_body': 'Create auth profiles in the Wizard when configuring an operation.', + 'secrets.profiles.references': 'Referenced secrets', + 'secrets.profiles.reference_count': '{count} refs', + 'secrets.profiles.meta.created': 'Created', + 'secrets.profiles.meta.updated': 'Updated', + 'secrets.profiles.summary_bearer': 'Bearer auth via {header} using {secret}.', + 'secrets.profiles.summary_basic': 'Basic auth using {username} and {password}.', + 'secrets.profiles.summary_api_key_header': 'API key header {header} using {secret}.', + 'secrets.profiles.summary_api_key_query': 'API key query param {param} using {secret}.', + 'secrets.profiles.summary_unknown': 'Unknown auth profile configuration.', + 'secrets.auth_kind.bearer': 'Bearer auth', + 'secrets.auth_kind.basic': 'Basic auth', + 'secrets.auth_kind.api_key_header': 'API key header', + 'secrets.auth_kind.api_key_query': 'API key query', + + // Logs page + 'logs.title': 'Logs', + 'logs.subtitle': 'Real-time invocation log for all operations in this workspace.', + 'logs.search': 'Search logs...', + 'logs.level.all': 'All', + 'logs.level.info': 'Info', + 'logs.level.warn': 'Warn', + 'logs.level.error': 'Error', + 'logs.level.debug': 'Debug', + 'logs.live': 'Live', + 'logs.paused': 'Paused', + 'logs.refresh': 'Refresh', + 'logs.range.30m': 'Last 30 min', + 'logs.range.1h': 'Last hour', + 'logs.range.6h': 'Last 6 hours', + 'logs.range.24h': 'Last 24 hours', + 'logs.range.7d': 'Last 7 days', + 'logs.search_messages': 'Search messages…', + 'logs.detail.agent': 'Agent', + 'logs.detail.request': 'Request preview', + 'logs.detail.response': 'Response preview', + 'logs.detail.meta': 'Metadata', + 'logs.loading.title': 'Loading logs…', + 'logs.loading.sub': 'Fetching invocation history for the current workspace.', + 'logs.error.title': 'Unable to load logs', + 'logs.error.api': 'Admin API is not available', + 'logs.error.workspace': 'Workspace is not selected', + 'logs.error.load': 'Failed to load logs', + 'logs.empty.title': 'No log entries found', + 'logs.empty.filtered': 'Try widening the period or clearing the current search and level filters.', + 'logs.empty.initial': 'Run tests or published MCP tools to start collecting invocation history.', + 'logs.live.on.title': 'Live mode enabled', + 'logs.live.on.body': 'Polling logs every 4 seconds.', + 'logs.live.off.title': 'Live mode paused', + '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.', + + // Usage page + 'usage.title': 'Usage', + 'usage.subtitle': 'Invocation metrics for all operations in your workspace.', + 'usage.export': 'Export CSV', + 'usage.period.7d': 'Last 7 days', + 'usage.period.30d': 'Last 30 days', + 'usage.period.90d': 'Last 90 days', + 'usage.period.this_month': 'This month', + 'usage.period.label.7d': 'last 7 days', + 'usage.period.label.30d': 'last 30 days', + 'usage.period.label.90d': 'last 90 days', + 'usage.period.label.this_month': 'this month', + 'usage.stats.total': 'Total invocations', + 'usage.stats.success': 'Success rate', + 'usage.stats.p50': 'Median latency (p50)', + 'usage.stats.p99': 'p99 latency', + 'usage.stats.across': 'Across {period}', + 'usage.stats.success_calls': '{count} successful calls', + 'usage.stats.median': 'Median latency for selected period', + 'usage.stats.error_calls': '{count} error calls', + 'usage.chart.title': 'Invocations over time', + 'usage.chart.success': 'Success', + 'usage.chart.error': 'Error', + 'usage.chart.empty.title': 'No usage data yet', + 'usage.chart.empty.sub': 'Invocation metrics will appear here after tests or published tool calls in the selected period.', + 'usage.table.title': 'By operation', + 'usage.table.subtitle': 'Breakdown for {period}', + 'usage.table.th.operation': 'Operation', + 'usage.table.th.protocol': 'Protocol', + 'usage.table.th.calls': 'Calls', + 'usage.table.th.errors': 'Errors', + 'usage.table.th.error_rate': 'Error rate', + 'usage.table.th.latency': 'Latency (p50 / p95 / p99)', + 'usage.table.th.share': 'Traffic share', + 'usage.table.empty.title': 'No operation breakdown yet', + 'usage.table.empty.sub': 'The selected period has no invocation samples to break down by operation.', + 'usage.table.share': '{value}% of workspace traffic', + 'usage.loading.title': 'Loading usage…', + 'usage.loading.sub': 'Aggregating invocation metrics for the selected workspace.', + 'usage.error.title': 'Unable to load usage', + 'usage.error.api': 'Admin API is not available', + 'usage.error.workspace': 'Workspace is not selected', + 'usage.error.load': 'Failed to load usage', + 'usage.export.empty.title': 'No usage data loaded', + 'usage.export.empty.body': 'Load usage data before exporting the CSV snapshot.', + 'usage.export.done.title': 'Usage exported', + 'usage.export.done.body': 'The current usage snapshot was exported as CSV.', + 'usage.chart.ok': '{count} ok', + 'usage.chart.errors': '{count} errors', + 'usage.chart.week': 'Wk {index}', + 'usage.csv.header': 'Operation,Protocol,Calls,Errors,Error Rate (%),p50 (ms),p95 (ms),p99 (ms)', + + // Settings sidebar + 'settings.title': 'Settings', + 'settings.nav.workspace': 'Workspace', + 'settings.nav.profile': 'Profile', + 'settings.nav.members': 'Members', + 'settings.nav.security': 'Security', + 'settings.nav.notif': 'Notifications', + 'settings.nav.danger': 'Danger zone', + + // Settings — workspace section + 'settings.ws.title': 'Workspace settings', + 'settings.ws.subtitle': 'General information about your workspace', + 'settings.ws.name': 'Workspace name', + 'settings.ws.display': 'Display name', + 'settings.ws.desc': 'Description', + 'settings.ws.tz': 'Timezone', + 'settings.ws.protocol': 'Default protocol', + + // Settings — language + 'settings.lang.title': 'Language', + 'settings.lang.subtitle': 'Interface display language', + 'settings.lang.en': 'English', + 'settings.lang.ru': 'Русский', + + // Settings — profile section + 'settings.profile.title': 'Profile', + 'settings.profile.name': 'Full name', + 'settings.profile.email': 'Email', + 'settings.profile.role': 'Role', + 'settings.page.title': 'Account settings', + 'settings.page.subtitle': 'Manage your profile, password, workspace settings, and the capability limits of this build.', + 'settings.ws.slug_hint': 'Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.', + 'settings.ws.protocol_hint': 'This build limits the default protocol to the capabilities available in the current edition.', + 'settings.ws.protocol_hint_capabilities': 'This build allows {protocols} as workspace defaults. Additional protocols depend on the edition.', + 'settings.ws.description_optional': 'Description (optional)', + 'settings.ws.description_placeholder': 'What does this workspace do?', + 'settings.profile.avatar_hint': 'Your avatar is generated from your display name and email.', + 'settings.profile.backend_hint': 'Your name and email are stored on the backend and used across the console and session identity.', + 'settings.profile.first_name': 'First name', + 'settings.profile.last_name': 'Last name', + 'settings.profile.last_name_placeholder': 'Optional', + 'settings.profile.email_label': 'Email address', + 'settings.profile.email_hint': 'Changing email updates the login identity for the current account.', + 'settings.profile.save': 'Save profile', + 'settings.profile.saving': 'Saving…', + 'settings.profile.saved': 'Saved ✓', + 'settings.profile.saved_status': 'Profile saved.', + 'settings.profile.save_error': 'Failed to save profile', + 'settings.security.title': 'Security', + 'settings.security.current_password': 'Current password', + 'settings.security.new_password': 'New password', + 'settings.security.new_password_placeholder': 'min. 12 characters', + 'settings.security.confirm_password': 'Confirm new password', + 'settings.security.change_password': 'Change password', + 'settings.security.mismatch': 'New password and confirmation do not match.', + 'settings.security.saved': 'Password updated.', + 'settings.security.save_error': 'Failed to change password', + 'settings.security.capabilities_title': 'Build capability summary', + 'settings.security.capabilities_body': 'This section shows what the current build supports. Password sign-in with one HttpOnly browser session is available now; short-lived tokens, one-time tokens, and advanced governance depend on the edition.', + 'settings.security.not_available': 'Build capabilities', + 'settings.security.capability_title_community': 'Community build', + 'settings.security.capability_title_enterprise': 'Enterprise build', + 'settings.security.capability_title_cloud': 'Cloud build', + 'settings.security.capability_summary': 'This build supports {protocols}, {access_modes}, and {security_levels} operation security. Limits: {max_workspaces} workspace, {max_users} user, {max_agents} AI agent. Short-lived tokens, one-time tokens, and advanced governance require a commercial edition.', + 'settings.capability.rest': 'REST / HTTP', + 'settings.capability.graphql': 'GraphQL', + 'settings.capability.grpc': 'gRPC unary', + 'settings.capability.websocket': 'WebSocket', + 'settings.capability.soap': 'SOAP', + 'settings.capability.standard': 'standard', + 'settings.capability.elevated': 'elevated', + 'settings.capability.strict': 'strict', + 'settings.capability.static_agent_key': 'static agent keys', + 'settings.capability.short_lived_token': 'short-lived tokens', + 'settings.capability.one_time_token': 'one-time tokens', + 'settings.capability.unlimited': 'unlimited', + 'settings.capability.none': 'none', + 'settings.session.title': 'Current session', + 'settings.session.browser': 'Browser session', + 'settings.session.current': 'current', + 'settings.session.loading': 'Loading current session details…', + 'settings.session.unavailable': 'Session details are unavailable. Use Log out to revoke the current browser session.', + 'settings.session.current_workspace': 'current workspace', + 'settings.session.summary': '{email} · {role} in {workspace}. Use Log out to revoke the current browser session.', + 'settings.notifications.title': 'Notifications', + 'settings.notifications.not_ready_title': 'Not included in this build.', + 'settings.notifications.not_ready_body': 'Notification routing and per-user preferences are outside the current Community surface.', + 'settings.notifications.capability_title_community': 'Community build boundary', + 'settings.notifications.capability_body_community': 'Per-user notification routing and delivery channels are outside the current Community surface. Use logs and usage views for operational follow-up in this build.', + 'settings.notifications.capability_title_enterprise': 'Enterprise delivery controls', + 'settings.notifications.capability_body_enterprise': 'Notification routing depends on enterprise delivery controls and is wired separately from the Community console surface.', + 'settings.notifications.capability_title_cloud': 'Cloud delivery controls', + 'settings.notifications.capability_body_cloud': 'Notification routing depends on hosted delivery controls and is wired separately from the Community console surface.', + 'settings.planned_badge': 'Planned', + 'settings.notifications.spike_title': 'Operation error spike', + 'settings.notifications.spike_body': 'Alert when error rate exceeds a rolling threshold for a published tool.', + 'settings.notifications.latency_title': 'Upstream latency degradation', + 'settings.notifications.latency_body': 'Notify operators when p99 latency rises sharply above the baseline.', + 'settings.notifications.digest_title': 'Quota and usage digests', + 'settings.notifications.digest_body': 'Deliver periodic summaries for quota consumption, errors and workspace activity.', + 'settings.workspace.saving': 'Saving…', + 'settings.workspace.saved': 'Saved ✓', + 'settings.workspace.save_error': 'Failed to save workspace settings', + 'settings.workspace.save_error_title': 'Workspace save failed', + + // Workspace setup + 'workspace_setup.title': 'Workspace settings', + 'workspace_setup.page_subtitle': 'Configure your workspace identity, default settings, and team members.', + 'workspace_setup.back': 'Back', + 'workspace_setup.identity.title': 'Workspace identity', + 'workspace_setup.identity.avatar_hint': 'Avatar is derived from your workspace name.', + 'workspace_setup.name': 'Workspace name', + 'workspace_setup.slug': 'Slug', + 'workspace_setup.required': 'required', + 'workspace_setup.optional': '(optional)', + 'workspace_setup.slug_hint': 'Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.', + 'workspace_setup.description': 'Description', + 'workspace_setup.description_placeholder': 'What does this workspace do?', + 'workspace_setup.defaults.title': 'Default settings', + 'workspace_setup.protocol': 'Default protocol', + 'workspace_setup.protocol_hint': 'Pre-selected in the operation wizard.', + 'workspace_setup.timezone': 'Timezone', + 'workspace_setup.language': 'Interface language', + 'workspace_setup.members.title': 'Team members', + 'workspace_setup.members.invite': 'Invite member', + 'workspace_setup.members.email': 'Email address', + 'workspace_setup.members.role': 'Role', + 'workspace_setup.members.send_invite': 'Send invite', + 'workspace_setup.members.cancel': 'Cancel', + 'workspace_setup.members.invite_sent': '✓ Invite sent.', + 'workspace_setup.members.you': '(you)', + 'workspace_setup.members.today': 'Today', + 'workspace_setup.members.days_ago.one': '{count} day ago', + 'workspace_setup.members.days_ago.other': '{count} days ago', + 'workspace_setup.members.week_ago': '1 week ago', + 'workspace_setup.members.never': 'Never', + 'workspace_setup.members.pending': 'Pending invites', + 'workspace_setup.members.invited_pending': 'Invited {date} · pending', + 'workspace_setup.members.revoke': 'Revoke invite', + 'workspace_setup.members.remove': 'Remove member', + 'workspace_setup.roles.title': 'Roles & permissions', + 'workspace_setup.roles.owner_body': 'Full workspace control, billing access, can transfer or delete the workspace.', + 'workspace_setup.roles.admin_body': 'Manage members and roles, create and edit all operations and agents, manage API keys.', + 'workspace_setup.roles.developer_body': 'Create and edit operations and agents. Cannot manage members or workspace settings.', + 'workspace_setup.roles.viewer_body': 'Read-only access to operations, agents, and logs.', + 'workspace_setup.invite.title': 'Invite team members', + 'workspace_setup.invite.add_person': '+ Add person', + 'workspace_setup.invite.later': 'You can invite more people later from Workspace settings.', + 'workspace_setup.actions.create': 'Create workspace', + 'workspace_setup.actions.save': 'Save changes', + 'workspace_setup.create.title': 'Create a new workspace', + 'workspace_setup.create.subtitle': "A workspace is a shared environment for your team's MCP operations and agents. Each workspace gets its own MCP endpoint namespace.", + 'workspace_setup.create.footer': "You'll be the Owner of this workspace. You can invite additional members after creation.", + 'workspace_setup.danger.title': 'Danger zone', + 'workspace_setup.danger.export_title': 'Export all data', + 'workspace_setup.danger.export_body': 'Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and agent access keys.', + 'workspace_setup.danger.delete_title': 'Delete workspace', + 'workspace_setup.danger.delete_body': 'Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.', + 'workspace_setup.export': 'Export', + 'workspace_setup.delete': 'Delete workspace', + 'workspace_setup.role.owner': 'Owner', + 'workspace_setup.role.admin': 'Admin', + 'workspace_setup.role.operator': 'Developer', + 'workspace_setup.role.viewer': 'Viewer', + 'workspace_setup.member_count.one': '{count} member', + 'workspace_setup.member_count.other': '{count} members', + 'workspace_setup.saving_create': 'Creating…', + 'workspace_setup.saving_update': 'Saving…', + 'workspace_setup.saved': 'Saved ✓', + 'workspace_setup.save_error': 'Failed to save workspace', + 'workspace_setup.save_error_title': 'Save failed', + 'workspace_setup.invite_error': 'Failed to send invite', + 'workspace_setup.invite_error_title': 'Invite failed', + 'workspace_setup.role_error': 'Failed to update role', + 'workspace_setup.role_error_title': 'Role update failed', + 'workspace_setup.remove_confirm': 'Remove {name} from this workspace?', + 'workspace_setup.remove_error': 'Failed to remove member', + 'workspace_setup.remove_error_title': 'Member removal failed', + 'workspace_setup.revoke_error': 'Failed to revoke invite', + 'workspace_setup.revoke_error_title': 'Invite revoke failed', + 'workspace_setup.export_error': 'Failed to export workspace', + 'workspace_setup.export_error_title': 'Export failed', + 'workspace_setup.delete_confirm': 'Delete this workspace? This cannot be undone.', + 'workspace_setup.delete_error': 'Failed to delete workspace', + 'workspace_setup.delete_error_title': 'Delete failed', + 'workspace.switch_error': 'Failed to switch workspace', + 'workspace.switch_error_title': 'Workspace switch failed', + 'workspace_setup.custom_protocol.rest': 'REST', + 'workspace_setup.custom_protocol.graphql': 'GraphQL', + 'workspace_setup.custom_protocol.grpc': 'gRPC', + 'workspace_setup.custom_protocol.websocket': 'WebSocket', + 'workspace_setup.custom_protocol.soap': 'SOAP', + + // Wizard + 'wizard.back_catalog': 'Operations', + 'wizard.progress.create': 'Create operation', + 'wizard.progress.edit': 'Edit operation', + 'wizard.exit': 'Exit wizard', + 'wizard.step_of': 'Step {step} of {total}', + 'wizard.step_short': 'Step {step}', + 'wizard.status.completed': 'Completed', + 'wizard.status.in_progress': 'In progress', + 'wizard.status.not_started': 'Not started', + 'wizard.help.title': 'Need help?', + 'wizard.help.body': 'Unsure which protocol to choose? Read the', + 'wizard.help.link': 'protocol comparison guide →', + 'wizard.progress.section': 'Progress', + 'wizard.step_name.protocol': 'Protocol', + 'wizard.step_name.upstream': 'Upstream target', + 'wizard.step_name.tool': 'Tool config', + 'wizard.step_name.mapping': 'Mapping', + 'wizard.button.back': 'Back', + 'wizard.button.continue': 'Continue', + 'wizard.button.save_draft': 'Save draft', + 'wizard.button.create': 'Create operation', + 'wizard.button.save_changes': 'Save changes', + 'wizard.sidebar.brand.create': 'Create operation', + 'wizard.sidebar.brand.edit': 'Edit operation', + 'wizard.step1.title': 'Choose a protocol', + 'wizard.step1.subtitle': 'Select the transport protocol your upstream service uses. This determines how Crank will communicate with your API and which configuration options are available in subsequent steps.', + 'wizard.step1.rest_name': 'REST / HTTP', + 'wizard.step1.rest_tagline': 'Standard HTTP verbs over a RESTful endpoint. The most broadly supported option.', + 'wizard.step1.graphql_name': 'GraphQL', + 'wizard.step1.graphql_tagline': 'Query and mutate a GraphQL schema. Supports queries and mutations over one fixed operation.', + 'wizard.step1.grpc_name': 'gRPC', + 'wizard.step1.grpc_tagline': 'High-performance binary RPC over HTTP/2 for unary calls to low-latency internal services.', + 'wizard.step1.websocket_name': 'WebSocket', + 'wizard.step1.websocket_tagline': 'Stateful event streams and push-oriented integrations normalized into bounded MCP streaming tools.', + 'wizard.step1.soap_name': 'SOAP', + 'wizard.step1.soap_tagline': 'Enterprise WSDL/XSD service contracts normalized into MCP tools with structured XML mapping.', + 'wizard.step1.query': 'Query', + 'wizard.step1.mutation': 'Mutation', + 'wizard.step1.unary': 'Unary', + 'wizard.step1.tip_title': 'You can change this later', + 'wizard.step1.tip_body': 'Switching protocol after configuring subsequent steps will clear schema and mapping data. Save your operation as a draft before experimenting. See protocol migration guide for details.', + 'wizard.step1.community_protocol_title': 'Community protocol scope', + 'wizard.step1.community_protocol_note': 'This Community build hides {protocols}. Commercial editions unlock those protocol surfaces.', + 'wizard.step5.streaming_title': 'Streaming execution in Community', + 'wizard.step5.community_streaming_note': 'This Community build keeps operations in unary mode. Window, session and async job tool families require a commercial edition.', + 'wizard.step2.title': 'Select upstream & endpoint', + 'wizard.step2.subtitle': 'Choose an existing upstream or register a new one. Then define the specific endpoint path this operation will call.', + 'wizard.required': 'Required', + 'wizard.optional': 'Optional', + 'wizard.step2.upstream_title': 'Upstream', + 'wizard.step2.upstream_subtitle': 'Shared host, base URL, and authentication', + 'wizard.step2.upstream_placeholder': 'Select an upstream…', + 'wizard.step2.search_placeholder': 'Search by name or URL…', + 'wizard.step2.change': 'Change', + 'wizard.step2.auth_none': 'No auth', + 'wizard.step2.auth_bearer': 'Bearer token', + 'wizard.step2.auth_basic': 'Basic auth', + 'wizard.step2.auth_apikey': 'API key', + 'wizard.step2.register_new': 'Register new upstream', + 'wizard.step2.name': 'Name', + 'wizard.step2.base_url': 'Base URL', + 'wizard.step2.unique_hint': 'Unique identifier for this upstream.', + 'wizard.step2.base_url_hint': 'Root URL without a trailing slash. Auth is configured separately below.', + 'wizard.step2.auth_selector': 'Upstream auth', + 'wizard.step2.auth_selector_hint': 'Use secrets-backed auth profiles instead of embedding credential headers in the upstream definition.', + 'wizard.step2.auth_mode.none': 'No auth', + 'wizard.step2.auth_mode.existing': 'Use existing auth profile', + 'wizard.step2.auth_mode.create': 'Create auth profile now', + 'wizard.step2.auth_profile': 'Auth profile', + 'wizard.step2.auth_profile_hint': 'The selected auth profile is applied automatically when the tool runs.', + 'wizard.step2.auth_profile_empty': 'No auth profiles yet. Create one below or on the Secrets page.', + 'wizard.step2.create_profile_title': 'Create auth profile', + 'wizard.step2.create_profile_subtitle': 'Create a reusable profile backed by encrypted workspace secrets.', + 'wizard.step2.profile_name': 'Profile name', + 'wizard.step2.profile_kind': 'Auth kind', + 'wizard.step2.auth_kind.bearer': 'Bearer token', + 'wizard.step2.auth_kind.basic': 'Basic auth', + 'wizard.step2.auth_kind.api_key_header': 'API key header', + 'wizard.step2.auth_kind.api_key_query': 'API key query', + 'wizard.step2.header_name': 'Header name', + 'wizard.step2.query_param': 'Query param', + 'wizard.step2.username_secret': 'Username secret', + 'wizard.step2.password_secret': 'Password secret', + 'wizard.step2.secret_value': 'Secret', + 'wizard.step2.quick_secret': 'Quick create secret', + 'wizard.step2.manage_secrets': 'Open secrets page', + 'wizard.step2.static_headers': 'Static headers (optional)', + 'wizard.step2.static_headers_hint': 'Optional non-secret headers sent on every request to this upstream. Use auth profiles for credentials.', + 'wizard.step2.quick_secret_title': 'Quick create secret', + 'wizard.step2.quick_secret_name': 'Secret name', + 'wizard.step2.quick_secret_kind': 'Secret kind', + 'wizard.step2.quick_secret_value': 'Secret value', + 'wizard.step2.quick_secret_hint': 'The plaintext is encrypted immediately and will not be returned again.', + 'wizard.step2.quick_secret_submit': 'Create secret', + 'wizard.step2.save_upstream': 'Save upstream', + 'wizard.step2.endpoint_title': 'Endpoint', + 'wizard.step2.endpoint_subtitle': 'Path and HTTP method for this specific operation', + 'wizard.step2.path_template': 'Path template', + 'wizard.step2.path_hint': 'Appended to the upstream base URL. Use {param} for path variables.', + 'wizard.step3.rest.label': 'Request config', + 'wizard.step3.graphql.label': 'GQL operation', + 'wizard.step3.rest.title': 'HTTP method & format', + 'wizard.step3.rest.subtitle': 'Choose the HTTP verb this operation sends and configure content negotiation headers. Crank will serialize MCP tool arguments into the appropriate request body format.', + 'wizard.step3.rest.method_title': 'HTTP method', + 'wizard.step3.rest.method_subtitle': 'Verb sent to the upstream on every tool invocation', + 'wizard.step3.rest.read': 'Read', + 'wizard.step3.rest.create': 'Create', + 'wizard.step3.rest.replace': 'Replace', + 'wizard.step3.rest.update': 'Update', + 'wizard.step3.rest.remove': 'Remove', + 'wizard.step3.rest.format_title': 'Request format', + 'wizard.step3.rest.format_subtitle': 'Content negotiation and serialisation', + 'wizard.step3.rest.content_type': 'Content-Type', + 'wizard.step3.rest.content_type_hint': 'How the request body is encoded when sent to the upstream.', + 'wizard.step3.rest.accept': 'Accept', + 'wizard.step3.rest.accept_hint': 'Accepted response content types from the upstream server.', + 'wizard.step3.graphql.title': 'GraphQL operation', + 'wizard.step3.graphql.subtitle': 'Define the GraphQL operation this tool will execute. All requests are sent as HTTP POST to the endpoint configured in the previous step. Variables are populated from MCP tool arguments at runtime.', + 'wizard.step3.graphql.type_title': 'Operation type', + 'wizard.step3.graphql.type_subtitle': 'GraphQL operation kind — subscriptions are not supported', + 'wizard.step3.graphql.query_desc': 'Fetch data without side-effects', + 'wizard.step3.graphql.mutation_desc': 'Create, update or delete data', + 'wizard.step3.graphql.doc_title': 'Query document', + 'wizard.step3.graphql.doc_subtitle': 'GraphQL document with typed variable declarations', + 'wizard.step3.graphql.variables_title': 'Variables are mapped in step 5', + 'wizard.step3.graphql.variables_body': 'Declare all variables in the query document using the $variable: Type syntax. In step 5 you will map MCP input fields to these variables. Crank serializes the final {"query","variables"} payload automatically.', + 'wizard.step4.title': 'Tool config', + 'wizard.step4.subtitle': 'Name your tool, write the LLM-facing description, and define the input/output schemas. The description is the primary signal the model uses when deciding whether to call this tool.', + 'wizard.step4.identity_title': 'Tool identity', + 'wizard.step4.identity_subtitle': 'Machine-readable name and LLM-facing description', + 'wizard.step4.tool_name': 'Tool name', + 'wizard.step4.tool_name_hint': 'Machine-readable identifier used in MCP tool calls. Only lowercase letters, numbers and underscores. Cannot be changed after publishing.', + 'wizard.step4.display_name': 'Display name', + 'wizard.step4.display_name_hint': 'Human-readable name shown in the console and audit log.', + 'wizard.step4.tool_title': 'Tool title', + 'wizard.step4.tool_title_hint': 'Short imperative sentence shown in the MCP tool manifest.', + 'wizard.step4.description': 'Description', + 'wizard.step4.description_hint': 'LLM-facing description. Be precise — this is the primary signal the model uses to decide whether to invoke this tool.', + 'wizard.step4.description_title': 'Writing effective descriptions', + 'wizard.step4.description_body': 'Start with a verb ("Creates", "Fetches", "Updates"). Mention key input requirements. Describe what a successful response looks like. Avoid vague phrasing like "handles" or "manages". See description best practices →', + 'wizard.step4.input_schema_title': 'Input schema', + 'wizard.step4.input_schema_subtitle': 'Parameters the LLM passes when calling this tool', + 'wizard.step4.output_schema_title': 'Output schema', + 'wizard.step4.output_schema_subtitle': 'Shape of the data returned to the LLM after a successful call', + 'wizard.step4.schema_draft': 'JSON Schema draft-07', + 'wizard.step4.protocol_agnostic_title': 'Schemas are protocol-agnostic', + 'wizard.step4.protocol_agnostic_body': 'These schemas describe the MCP contract, not the upstream wire format. Field mapping in Step 5 translates between the two. You can upload a sample JSON response to auto-generate the output schema.', + 'wizard.step5.title': 'Mapping and execution', + 'wizard.step5.subtitle': 'Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 2.', + 'wizard.step5.input_request': 'Input → Request', + 'wizard.step5.output_response': 'Response → Output', + 'wizard.step5.execution': 'Execution', + 'wizard.step5.exec_title': 'Execution config', + 'wizard.step5.exec_subtitle': 'Timeouts, retry policy and auth profile reference', + 'wizard.step5.security_level_title': 'Operation security in Community', + 'wizard.step5.community_security_note': 'Community supports only the standard operation security level with static AI-agent keys. Short-lived and one-time token flows require a commercial edition.', + 'wizard.step5.live_title': 'Live validation and publishing', + 'wizard.step5.draft_actions': 'Draft actions', + 'wizard.step5.live_save_title': 'Live actions save the draft first', + 'wizard.step5.live_save_body': 'Sample uploads, test runs, YAML actions and publish all persist the current draft before calling the backend.', + 'wizard.step5.samples_title': 'Samples and draft generation', + 'wizard.step5.samples_subtitle': 'Persist input/output samples, then generate schemas and mappings from real payloads.', + 'wizard.step5.input_sample': 'Input sample', + 'wizard.step5.output_sample': 'Output sample', + 'wizard.step5.save_input_sample': 'Save input sample', + 'wizard.step5.save_output_sample': 'Save output sample', + 'wizard.step5.generate_draft': 'Generate draft from samples', + 'wizard.step5.test_title': 'Test run', + 'wizard.step5.test_subtitle': 'Run the current draft against the upstream before publishing it.', + 'wizard.step5.test_input_payload': 'Test input payload', + 'wizard.step5.run_test': 'Run test', + 'wizard.step5.use_response_output': 'Use response as output sample', + 'wizard.step5.request_preview': 'Request preview', + 'wizard.step5.response_preview': 'Response preview', + 'wizard.step5.errors': 'Errors', + 'wizard.step5.choose_descriptor': 'Choose descriptor set', + 'wizard.step5.upload_discover': 'Upload and discover services', + 'wizard.step5.no_descriptor': 'No descriptor set selected', + 'wizard.step5.yaml_title': 'YAML import and export', + 'wizard.step5.yaml_subtitle': 'Move operation drafts between environments without leaving the wizard.', + 'wizard.step5.export_yaml': 'Export YAML', + 'wizard.step5.load_yaml_file': 'Load YAML file', + 'wizard.step5.import_yaml': 'Import YAML', + 'wizard.step5.yaml_document': 'YAML document', + 'wizard.step5.yaml_placeholder': 'Paste exported YAML here to create or upsert an operation.', + 'wizard.step5.publish_title': 'Publish', + 'wizard.step5.publish_subtitle': 'Save the current draft and expose it to published agents.', + 'wizard.step5.publish_hint': 'Publishing uses the current draft version from this wizard session.', + 'wizard.step5.publish_action': 'Publish operation', + 'wizard.step5.editable_title': 'Operation stays editable in this wizard', + 'wizard.step5.editable_body': 'Saving creates or updates the current draft in place. Use the live actions above to upload samples, test the draft, export or import YAML, and publish when the upstream contract is ready.', + 'wizard.live.failed_title': 'Live action failed', + 'wizard.live.failed_body': 'The backend request failed. Review the draft and try again.', + 'wizard.error.no_workspace': 'No workspace selected', + 'wizard.error.tool_name': 'Tool name is required', + 'wizard.error.parser_unavailable': 'YAML parser is not available', + 'wizard.error.select_upstream': 'Select or register an upstream first', + 'wizard.error.auth_profile_required': 'Select an auth profile or switch upstream auth to No auth.', + 'wizard.error.auth_profile_name': 'Auth profile name is required.', + 'wizard.error.secret_required': 'Select the secret required by this auth profile.', + 'wizard.error.basic_secrets_required': 'Select both username and password secrets.', + 'wizard.error.header_name_required': 'Header name is required for this auth profile.', + 'wizard.error.query_param_required': 'Query parameter name is required for this auth profile.', + 'wizard.error.secret_name_required': 'Secret name is required.', + 'wizard.error.secret_value_required': 'Secret value is required.', + 'wizard.error.save_upstream_first': 'Save the upstream after creating a new auth profile before continuing.', + 'wizard.toast.auth_profile_created_title': 'Auth profile created', + 'wizard.toast.auth_profile_created_body': 'New upstream auth profile is ready and selected.', + 'wizard.toast.auth_profile_error_title': 'Auth profile creation failed', + 'wizard.toast.quick_secret_created_title': 'Secret created', + 'wizard.toast.quick_secret_created_body': 'The new secret is now available in the auth selector.', + 'wizard.toast.quick_secret_error_title': 'Secret creation failed', + 'wizard.error.save': 'Failed to save operation', + 'wizard.error.save_title': 'Save failed', + 'wizard.save.created': 'Draft created', + 'wizard.save.updated': 'Draft updated', + 'wizard.save.body': 'The current draft is saved on the backend and ready for live actions in this wizard.', + 'wizard.save.saved': 'Saved ✓', + 'wizard.busy.input_sample': 'Saving input sample…', + 'wizard.busy.output_sample': 'Saving output sample…', + 'wizard.busy.generate': 'Generating draft…', + 'wizard.busy.test': 'Running test…', + 'wizard.busy.export_yaml': 'Exporting YAML…', + 'wizard.busy.import_yaml': 'Importing YAML…', + 'wizard.busy.publish': 'Publishing operation…', + 'wizard.busy.descriptor': 'Uploading descriptor set…', + 'wizard.busy.wsdl': 'Inspecting WSDL…', + 'wizard.sample.input_saved': 'Input sample saved', + 'wizard.sample.input_saved_body': 'The input sample is now stored against the current draft version.', + 'wizard.sample.output_saved': 'Output sample saved', + 'wizard.sample.output_saved_body': 'The output sample is now stored against the current draft version.', + 'wizard.sample.generated': 'Draft generated', + 'wizard.sample.generated_body': 'Schemas and mappings were regenerated from the saved samples.', + 'wizard.test.completed': 'Test run completed', + 'wizard.test.failed': 'Test run returned errors', + 'wizard.test.completed_body': 'The current draft executed successfully against the upstream.', + 'wizard.test.failed_body': 'The draft executed, but the backend returned validation or runtime errors.', + 'wizard.test.window_completed': 'Window test completed', + 'wizard.test.window_completed_body': 'Collected a bounded result window.', + 'wizard.test.window_truncated_note': 'Results were truncated.', + 'wizard.test.window_more_note': 'More data is available if you continue from the streaming views.', + 'wizard.test.window_incomplete_note': 'The upstream stream ended before the configured window was fully collected.', + 'wizard.test.websocket_window_completed': 'WebSocket window test completed', + 'wizard.test.websocket_window_completed_body': 'Collected {items} WebSocket event(s) inside the bounded test window.', + 'wizard.test.websocket_window_truncated_note': 'The bounded window stopped after reaching the configured item or byte limit.', + 'wizard.test.websocket_window_more_note': 'The socket produced more events than this test window retained. Increase limits or switch to session mode if you need a longer stream.', + 'wizard.test.websocket_window_incomplete_note': 'The upstream socket closed before the bounded window finished collecting all configured events.', + 'wizard.test.websocket_session_started': 'WebSocket session test started', + 'wizard.test.websocket_session_started_body': 'WebSocket session {session_id} was created. Continue from Stream sessions after a short delay.', + 'wizard.test.websocket_async_job_started': 'WebSocket async job test started', + 'wizard.test.websocket_async_job_started_body': 'WebSocket async job {job_id} was created. Continue from the async jobs view for status and result.', + 'wizard.test.websocket_failed': 'WebSocket test returned errors', + 'wizard.test.websocket_failed_body': 'The socket test failed before Crank could collect a bounded result window.', + 'wizard.test.websocket_failed_reconnect_body': 'The upstream socket closed before enough events were collected, and the configured reconnect policy was exhausted.', + 'wizard.test.websocket_failed_closed_body': 'The upstream socket closed before the bounded test window completed.', + 'wizard.test.session_started': 'Session test started', + 'wizard.test.session_started_body': 'Session {session_id} was created. Check Stream sessions in a few seconds.', + 'wizard.test.async_job_started': 'Async job test started', + 'wizard.test.async_job_started_body': 'Async job {job_id} was created. Track status and result from the async jobs view.', + 'wizard.test.no_response': 'No test response yet', + 'wizard.test.no_response_body': 'Run a test first, then copy the response preview into the output sample editor.', + 'wizard.test.response_copied': 'Response copied', + 'wizard.test.response_copied_body': 'The latest test response was copied into the output sample editor.', + 'wizard.boolean.yes': 'yes', + 'wizard.boolean.no': 'no', + 'wizard.yaml.exported': 'YAML exported', + 'wizard.yaml.exported_body': 'The current draft YAML was downloaded from the backend.', + 'wizard.yaml.none': 'No YAML provided', + 'wizard.yaml.none_body': 'Paste a YAML document or load it from a file before importing.', + 'wizard.yaml.imported': 'YAML imported', + 'wizard.yaml.imported_body': 'The imported operation draft was stored. The wizard will reopen it now.', + 'wizard.yaml.loaded': 'YAML loaded', + 'wizard.yaml.loaded_body': 'The YAML document was loaded into the import editor.', + 'wizard.publish.done': 'Operation published', + 'wizard.publish.done_body': 'Version {version} is now published and can be bound into agents.', + 'wizard.grpc.discovery_title': 'gRPC discovery moved to live descriptor flow', + 'wizard.grpc.discovery_body': 'Server reflection is not wired in this deployment. Save the draft, then use the descriptor-set tools in Step 5 to upload a real descriptor set and discover unary methods from the backend.', + 'wizard.grpc.no_methods': 'No unary RPC methods found.', + 'wizard.grpc.fields_unresolved': 'Fields not resolved.', + 'wizard.grpc.fields_unresolved_body': 'Import or external types are not expanded client-side.', + 'wizard.grpc.services_found': 'Found {services_label} and {methods_label}.', + 'wizard.grpc.services_label.one': '{count} service', + 'wizard.grpc.services_label.other': '{count} services', + 'wizard.grpc.methods_label.one': '{count} unary method', + 'wizard.grpc.methods_label.other': '{count} unary methods', + 'wizard.grpc.services_discovered': '{services} services · {methods} unary methods discovered', + 'wizard.grpc.services_none': 'No unary methods discovered yet', + + // Common buttons + 'btn.save': 'Save changes', + 'btn.cancel': 'Cancel', + 'btn.create': 'Create', + 'btn.delete': 'Delete', + 'btn.revoke': 'Revoke', + 'btn.copy': 'Copy', + + // Agents page + 'agents.title': 'Agents', + 'agents.subtitle': 'Named MCP endpoints that expose a curated subset of operations to an LLM', + 'agents.subtitle.community': 'Dedicated MCP endpoints for AI agents. Community keeps machine access on static agent keys.', + 'agents.new': 'New agent', + 'agents.limit.title': 'Agent limit reached', + 'agents.limit.message': 'This Community build allows up to {limit} AI agent. Upgrade the edition or remove an existing agent to add another one.', + 'agents.stats.total': 'Total agents', + 'agents.stats.total_sub': 'across this workspace', + 'agents.stats.published': 'Published', + 'agents.stats.published_sub': '{value}% of total', + 'agents.stats.tools': 'Operations exposed', + 'agents.stats.tools_sub': 'across {count} agents', + 'agents.stats.calls': 'Calls today', + 'agents.stats.calls_sub': 'all agents combined', + 'agents.search': 'Search agents...', + 'agents.results': '{shown} of {total} agents', + 'agents.callout.default': 'Each agent gets its own MCP endpoint. Connect your LLM client to a specific agent so it only sees the tools it needs — not all {count} operations in this workspace.', + 'agents.callout.community': 'Each agent gets its own MCP endpoint and static agent keys in Community. Connect your LLM client to a specific agent so it only sees the tools it needs — not all {count} operations in this workspace.', + 'agents.loading': 'Loading agents…', + 'agents.error.title': 'Failed to load agents', + 'agents.error.api': 'Workspace or API is unavailable', + 'agents.error.load': 'Failed to load agents', + 'agents.empty.initial.title': 'No agents yet', + 'agents.empty.initial.sub': 'Create your first agent to get a dedicated MCP endpoint with a curated set of tools.', + 'agents.empty.initial.sub_community': 'Create your first agent to get one dedicated MCP endpoint and its static machine-access keys for a focused toolset.', + 'agents.empty.initial.next_step': 'After creation, issue separate machine-access keys for this endpoint on the API Keys page.', + 'agents.empty.search.title': 'No agents match "{query}"', + 'agents.empty.search.sub': 'Try a different search term.', + 'agents.card.tools': 'tools', + 'agents.card.keys': 'keys', + 'agents.card.calls_today': 'calls today', + 'agents.card.created': 'Created {date}', + 'agents.card.copy_endpoint': 'Copy endpoint', + 'agents.card.endpoint_help': 'Use this MCP endpoint in your client and manage its machine-access keys on the API Keys page.', + 'agents.card.endpoint_help_community': 'Use this MCP endpoint in your client. Community keeps machine access on static agent keys managed on the API Keys page.', + 'agents.action.edit': 'Edit', + 'agents.action.archive': 'Archive', + 'agents.action.delete': 'Delete', + 'agents.lifecycle.publish': 'Publish', + 'agents.lifecycle.unpublish': 'Unpublish', + 'agents.lifecycle.restore_draft': 'Restore draft', + 'agents.lifecycle.published': 'Published', + 'agents.lifecycle.archived': 'Archived', + 'agents.lifecycle.draft': 'Draft', + 'agents.drawer.new_title': 'New agent', + 'agents.drawer.edit_title': 'Edit agent', + 'agents.drawer.new_sub': 'Configure a named MCP endpoint for your LLM', + 'agents.drawer.new_sub_community': 'Configure one MCP endpoint and its static agent-key boundary for a focused LLM use case', + 'agents.drawer.edit_sub': 'Update agent settings and tool selection', + 'agents.drawer.identity': 'Identity', + 'agents.drawer.display_name': 'Display name', + 'agents.drawer.slug': 'Slug', + 'agents.drawer.description': 'Description', + 'agents.drawer.optional': '(optional)', + 'agents.drawer.required': 'required', + 'agents.drawer.status': 'Status', + 'agents.drawer.endpoint': 'MCP endpoint', + 'agents.drawer.slug_hint': 'Keep the slug stable after clients start using this endpoint. Changing it changes the MCP path.', + 'agents.drawer.placeholder.name': 'Customer Support', + 'agents.drawer.placeholder.slug': 'customer-support', + 'agents.drawer.placeholder.description': 'What does this agent do? What LLM or use-case is it for?', + 'agents.drawer.operations': 'Operations', + 'agents.drawer.selected': '{count} selected', + 'agents.drawer.operations_sub': 'Select which operations this agent exposes. LLMs connecting to this agent will only see these tools.', + 'agents.drawer.operations_sub_community': 'Select which operations this agent exposes. Any keys issued for this agent only work against this MCP endpoint.', + 'agents.drawer.filter_ops': 'Filter operations…', + 'agents.drawer.ops_no_match': 'No operations match "{query}"', + 'agents.drawer.ops_selected': '{count} operations selected', + 'agents.drawer.clear_all': 'Clear all', + 'agents.drawer.recommendation': "You've selected {count} tools. LLMs usually work best when an agent has fewer than 15 tools. Consider splitting this into separate agents by use case.", + 'agents.drawer.cancel': 'Cancel', + 'agents.drawer.create': 'Create agent', + 'agents.drawer.save': 'Save changes', + 'agents.toast.saved_title_create': 'Agent created', + 'agents.toast.saved_title_update': 'Agent updated', + 'agents.toast.saved_message': '{name} was saved with {count} tool bindings.', + 'agents.toast.save_error_title': 'Agent update failed', + 'agents.toast.save_error_message': 'Failed to save agent', + 'agents.toast.delete_confirm': 'Delete this agent? This cannot be undone.', + 'agents.toast.delete_title': 'Agent deleted', + 'agents.toast.delete_message': '{name} was deleted.', + 'agents.toast.delete_error_title': 'Delete failed', + 'agents.toast.delete_error_message': 'Failed to delete agent', + 'agents.toast.lifecycle_title': 'Agent lifecycle updated', + 'agents.toast.lifecycle_publish': '{name} was published.', + 'agents.toast.lifecycle_unpublish': '{name} was returned to draft.', + 'agents.toast.lifecycle_archive': '{name} was archived.', + 'agents.toast.lifecycle_error_title': 'Lifecycle update failed', + 'agents.toast.lifecycle_error_message': 'Failed to update agent lifecycle', + 'agents.toast.endpoint_title': 'MCP endpoint copied', + + // Demo content + 'demo.agent.revops-copilot.display_name': 'RevOps Copilot', + 'demo.agent.revops-copilot.description': 'Revenue operations assistant with CRM and billing tools.', + 'demo.agent.support-triage.display_name': 'Support Triage', + 'demo.agent.support-triage.description': 'Draft support agent focused on unary gRPC workflows.', + '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.billing_get_invoice.display_name': 'Get Invoice Status', + 'demo.operation.billing_get_invoice.description': 'Fetch invoice state and amount from billing.', + 'demo.operation.support_lookup_ticket.display_name': 'Lookup Support Ticket', + 'demo.operation.support_lookup_ticket.description': 'Draft unary gRPC support lookup using a descriptor set.', + 'demo.operation.marketing_archive_contact.display_name': 'Archive Marketing Contact', + 'demo.operation.marketing_archive_contact.description': 'Legacy archived flow kept for audit only.', + + // Login + 'login.title': 'Sign in', + 'login.subtitle': 'Welcome back to your workspace', + 'login.email': 'Email', + 'login.password': 'Password', + 'login.submit': 'Sign in', + 'login.sso': 'Continue with Google', + 'login.email_label': 'Email address', + 'login.email_placeholder': 'you@acme.com', + 'login.forgot': 'Forgot password?', + 'login.divider': 'or continue with', + 'login.sso_short': 'Google SSO', + 'login.request_prefix': "Don't have an account?", + 'login.request_link': 'Request access', + 'login.planned_badge': 'Planned', + 'login.password_only': 'Sign in with email and password.', + 'login.coming_soon': 'Password reset and SSO will be added later.', + 'login.error.required': 'Please enter your email and password.', + 'login.error.invalid': 'Invalid email or password. Please try again.', + 'login.error.generic': 'Unable to sign in right now. Please try again.', + }, + + ru: { + // Nav + 'nav.operations': 'Операции', + 'nav.agents': 'Агенты', + 'nav.apikeys': 'API Ключи', + 'nav.secrets': 'Секреты', + 'nav.logs': 'Логи', + 'nav.usage': 'Использование', + 'nav.settings': 'Настройки', + 'nav.logout': 'Выйти', + 'nav.workspaces': 'Ваши воркспейсы', + 'nav.create_workspace': 'Создать воркспейс', + 'nav.notifications': 'Уведомления', + 'nav.menu': 'Меню', + 'nav.account': 'Аккаунт', + 'nav.user_fallback': 'Crank', + 'nav.workspace_fallback': 'workspace', + + // Operations page + 'ops.title': 'Операции', + 'ops.subtitle': 'Контракты инструментов, открывающие ваши API как MCP-инструменты', + 'ops.new': 'Новая операция', + 'ops.loading': 'Загрузка операций…', + 'ops.empty.title': 'Операции не найдены', + 'ops.empty.sub': 'Попробуйте изменить поисковый запрос или фильтры.', + 'ops.empty.initial.title': 'Операций пока нет', + 'ops.empty.initial.sub': 'Создайте первую операцию в этом воркспейсе, чтобы открыть живой MCP-инструмент.', + 'ops.error.title': 'Не удалось подключиться к backend', + 'ops.delete.confirm': 'Удалить эту операцию? Действие нельзя отменить.', + 'ops.delete.success.title': 'Операция удалена', + 'ops.delete.success.message': 'Операция {name} удалена.', + 'ops.delete.error.title': 'Не удалось удалить', + 'ops.delete.error.message': 'Не удалось удалить операцию', + 'ops.action.edit': 'Редактировать операцию', + 'ops.action.delete':'Удалить операцию', + 'ops.stats.synced': 'Каталог синхронизирован с backend', + 'ops.stats.none': 'Операций пока нет', + 'ops.stats.share': '{value}% от общего числа', + 'ops.stats.no_published': 'Опубликованных инструментов пока нет', + 'ops.stats.requests_rollup': 'Сумма по статистике операций', + 'ops.stats.no_traffic': 'Трафик сегодня не зафиксирован', + 'ops.stats.latency_weighted': 'Взвешено по сегодняшним вызовам', + 'ops.stats.latency_waiting': 'Ожидание runtime-метрик', + 'ops.results': 'Показано {shown} из {total}', + 'ops.range': 'Показано {from}–{to} из {total} операций', + 'ops.filter.protocol': 'Протокол: {value}', + 'ops.filter.category': 'Категория: {value}', + 'ops.filter.agent': 'Агент: {value}', + 'ops.no_active_agents': 'Нет активных агентов', + + // Stats + 'stats.total': 'Всего операций', + 'stats.active': 'Активные', + 'stats.requests': 'Запросов сегодня', + 'stats.latency': 'Ср. задержка', + + // Tabs + 'tab.all': 'Все', + 'tab.active': 'Активные', + 'tab.draft': 'Черновик', + 'tab.error': 'Ошибка', + 'tab.inactive': 'Неактивные', + + // Filters & sort + 'filter.search': 'Поиск операций...', + 'filter.protocol': 'Протокол', + 'filter.status': 'Статус', + 'filter.category': 'Категория', + 'filter.agent': 'Агент', + 'filter.showing': 'Показано', + 'filter.of': 'из', + 'filter.clear': 'Сбросить', + 'filter.clear_all': 'Сбросить все', + 'filter.remove': 'Убрать фильтр', + 'sort.created_desc':'Сначала новые', + 'sort.created_asc': 'Сначала старые', + 'sort.name_asc': 'Название А–Я', + 'sort.name_desc': 'Название Я–А', + + // Table headers + 'th.name': 'НАЗВАНИЕ', + 'th.protocol': 'ПРОТОКОЛ', + 'th.status': 'СТАТУС', + 'th.created': 'СОЗДАН', + 'th.url': 'ЦЕЛЕВОЙ URL', + + // Pagination + 'page.showing': 'Показано', + 'page.of': 'из', + 'page.ops': 'операций', + + // API Keys page + 'apikeys.title': 'Ключи агентов', + 'apikeys.subtitle': 'Ключи аутентифицируют внешние MCP-клиенты для конкретного endpoint AI-агента.', + 'apikeys.new': 'Создать ключ', + 'apikeys.agent.title': 'Доступ агента', + 'apikeys.agent.subtitle': 'Выберите AI-агента, чей MCP endpoint должен принимать этот ключ.', + 'apikeys.agent.label': 'AI-агент', + 'apikeys.agent.empty': 'Агенты отсутствуют', + 'apikeys.agent.empty_hint': 'Сначала создайте AI-агента, затем выпустите машинный ключ для его MCP endpoint.', + 'apikeys.agent.endpoint': 'MCP endpoint: {endpoint}', + 'apikeys.agent.endpoint_missing': 'У выбранного агента пока нет опубликованного MCP endpoint.', + 'apikeys.machine_access.title': 'Режимы машинного доступа', + 'apikeys.machine_access.title_community': 'Машинный доступ Community', + 'apikeys.machine_access.subtitle': 'Эта сборка показывает стабильный контракт машинного доступа и сразу отражает доступные режимы.', + 'apikeys.machine_access.subtitle_community': 'Community публикует стабильный MCP-контракт машинного доступа, но в этой сборке активны только статические ключи AI-агентов.', + 'apikeys.machine_access.summary_default': 'Community сейчас поддерживает только статические ключи AI-агентов.', + 'apikeys.machine_access.summary': 'Эта сборка поддерживает {access_modes} для операций с уровнями защиты {security_levels}. При включении коммерческой редакции short-lived и one-time token issuance используют тот же public HTTP surface.', + 'apikeys.machine_access.note': 'Выдача short-lived и one-time token использует тот же public HTTP surface, но требует коммерческую редакцию.', + 'apikeys.machine_access.note_community': 'В Community используйте статические ключи AI-агентов. Short-lived и one-time token требуют коммерческую редакцию.', + 'apikeys.th.name': 'НАЗВАНИЕ', + 'apikeys.th.scope': 'ОБЛАСТЬ', + 'apikeys.th.created':'СОЗДАН', + 'apikeys.th.last': 'ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ', + 'apikeys.callout.title': 'Ключ показывается только один раз.', + 'apikeys.callout.body': 'Скопируйте и сохраните ключ сразу после создания — Crank хранит только хэш. Поле «Последнее использование» обновляется после успешного MCP-вызова.', + 'apikeys.active.title': 'Активные ключи', + 'apikeys.active.subtitle': '{active} активных · {revoked} отозванных', + 'apikeys.search': 'Фильтр ключей…', + 'apikeys.th.prefix': 'ПРЕФИКС КЛЮЧА', + 'apikeys.th.scopes': 'ОБЛАСТИ', + 'apikeys.th.status': 'СТАТУС', + 'apikeys.scope_ref': 'Справка по областям', + 'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.', + 'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.', + 'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.', + 'apikeys.modal.title': 'Создать ключ агента', + 'apikeys.modal.name': 'Имя ключа', + 'apikeys.modal.name_hint': 'Понятная метка для идентификации ключа. Видна только администраторам.', + 'apikeys.modal.name_placeholder': 'например, Production, CI pipeline', + 'apikeys.modal.scopes': 'Области', + 'apikeys.modal.reveal_title': 'Скопируйте ключ сейчас.', + 'apikeys.modal.reveal_body': 'Повторно он не будет показан.', + 'apikeys.modal.copy': 'Скопировать', + 'apikeys.modal.cancel': 'Отмена', + 'apikeys.modal.create': 'Создать ключ', + 'apikeys.modal.done': 'Готово — ключ скопирован', + 'apikeys.status.active': 'Активен', + 'apikeys.status.revoked': 'Отозван', + 'apikeys.last_used.never': 'Никогда', + 'apikeys.empty.none': 'API-ключей пока нет', + 'apikeys.empty.search': 'Нет ключей по текущему поиску', + 'apikeys.loading': 'Загрузка…', + 'apikeys.error.api': 'Воркспейс или API недоступен', + 'apikeys.error.load': 'Не удалось загрузить API-ключи', + 'apikeys.confirm.revoke': 'Отозвать этот ключ? Все запросы с ним сразу начнут падать.', + 'apikeys.confirm.delete': 'Удалить этот ключ? Действие нельзя отменить.', + 'apikeys.toast.revoke_title': 'Ключ отозван', + 'apikeys.toast.revoke_message': 'Ключ {name} отозван.', + 'apikeys.toast.revoke_error_title': 'Не удалось отозвать', + 'apikeys.toast.revoke_error_message': 'Не удалось отозвать ключ', + 'apikeys.toast.delete_title': 'Ключ удален', + 'apikeys.toast.delete_message': 'Ключ {name} удален.', + 'apikeys.toast.delete_error_title': 'Не удалось удалить', + 'apikeys.toast.delete_error_message': 'Не удалось удалить ключ', + 'apikeys.toast.scope_missing_title': 'Не выбрана область', + 'apikeys.toast.scope_missing_message': 'Выберите хотя бы одну область перед созданием ключа.', + 'apikeys.toast.create_title': 'Ключ агента создан', + 'apikeys.toast.create_message': 'Скопируйте ключ сейчас. Повторно он не будет показан.', + 'apikeys.toast.create_error_title': 'Не удалось создать ключ', + 'apikeys.toast.create_error_message': 'Не удалось создать ключ', + 'apikeys.toast.copy_title': 'Ключ агента скопирован', + 'apikeys.toast.copy_message': 'Сохраните исходный ключ в надежном месте. Повторно показать его нельзя.', + 'apikeys.toast.prefix_title': 'Префикс ключа скопирован', + 'apikeys.action.copy_prefix': 'Скопировать префикс ключа', + 'apikeys.action.revoke': 'Отозвать ключ', + 'apikeys.action.delete': 'Удалить', + 'apikeys.creating': 'Создание…', + + // Secrets page + 'secrets.title': 'Секреты', + 'secrets.subtitle': 'Зашифрованные upstream-учетные данные для bearer token, basic auth и API key, которые используют auth profiles.', + 'secrets.new': 'Создать секрет', + 'secrets.callout.title': 'Plaintext секрета доступен только на запись.', + 'secrets.callout.body': 'Crank шифрует значения секретов мастер-ключом воркспейса. После создания или ротации API возвращает только метаданные, поэтому на этой странице доступны управление секретами и просмотр их использования.', + 'secrets.active.title': 'Секреты воркспейса', + 'secrets.active.subtitle': '{active} активных · {total} всего', + 'secrets.search': 'Фильтр секретов…', + 'secrets.th.name': 'НАЗВАНИЕ', + 'secrets.th.kind': 'ТИП', + 'secrets.th.version': 'ВЕРСИЯ', + 'secrets.th.last_used': 'ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ', + 'secrets.th.used_by': 'ИСПОЛЬЗУЕТСЯ В', + 'secrets.th.status': 'СТАТУС', + 'secrets.kind.token': 'Токен', + 'secrets.kind.username_password': 'Логин/пароль', + 'secrets.kind.header': 'Значение заголовка', + 'secrets.kind.generic': 'Общий JSON', + 'secrets.status.active': 'Активен', + 'secrets.status.disabled': 'Отключен', + 'secrets.last_used.never': 'Никогда', + 'secrets.used_by_count.one': '{count} профиль', + 'secrets.used_by_count.few': '{count} профиля', + 'secrets.used_by_count.many': '{count} профилей', + 'secrets.used_by_count.other': '{count} профиля', + 'secrets.loading': 'Загрузка…', + 'secrets.empty.none': 'Секретов пока нет', + 'secrets.empty.search': 'Нет секретов по текущему поиску', + 'secrets.error.workspace': 'Воркспейс не выбран', + 'secrets.error.load': 'Не удалось загрузить секреты', + 'secrets.action.rotate': 'Ротировать', + 'secrets.action.delete': 'Удалить', + 'secrets.confirm.delete': 'Удалить секрет {name}? Это нельзя сделать, пока на секрет ссылается auth profile.', + 'secrets.toast.create_title': 'Секрет создан', + 'secrets.toast.create_message': 'Секрет сохранен в зашифрованном виде. Повторно посмотреть plaintext через API нельзя.', + 'secrets.toast.create_error_title': 'Не удалось создать секрет', + 'secrets.toast.create_error_message': 'Не удалось создать секрет', + 'secrets.toast.rotate_title': 'Секрет ротирован', + 'secrets.toast.rotate_message': 'Новая версия секрета теперь активна.', + 'secrets.toast.rotate_error_title': 'Не удалось ротировать секрет', + 'secrets.toast.rotate_error_message': 'Не удалось ротировать секрет', + 'secrets.toast.delete_title': 'Секрет удален', + 'secrets.toast.delete_message': 'Секрет {name} удален.', + 'secrets.toast.delete_error_title': 'Не удалось удалить секрет', + 'secrets.toast.delete_error_message': 'Не удалось удалить секрет', + 'secrets.modal.create_title': 'Создать секрет', + 'secrets.modal.rotate_title': 'Ротировать секрет', + 'secrets.modal.name': 'Имя секрета', + 'secrets.modal.name_hint': 'Используйте стабильную операторскую метку. Plaintext после сохранения не возвращается.', + 'secrets.modal.kind': 'Тип секрета', + 'secrets.modal.value': 'Значение секрета', + 'secrets.modal.header_value': 'Значение заголовка', + 'secrets.modal.username': 'Логин', + 'secrets.modal.password': 'Пароль', + 'secrets.modal.json_payload': 'JSON payload', + 'secrets.modal.json_hint': 'Подходит для общих secret payloads. Значение должно быть валидным JSON.', + 'secrets.modal.create_note': 'Создание сохраняет зашифрованный plaintext и возвращает только metadata.', + 'secrets.modal.rotate_note': 'Ротация создает новую зашифрованную версию и сохраняет тот же secret id.', + 'secrets.modal.cancel': 'Отмена', + 'secrets.modal.create_action': 'Создать секрет', + 'secrets.modal.rotate_action': 'Ротировать секрет', + 'secrets.modal.creating': 'Создание…', + 'secrets.modal.rotating': 'Ротация…', + 'secrets.hint.token': 'Лучший вариант для bearer tokens и простых API key values.', + 'secrets.hint.username_password': 'Хранит логин и пароль как один зашифрованный payload.', + 'secrets.hint.header': 'Хранит сырое значение заголовка. Имя заголовка задается в auth profile.', + 'secrets.hint.generic': 'Хранит произвольный JSON для нестандартных интеграций, которым нужен структурированный секрет.', + 'secrets.validation.name_required': 'Нужно указать имя секрета.', + 'secrets.validation.value_required': 'Нужно указать значение секрета.', + 'secrets.validation.username_password_required': 'Нужно указать и логин, и пароль.', + 'secrets.validation.json_invalid': 'Generic secret payload должен быть валидным JSON.', + 'secrets.profiles.title': 'Ссылки auth profiles', + 'secrets.profiles.subtitle': 'Профили резолвят секреты в runtime перед upstream execution.', + 'secrets.profiles.subtitle_count.one': '{count} профиль авторизации', + 'secrets.profiles.subtitle_count.few': '{count} профиля авторизации', + 'secrets.profiles.subtitle_count.many': '{count} профилей авторизации', + 'secrets.profiles.subtitle_count.other': '{count} профиля авторизации', + 'secrets.profiles.error_title': 'Не удалось загрузить ссылки auth profiles', + 'secrets.profiles.loading_title': 'Загрузка ссылок auth profiles…', + 'secrets.profiles.loading_body': 'Получаем auth profiles для текущего воркспейса.', + 'secrets.profiles.empty_title': 'Auth profiles пока нет', + 'secrets.profiles.empty_body': 'Создайте auth profile в мастере при настройке операции.', + 'secrets.profiles.references': 'Связанные секреты', + 'secrets.profiles.reference_count': '{count} ссылок', + 'secrets.profiles.meta.created': 'Создан', + 'secrets.profiles.meta.updated': 'Обновлен', + 'secrets.profiles.summary_bearer': 'Bearer auth через {header} с использованием {secret}.', + 'secrets.profiles.summary_basic': 'Basic auth с использованием {username} и {password}.', + 'secrets.profiles.summary_api_key_header': 'API key header {header} с использованием {secret}.', + 'secrets.profiles.summary_api_key_query': 'API key query param {param} с использованием {secret}.', + 'secrets.profiles.summary_unknown': 'Неизвестная конфигурация auth profile.', + 'secrets.auth_kind.bearer': 'Bearer auth', + 'secrets.auth_kind.basic': 'Basic auth', + 'secrets.auth_kind.api_key_header': 'API key header', + 'secrets.auth_kind.api_key_query': 'API key query', + + // Logs page + 'logs.title': 'Логи', + 'logs.subtitle': 'Живой журнал вызовов по всем операциям этого воркспейса.', + 'logs.search': 'Поиск в логах...', + 'logs.level.all': 'Все', + 'logs.level.info': 'Info', + 'logs.level.warn': 'Warn', + 'logs.level.error': 'Error', + 'logs.level.debug': 'Debug', + 'logs.live': 'Live', + 'logs.paused': 'Пауза', + 'logs.refresh': 'Обновить', + 'logs.range.30m': 'Последние 30 мин', + 'logs.range.1h': 'Последний час', + 'logs.range.6h': 'Последние 6 часов', + 'logs.range.24h': 'Последние 24 часа', + 'logs.range.7d': 'Последние 7 дней', + 'logs.search_messages': 'Поиск по сообщениям…', + 'logs.detail.agent': 'Агент', + 'logs.detail.request': 'Предпросмотр запроса', + 'logs.detail.response': 'Предпросмотр ответа', + 'logs.detail.meta': 'Метаданные', + 'logs.loading.title': 'Загрузка логов…', + 'logs.loading.sub': 'Получаем историю вызовов для текущего воркспейса.', + 'logs.error.title': 'Не удалось загрузить логи', + 'logs.error.api': 'Admin API недоступен', + 'logs.error.workspace': 'Воркспейс не выбран', + 'logs.error.load': 'Не удалось загрузить логи', + 'logs.empty.title': 'Записей логов не найдено', + 'logs.empty.filtered': 'Попробуйте расширить период или сбросить текущий поиск и фильтры уровня.', + 'logs.empty.initial': 'Запустите тесты или опубликованные MCP-инструменты, чтобы начать собирать историю вызовов.', + 'logs.live.on.title': 'Live режим включен', + 'logs.live.on.body': 'Логи опрашиваются каждые 4 секунды.', + 'logs.live.off.title': 'Live режим на паузе', + 'logs.live.off.body': 'Автоматический опрос остановлен.', + 'logs.refresh.title': 'Логи обновлены', + 'logs.refresh.body': 'Получены последние записи вызовов для текущего воркспейса.', + + // Usage page + 'usage.title': 'Использование', + 'usage.subtitle': 'Метрики вызовов по всем операциям этого воркспейса.', + 'usage.export': 'Экспорт CSV', + 'usage.period.7d': 'Последние 7 дней', + 'usage.period.30d': 'Последние 30 дней', + 'usage.period.90d': 'Последние 90 дней', + 'usage.period.this_month': 'Этот месяц', + 'usage.period.label.7d': 'последние 7 дней', + 'usage.period.label.30d': 'последние 30 дней', + 'usage.period.label.90d': 'последние 90 дней', + 'usage.period.label.this_month': 'этот месяц', + 'usage.stats.total': 'Всего вызовов', + 'usage.stats.success': 'Доля успешных', + 'usage.stats.p50': 'Медианная задержка (p50)', + 'usage.stats.p99': 'Задержка p99', + 'usage.stats.across': 'За период: {period}', + 'usage.stats.success_calls': 'Успешных вызовов: {count}', + 'usage.stats.median': 'Медианная задержка за выбранный период', + 'usage.stats.error_calls': 'Ошибочных вызовов: {count}', + 'usage.chart.title': 'Вызовы во времени', + 'usage.chart.success': 'Успех', + 'usage.chart.error': 'Ошибка', + 'usage.chart.empty.title': 'Данных по использованию пока нет', + 'usage.chart.empty.sub': 'Метрики появятся здесь после тестов или вызовов опубликованных инструментов за выбранный период.', + 'usage.table.title': 'По операциям', + 'usage.table.subtitle': 'Разбивка за период: {period}', + 'usage.table.th.operation': 'Операция', + 'usage.table.th.protocol': 'Протокол', + 'usage.table.th.calls': 'Вызовы', + 'usage.table.th.errors': 'Ошибки', + 'usage.table.th.error_rate': 'Доля ошибок', + 'usage.table.th.latency': 'Задержка (p50 / p95 / p99)', + 'usage.table.th.share': 'Доля трафика', + 'usage.table.empty.title': 'Разбивки по операциям пока нет', + 'usage.table.empty.sub': 'За выбранный период нет вызовов, которые можно разложить по операциям.', + 'usage.table.share': '{value}% трафика воркспейса', + 'usage.loading.title': 'Загрузка использования…', + 'usage.loading.sub': 'Собираем метрики вызовов для выбранного воркспейса.', + 'usage.error.title': 'Не удалось загрузить использование', + 'usage.error.api': 'Admin API недоступен', + 'usage.error.workspace': 'Воркспейс не выбран', + 'usage.error.load': 'Не удалось загрузить метрики использования', + 'usage.export.empty.title': 'Данные по использованию не загружены', + 'usage.export.empty.body': 'Сначала загрузите данные использования, а потом экспортируйте снимок CSV.', + 'usage.export.done.title': 'Использование экспортировано', + 'usage.export.done.body': 'Текущий снимок использования экспортирован в CSV.', + 'usage.chart.ok': 'Успешных: {count}', + 'usage.chart.errors': 'Ошибок: {count}', + 'usage.chart.week': 'Нед. {index}', + 'usage.csv.header': 'Операция,Протокол,Вызовы,Ошибки,Доля ошибок (%),p50 (мс),p95 (мс),p99 (мс)', + + // Settings sidebar + 'settings.title': 'Настройки', + 'settings.nav.workspace': 'Воркспейс', + 'settings.nav.profile': 'Профиль', + 'settings.nav.members': 'Участники', + 'settings.nav.security': 'Безопасность', + 'settings.nav.notif': 'Уведомления', + 'settings.nav.danger': 'Опасная зона', + + // Settings — workspace section + 'settings.ws.title': 'Настройки воркспейса', + 'settings.ws.subtitle': 'Общая информация о вашем воркспейсе', + 'settings.ws.name': 'Имя воркспейса', + 'settings.ws.display': 'Отображаемое имя', + 'settings.ws.desc': 'Описание', + 'settings.ws.tz': 'Часовой пояс', + 'settings.ws.protocol': 'Протокол по умолчанию', + + // Settings — language + 'settings.lang.title': 'Язык', + 'settings.lang.subtitle': 'Язык отображения интерфейса', + 'settings.lang.en': 'English', + 'settings.lang.ru': 'Русский', + + // Settings — profile section + 'settings.profile.title': 'Профиль', + 'settings.profile.name': 'Полное имя', + 'settings.profile.email': 'Email', + 'settings.profile.role': 'Роль', + 'settings.page.title': 'Настройки аккаунта', + 'settings.page.subtitle': 'Управляйте профилем, паролем, настройками воркспейса и границами возможностей текущей сборки.', + 'settings.ws.slug_hint': 'Используется в API endpoint-ах и по всей консоли. Только строчные буквы, цифры и дефисы.', + 'settings.ws.protocol_hint': 'Эта сборка ограничивает протокол по умолчанию возможностями текущей редакции.', + 'settings.ws.protocol_hint_capabilities': 'В этой сборке в качестве протокола воркспейса доступны {protocols}. Дополнительные протоколы зависят от редакции.', + 'settings.ws.description_optional': 'Описание (необязательно)', + 'settings.ws.description_placeholder': 'Чем занимается этот воркспейс?', + 'settings.profile.avatar_hint': 'Аватар строится из отображаемого имени и email.', + 'settings.profile.backend_hint': 'Имя и email хранятся в backend и используются по всей консоли и в сессионной идентичности.', + 'settings.profile.first_name': 'Имя', + 'settings.profile.last_name': 'Фамилия', + 'settings.profile.last_name_placeholder': 'Необязательно', + 'settings.profile.email_label': 'Email адрес', + 'settings.profile.email_hint': 'Изменение email обновляет логин для текущего аккаунта.', + 'settings.profile.save': 'Сохранить профиль', + 'settings.profile.saving': 'Сохранение…', + 'settings.profile.saved': 'Сохранено ✓', + 'settings.profile.saved_status': 'Профиль сохранен.', + 'settings.profile.save_error': 'Не удалось сохранить профиль', + 'settings.security.title': 'Безопасность', + 'settings.security.current_password': 'Текущий пароль', + 'settings.security.new_password': 'Новый пароль', + 'settings.security.new_password_placeholder': 'минимум 12 символов', + 'settings.security.confirm_password': 'Подтвердите новый пароль', + 'settings.security.change_password': 'Сменить пароль', + 'settings.security.mismatch': 'Новый пароль и подтверждение не совпадают.', + 'settings.security.saved': 'Пароль обновлен.', + 'settings.security.save_error': 'Не удалось изменить пароль', + 'settings.security.capabilities_title': 'Сводка по возможностям сборки', + 'settings.security.capabilities_body': 'Этот блок показывает, что поддерживает текущая сборка. Сейчас доступен вход по паролю с одной HttpOnly browser session; короткоживущие токены, одноразовые токены и расширенное управление доступом зависят от редакции.', + 'settings.security.not_available': 'Возможности сборки', + 'settings.security.capability_title_community': 'Редакция Community', + 'settings.security.capability_title_enterprise': 'Редакция Enterprise', + 'settings.security.capability_title_cloud': 'Редакция Cloud', + 'settings.security.capability_summary': 'В этой сборке доступны {protocols}, {access_modes} и уровень защиты операций {security_levels}. Лимиты: {max_workspaces} воркспейс, {max_users} пользователь, {max_agents} AI-агент. Короткоживущие токены, одноразовые токены и расширенное управление доступом требуют коммерческую редакцию.', + 'settings.capability.rest': 'REST / HTTP', + 'settings.capability.graphql': 'GraphQL', + 'settings.capability.grpc': 'gRPC unary', + 'settings.capability.websocket': 'WebSocket', + 'settings.capability.soap': 'SOAP', + 'settings.capability.standard': 'standard', + 'settings.capability.elevated': 'elevated', + 'settings.capability.strict': 'strict', + 'settings.capability.static_agent_key': 'статические ключи агентов', + 'settings.capability.short_lived_token': 'короткоживущие токены', + 'settings.capability.one_time_token': 'одноразовые токены', + 'settings.capability.unlimited': 'без лимита', + 'settings.capability.none': 'нет', + 'settings.session.title': 'Текущая сессия', + 'settings.session.browser': 'Сессия браузера', + 'settings.session.current': 'текущая', + 'settings.session.loading': 'Загрузка деталей текущей сессии…', + 'settings.session.unavailable': 'Детали сессии недоступны. Используйте «Выйти», чтобы отозвать текущую browser session.', + 'settings.session.current_workspace': 'текущий воркспейс', + 'settings.session.summary': '{email} · {role} в {workspace}. Используйте «Выйти», чтобы отозвать текущую browser session.', + 'settings.notifications.title': 'Уведомления', + 'settings.notifications.not_ready_title': 'Не входит в эту сборку.', + 'settings.notifications.not_ready_body': 'Маршрутизация уведомлений и персональные настройки находятся вне текущей поверхности Community.', + 'settings.notifications.capability_title_community': 'Граница редакции Community', + 'settings.notifications.capability_body_community': 'Персональные каналы доставки и маршрутизация уведомлений не входят в текущую поверхность Community. В этой сборке для операционного контроля используйте журналы и представления использования.', + 'settings.notifications.capability_title_enterprise': 'Контур доставки Enterprise', + 'settings.notifications.capability_body_enterprise': 'Маршрутизация уведомлений зависит от корпоративного контура доставки и подключается отдельно от поверхности консоли Community.', + 'settings.notifications.capability_title_cloud': 'Контур доставки Cloud', + 'settings.notifications.capability_body_cloud': 'Маршрутизация уведомлений зависит от облачного контура доставки и подключается отдельно от поверхности консоли Community.', + 'settings.planned_badge': 'Запланировано', + 'settings.notifications.spike_title': 'Всплеск ошибок операции', + 'settings.notifications.spike_body': 'Алерт, когда доля ошибок превышает скользящий порог для опубликованного инструмента.', + 'settings.notifications.latency_title': 'Деградация задержки upstream', + 'settings.notifications.latency_body': 'Уведомлять операторов, когда p99 latency резко растет выше базовой линии.', + 'settings.notifications.digest_title': 'Сводки по квотам и использованию', + 'settings.notifications.digest_body': 'Доставлять периодические сводки по расходу квот, ошибкам и активности воркспейса.', + 'settings.workspace.saving': 'Сохранение…', + 'settings.workspace.saved': 'Сохранено ✓', + 'settings.workspace.save_error': 'Не удалось сохранить настройки воркспейса', + 'settings.workspace.save_error_title': 'Не удалось сохранить воркспейс', + + // Workspace setup + 'workspace_setup.title': 'Настройки воркспейса', + 'workspace_setup.page_subtitle': 'Настройте идентичность воркспейса, значения по умолчанию и состав команды.', + 'workspace_setup.back': 'Назад', + 'workspace_setup.identity.title': 'Идентичность воркспейса', + 'workspace_setup.identity.avatar_hint': 'Аватар формируется из имени воркспейса.', + 'workspace_setup.name': 'Имя воркспейса', + 'workspace_setup.slug': 'Slug', + 'workspace_setup.required': 'обязательно', + 'workspace_setup.optional': '(необязательно)', + 'workspace_setup.slug_hint': 'Только строчные буквы, цифры и дефисы. Используется в URL MCP endpoint-ов.', + 'workspace_setup.description': 'Описание', + 'workspace_setup.description_placeholder': 'Чем занимается этот воркспейс?', + 'workspace_setup.defaults.title': 'Настройки по умолчанию', + 'workspace_setup.protocol': 'Протокол по умолчанию', + 'workspace_setup.protocol_hint': 'Предварительно выбирается в мастере операций.', + 'workspace_setup.timezone': 'Часовой пояс', + 'workspace_setup.language': 'Язык интерфейса', + 'workspace_setup.members.title': 'Участники команды', + 'workspace_setup.members.invite': 'Пригласить участника', + 'workspace_setup.members.email': 'Email адрес', + 'workspace_setup.members.role': 'Роль', + 'workspace_setup.members.send_invite': 'Отправить приглашение', + 'workspace_setup.members.cancel': 'Отмена', + 'workspace_setup.members.invite_sent': '✓ Приглашение отправлено.', + 'workspace_setup.members.you': '(вы)', + 'workspace_setup.members.today': 'Сегодня', + 'workspace_setup.members.days_ago.one': '{count} день назад', + 'workspace_setup.members.days_ago.few': '{count} дня назад', + 'workspace_setup.members.days_ago.many': '{count} дней назад', + 'workspace_setup.members.days_ago.other': '{count} дня назад', + 'workspace_setup.members.week_ago': '1 неделю назад', + 'workspace_setup.members.never': 'Никогда', + 'workspace_setup.members.pending': 'Ожидающие приглашения', + 'workspace_setup.members.invited_pending': 'Приглашен {date} · ожидает принятия', + 'workspace_setup.members.revoke': 'Отозвать приглашение', + 'workspace_setup.members.remove': 'Удалить участника', + 'workspace_setup.roles.title': 'Роли и права', + 'workspace_setup.roles.owner_body': 'Полный контроль над воркспейсом, доступ к биллингу, возможность передать или удалить воркспейс.', + 'workspace_setup.roles.admin_body': 'Управление участниками и ролями, создание и редактирование всех операций и агентов, управление API-ключами.', + 'workspace_setup.roles.developer_body': 'Создание и редактирование операций и агентов. Нельзя управлять участниками или настройками воркспейса.', + 'workspace_setup.roles.viewer_body': 'Доступ только на чтение к операциям, агентам и логам.', + 'workspace_setup.invite.title': 'Пригласить участников команды', + 'workspace_setup.invite.add_person': '+ Добавить человека', + 'workspace_setup.invite.later': 'Позже можно пригласить людей из настроек воркспейса.', + 'workspace_setup.actions.create': 'Создать воркспейс', + 'workspace_setup.actions.save': 'Сохранить изменения', + 'workspace_setup.create.title': 'Создать новый воркспейс', + 'workspace_setup.create.subtitle': 'Воркспейс — это общее окружение для MCP-операций и агентов вашей команды. У каждого воркспейса свой namespace MCP endpoint-ов.', + 'workspace_setup.create.footer': 'Вы станете владельцем этого воркспейса. После создания можно пригласить дополнительных участников.', + 'workspace_setup.danger.title': 'Опасная зона', + 'workspace_setup.danger.export_title': 'Экспортировать все данные', + 'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, участников, приглашений, операций, агентов и ключей доступа агентов.', + 'workspace_setup.danger.delete_title': 'Удалить воркспейс', + 'workspace_setup.danger.delete_body': 'Безвозвратно удаляет все операции, ключи, логи и настройки. Действие нельзя отменить. Вы будете немедленно разлогинены.', + 'workspace_setup.export': 'Экспорт', + 'workspace_setup.delete': 'Удалить воркспейс', + 'workspace_setup.role.owner': 'Владелец', + 'workspace_setup.role.admin': 'Администратор', + 'workspace_setup.role.operator': 'Разработчик', + 'workspace_setup.role.viewer': 'Наблюдатель', + 'workspace_setup.member_count.one': '{count} участник', + 'workspace_setup.member_count.few': '{count} участника', + 'workspace_setup.member_count.many': '{count} участников', + 'workspace_setup.member_count.other': '{count} участника', + 'workspace_setup.saving_create': 'Создание…', + 'workspace_setup.saving_update': 'Сохранение…', + 'workspace_setup.saved': 'Сохранено ✓', + 'workspace_setup.save_error': 'Не удалось сохранить воркспейс', + 'workspace_setup.save_error_title': 'Не удалось сохранить', + 'workspace_setup.invite_error': 'Не удалось отправить приглашение', + 'workspace_setup.invite_error_title': 'Не удалось пригласить', + 'workspace_setup.role_error': 'Не удалось обновить роль', + 'workspace_setup.role_error_title': 'Не удалось обновить роль', + 'workspace_setup.remove_confirm': 'Удалить {name} из этого воркспейса?', + 'workspace_setup.remove_error': 'Не удалось удалить участника', + 'workspace_setup.remove_error_title': 'Не удалось удалить участника', + 'workspace_setup.revoke_error': 'Не удалось отозвать приглашение', + 'workspace_setup.revoke_error_title': 'Не удалось отозвать приглашение', + 'workspace_setup.export_error': 'Не удалось экспортировать воркспейс', + 'workspace_setup.export_error_title': 'Не удалось экспортировать', + 'workspace_setup.delete_confirm': 'Удалить этот воркспейс? Действие нельзя отменить.', + 'workspace_setup.delete_error': 'Не удалось удалить воркспейс', + 'workspace_setup.delete_error_title': 'Не удалось удалить', + 'workspace.switch_error': 'Не удалось переключить воркспейс', + 'workspace.switch_error_title': 'Не удалось переключить воркспейс', + 'workspace_setup.custom_protocol.rest': 'REST', + 'workspace_setup.custom_protocol.graphql': 'GraphQL', + 'workspace_setup.custom_protocol.grpc': 'gRPC', + 'workspace_setup.custom_protocol.websocket': 'WebSocket', + 'workspace_setup.custom_protocol.soap': 'SOAP', + + // Wizard + 'wizard.back_catalog': 'Операции', + 'wizard.progress.create': 'Создать операцию', + 'wizard.progress.edit': 'Редактировать операцию', + 'wizard.exit': 'Выйти из мастера', + 'wizard.step_of': 'Шаг {step} из {total}', + 'wizard.step_short': 'Шаг {step}', + 'wizard.status.completed': 'Завершено', + 'wizard.status.in_progress': 'В процессе', + 'wizard.status.not_started': 'Не начато', + 'wizard.help.title': 'Нужна помощь?', + 'wizard.help.body': 'Не уверены, какой протокол выбрать? Откройте', + 'wizard.help.link': 'гайд по сравнению протоколов →', + 'wizard.progress.section': 'Прогресс', + 'wizard.step_name.protocol': 'Протокол', + 'wizard.step_name.upstream': 'Upstream', + 'wizard.step_name.tool': 'Конфиг инструмента', + 'wizard.step_name.mapping': 'Маппинг', + 'wizard.button.back': 'Назад', + 'wizard.button.continue': 'Продолжить', + 'wizard.button.save_draft': 'Сохранить черновик', + 'wizard.button.create': 'Создать операцию', + 'wizard.button.save_changes': 'Сохранить изменения', + 'wizard.sidebar.brand.create': 'Создать операцию', + 'wizard.sidebar.brand.edit': 'Редактировать операцию', + 'wizard.step1.title': 'Выберите протокол', + 'wizard.step1.subtitle': 'Выберите транспортный протокол upstream-сервиса. От этого зависит, как Crank будет общаться с вашим API и какие настройки будут доступны на следующих шагах.', + 'wizard.step1.rest_name': 'REST / HTTP', + 'wizard.step1.rest_tagline': 'Стандартные HTTP-методы поверх REST endpoint-а. Самый универсальный вариант.', + 'wizard.step1.graphql_name': 'GraphQL', + 'wizard.step1.graphql_tagline': 'Операции по GraphQL-схеме. Поддерживаются Query и Mutation для одной фиксированной операции.', + 'wizard.step1.grpc_name': 'gRPC', + 'wizard.step1.grpc_tagline': 'Высокопроизводительный бинарный RPC поверх HTTP/2 для unary-вызовов к низколатентным внутренним сервисам.', + 'wizard.step1.websocket_name': 'WebSocket', + 'wizard.step1.websocket_tagline': 'Состояние соединения, event streams и push-oriented интеграции, нормализованные в bounded MCP streaming tools.', + 'wizard.step1.soap_name': 'SOAP', + 'wizard.step1.soap_tagline': 'Enterprise WSDL/XSD-контракты, нормализованные в MCP-инструменты со структурированным XML mapping.', + 'wizard.step1.query': 'Query', + 'wizard.step1.mutation': 'Mutation', + 'wizard.step1.unary': 'Unary', + 'wizard.step1.tip_title': 'Это можно изменить позже', + 'wizard.step1.tip_body': 'Смена протокола после настройки следующих шагов очистит схемы и mapping. Сохраните операцию как черновик перед экспериментами. См. гайд по миграции протоколов.', + 'wizard.step1.community_protocol_title': 'Граница протоколов Community', + 'wizard.step1.community_protocol_note': 'В этой Community-сборке скрыты {protocols}. Эти протокольные поверхности открываются в коммерческих редакциях.', + 'wizard.step5.streaming_title': 'Потоковое выполнение в Community', + 'wizard.step5.community_streaming_note': 'В этой Community-сборке операции остаются в режиме unary. Семейства инструментов window, session и async job требуют коммерческую редакцию.', + 'wizard.step2.title': 'Выберите upstream и endpoint', + 'wizard.step2.subtitle': 'Выберите существующий upstream или зарегистрируйте новый. Затем задайте конкретный путь endpoint-а для этой операции.', + 'wizard.required': 'Обязательно', + 'wizard.optional': 'Необязательно', + 'wizard.step2.upstream_title': 'Upstream', + 'wizard.step2.upstream_subtitle': 'Общий хост, базовый URL и аутентификация', + 'wizard.step2.upstream_placeholder': 'Выберите upstream…', + 'wizard.step2.search_placeholder': 'Поиск по имени или URL…', + 'wizard.step2.change': 'Изменить', + 'wizard.step2.auth_none': 'Без авторизации', + 'wizard.step2.auth_bearer': 'Bearer token', + 'wizard.step2.auth_basic': 'Basic auth', + 'wizard.step2.auth_apikey': 'API key', + 'wizard.step2.register_new': 'Зарегистрировать новый upstream', + 'wizard.step2.name': 'Имя', + 'wizard.step2.base_url': 'Base URL', + 'wizard.step2.unique_hint': 'Уникальный идентификатор этого upstream-а.', + 'wizard.step2.base_url_hint': 'Корневой URL без завершающего слеша. Auth настраивается отдельно ниже.', + 'wizard.step2.auth_selector': 'Upstream auth', + 'wizard.step2.auth_selector_hint': 'Используйте secrets-backed auth profiles вместо встраивания credential headers в определение upstream-а.', + 'wizard.step2.auth_mode.none': 'Без авторизации', + 'wizard.step2.auth_mode.existing': 'Использовать существующий auth profile', + 'wizard.step2.auth_mode.create': 'Создать auth profile сейчас', + 'wizard.step2.auth_profile': 'Auth profile', + 'wizard.step2.auth_profile_hint': 'Выбранный профиль применяется автоматически при каждом вызове инструмента.', + 'wizard.step2.auth_profile_empty': 'Auth profiles пока нет. Создайте его ниже или на странице Secrets.', + 'wizard.step2.create_profile_title': 'Создать auth profile', + 'wizard.step2.create_profile_subtitle': 'Создайте переиспользуемый профиль на базе зашифрованных workspace secrets.', + 'wizard.step2.profile_name': 'Имя профиля', + 'wizard.step2.profile_kind': 'Тип auth', + 'wizard.step2.auth_kind.bearer': 'Bearer token', + 'wizard.step2.auth_kind.basic': 'Basic auth', + 'wizard.step2.auth_kind.api_key_header': 'API key header', + 'wizard.step2.auth_kind.api_key_query': 'API key query', + 'wizard.step2.header_name': 'Имя заголовка', + 'wizard.step2.query_param': 'Query param', + 'wizard.step2.username_secret': 'Секрет логина', + 'wizard.step2.password_secret': 'Секрет пароля', + 'wizard.step2.secret_value': 'Секрет', + 'wizard.step2.quick_secret': 'Быстро создать секрет', + 'wizard.step2.manage_secrets': 'Открыть страницу Secrets', + 'wizard.step2.static_headers': 'Статические заголовки (опционально)', + 'wizard.step2.static_headers_hint': 'Необязательные не-секретные заголовки для каждого запроса к этому upstream-у. Для credential flows используйте auth profiles.', + 'wizard.step2.quick_secret_title': 'Быстро создать секрет', + 'wizard.step2.quick_secret_name': 'Имя секрета', + 'wizard.step2.quick_secret_kind': 'Тип секрета', + 'wizard.step2.quick_secret_value': 'Значение секрета', + 'wizard.step2.quick_secret_hint': 'Plaintext сразу шифруется и больше не возвращается.', + 'wizard.step2.quick_secret_submit': 'Создать секрет', + 'wizard.step2.save_upstream': 'Сохранить upstream', + 'wizard.step2.endpoint_title': 'Endpoint', + 'wizard.step2.endpoint_subtitle': 'Путь и HTTP-метод для конкретной операции', + 'wizard.step2.path_template': 'Шаблон пути', + 'wizard.step2.path_hint': 'Добавляется к base URL upstream-а. Используйте {param} для path-параметров.', + 'wizard.step3.rest.label': 'Конфиг запроса', + 'wizard.step3.graphql.label': 'GQL операция', + 'wizard.step3.rest.title': 'HTTP метод и формат', + 'wizard.step3.rest.subtitle': 'Выберите HTTP-метод, который отправляет эта операция, и настройте content negotiation headers. Crank сериализует аргументы MCP-инструмента в нужный формат request body.', + 'wizard.step3.rest.method_title': 'HTTP метод', + 'wizard.step3.rest.method_subtitle': 'Метод, который отправляется в upstream при каждом вызове инструмента', + 'wizard.step3.rest.read': 'Чтение', + 'wizard.step3.rest.create': 'Создание', + 'wizard.step3.rest.replace': 'Замена', + 'wizard.step3.rest.update': 'Обновление', + 'wizard.step3.rest.remove': 'Удаление', + 'wizard.step3.rest.format_title': 'Формат запроса', + 'wizard.step3.rest.format_subtitle': 'Content negotiation и сериализация', + 'wizard.step3.rest.content_type': 'Content-Type', + 'wizard.step3.rest.content_type_hint': 'Как кодируется request body перед отправкой в upstream.', + 'wizard.step3.rest.accept': 'Accept', + 'wizard.step3.rest.accept_hint': 'Какие типы ответа принимаются от upstream-сервера.', + 'wizard.step3.graphql.title': 'GraphQL операция', + 'wizard.step3.graphql.subtitle': 'Определите GraphQL-операцию, которую будет выполнять этот инструмент. Все запросы отправляются как HTTP POST на endpoint, настроенный на предыдущем шаге. Variables заполняются из аргументов MCP-инструмента во время выполнения.', + 'wizard.step3.graphql.type_title': 'Тип операции', + 'wizard.step3.graphql.type_subtitle': 'Вид GraphQL-операции — subscriptions не поддерживаются', + 'wizard.step3.graphql.query_desc': 'Получение данных без побочных эффектов', + 'wizard.step3.graphql.mutation_desc': 'Создание, изменение или удаление данных', + 'wizard.step3.graphql.doc_title': 'Документ запроса', + 'wizard.step3.graphql.doc_subtitle': 'GraphQL-документ с типизированными объявлениями переменных', + 'wizard.step3.graphql.variables_title': 'Переменные мапятся на шаге 5', + 'wizard.step3.graphql.variables_body': 'Объявите все переменные в документе запроса в формате $variable: Type. На шаге 5 вы свяжете поля MCP input-а с этими переменными. Crank автоматически сериализует итоговый payload {"query","variables"}.', + 'wizard.step4.title': 'Конфиг инструмента', + 'wizard.step4.subtitle': 'Задайте имя инструмента, описание для LLM и определите входную/выходную схему. Описание — главный сигнал, по которому модель решает, вызывать ли этот инструмент.', + 'wizard.step4.identity_title': 'Идентичность инструмента', + 'wizard.step4.identity_subtitle': 'Машиночитаемое имя и описание для LLM', + 'wizard.step4.tool_name': 'Имя инструмента', + 'wizard.step4.tool_name_hint': 'Машиночитаемый идентификатор для MCP tool calls. Только строчные буквы, цифры и подчеркивания. После публикации изменить нельзя.', + 'wizard.step4.display_name': 'Отображаемое имя', + 'wizard.step4.display_name_hint': 'Человеко-читаемое имя для консоли и журнала аудита.', + 'wizard.step4.tool_title': 'Tool title', + 'wizard.step4.tool_title_hint': 'Короткая императивная фраза, которая попадет в MCP tool manifest.', + 'wizard.step4.description': 'Описание', + 'wizard.step4.description_hint': 'Описание для LLM. Пишите точно — это главный сигнал, по которому модель решает, нужно ли вызывать инструмент.', + 'wizard.step4.description_title': 'Как писать хорошие описания', + 'wizard.step4.description_body': 'Начинайте с глагола ("Создает", "Получает", "Обновляет"). Укажите ключевые требования ко входу. Опишите, как выглядит успешный ответ. Избегайте расплывчатых формулировок вроде "обрабатывает" или "управляет". См. рекомендации по описаниям →', + 'wizard.step4.input_schema_title': 'Входная схема', + 'wizard.step4.input_schema_subtitle': 'Параметры, которые LLM передает при вызове инструмента', + 'wizard.step4.output_schema_title': 'Выходная схема', + 'wizard.step4.output_schema_subtitle': 'Форма данных, которые LLM получает после успешного вызова', + 'wizard.step4.schema_draft': 'JSON Schema draft-07', + 'wizard.step4.protocol_agnostic_title': 'Схемы не зависят от протокола', + 'wizard.step4.protocol_agnostic_body': 'Эти схемы описывают MCP-контракт, а не wire-format upstream-а. Маппинг на шаге 5 переводит одно в другое. Можно загрузить sample JSON-ответа, чтобы автоматически сгенерировать output schema.', + 'wizard.step5.title': 'Mapping и исполнение', + 'wizard.step5.subtitle': 'Определите, как аргументы MCP-инструмента мапятся в поля upstream-запроса и как ответ upstream-а мапится обратно в MCP output. Затем задайте execution-параметры — auth настраивается на уровне upstream на шаге 2.', + 'wizard.step5.input_request': 'Input → Request', + 'wizard.step5.output_response': 'Response → Output', + 'wizard.step5.execution': 'Исполнение', + 'wizard.step5.exec_title': 'Execution config', + 'wizard.step5.exec_subtitle': 'Таймауты, retry policy и ссылка на auth profile', + 'wizard.step5.security_level_title': 'Защита операций в Community', + 'wizard.step5.community_security_note': 'Community поддерживает только стандартный уровень защиты операций со статическими ключами AI-агента. Короткоживущие и одноразовые токены требуют коммерческую редакцию.', + 'wizard.step5.live_title': 'Live-проверка и публикация', + 'wizard.step5.draft_actions': 'Действия черновика', + 'wizard.step5.live_save_title': 'Live-действия сначала сохраняют черновик', + 'wizard.step5.live_save_body': 'Загрузка sample-ов, тестовые прогоны, YAML-действия и публикация сначала сохраняют текущий черновик перед вызовом backend-а.', + 'wizard.step5.samples_title': 'Samples и генерация черновика', + 'wizard.step5.samples_subtitle': 'Сохраните входные и выходные sample-ы, затем сгенерируйте схемы и маппинг из реальных payload-ов.', + 'wizard.step5.input_sample': 'Входной sample', + 'wizard.step5.output_sample': 'Выходной sample', + 'wizard.step5.save_input_sample': 'Сохранить входной sample', + 'wizard.step5.save_output_sample': 'Сохранить выходной sample', + 'wizard.step5.generate_draft': 'Сгенерировать черновик из sample-ов', + 'wizard.step5.test_title': 'Тестовый прогон', + 'wizard.step5.test_subtitle': 'Проверьте текущий черновик на upstream-е перед публикацией.', + 'wizard.step5.test_input_payload': 'Входной payload для теста', + 'wizard.step5.run_test': 'Запустить тест', + 'wizard.step5.use_response_output': 'Использовать ответ как выходной sample', + 'wizard.step5.request_preview': 'Предпросмотр запроса', + 'wizard.step5.response_preview': 'Предпросмотр ответа', + 'wizard.step5.errors': 'Ошибки', + 'wizard.step5.choose_descriptor': 'Выбрать descriptor set', + 'wizard.step5.upload_discover': 'Загрузить и открыть сервисы', + 'wizard.step5.no_descriptor': 'Descriptor set не выбран', + 'wizard.step5.yaml_title': 'Импорт и экспорт YAML', + 'wizard.step5.yaml_subtitle': 'Переносите черновики операций между окружениями, не выходя из мастера.', + 'wizard.step5.export_yaml': 'Экспорт YAML', + 'wizard.step5.load_yaml_file': 'Загрузить YAML-файл', + 'wizard.step5.import_yaml': 'Импорт YAML', + 'wizard.step5.yaml_document': 'YAML-документ', + 'wizard.step5.yaml_placeholder': 'Вставьте экспортированный YAML сюда, чтобы создать или обновить операцию.', + 'wizard.step5.publish_title': 'Публикация', + 'wizard.step5.publish_subtitle': 'Сохраните текущий черновик и откройте его опубликованным агентам.', + 'wizard.step5.publish_hint': 'Публикация использует текущую версию черновика из этой сессии мастера.', + 'wizard.step5.publish_action': 'Опубликовать операцию', + 'wizard.step5.editable_title': 'Операция остается редактируемой в этом мастере', + 'wizard.step5.editable_body': 'Сохранение создает или обновляет текущий черновик на месте. Используйте live-действия выше, чтобы загрузить sample-ы, протестировать черновик, экспортировать или импортировать YAML и опубликовать контракт, когда он будет готов.', + 'wizard.live.failed_title': 'Не удалось выполнить live-действие', + 'wizard.live.failed_body': 'Запрос к backend завершился ошибкой. Проверьте черновик и попробуйте еще раз.', + 'wizard.error.no_workspace': 'Воркспейс не выбран', + 'wizard.error.tool_name': 'Имя инструмента обязательно', + 'wizard.error.parser_unavailable': 'YAML parser недоступен', + 'wizard.error.select_upstream': 'Сначала выберите или зарегистрируйте upstream', + 'wizard.error.auth_profile_required': 'Выберите auth profile или переключите upstream auth в режим «Без авторизации».', + 'wizard.error.auth_profile_name': 'Нужно указать имя auth profile.', + 'wizard.error.secret_required': 'Выберите секрет, необходимый для этого auth profile.', + 'wizard.error.basic_secrets_required': 'Выберите секреты и для логина, и для пароля.', + 'wizard.error.header_name_required': 'Для этого auth profile нужно указать имя заголовка.', + 'wizard.error.query_param_required': 'Для этого auth profile нужно указать имя query-параметра.', + 'wizard.error.secret_name_required': 'Нужно указать имя секрета.', + 'wizard.error.secret_value_required': 'Нужно указать значение секрета.', + 'wizard.error.save_upstream_first': 'Сначала сохраните upstream после создания нового auth profile.', + 'wizard.toast.auth_profile_created_title': 'Auth profile создан', + 'wizard.toast.auth_profile_created_body': 'Новый upstream auth profile готов и уже выбран.', + 'wizard.toast.auth_profile_error_title': 'Не удалось создать auth profile', + 'wizard.toast.quick_secret_created_title': 'Секрет создан', + 'wizard.toast.quick_secret_created_body': 'Новый секрет уже доступен в auth selector.', + 'wizard.toast.quick_secret_error_title': 'Не удалось создать секрет', + 'wizard.error.save': 'Не удалось сохранить операцию', + 'wizard.error.save_title': 'Не удалось сохранить', + 'wizard.save.created': 'Черновик создан', + 'wizard.save.updated': 'Черновик обновлен', + 'wizard.save.body': 'Текущий черновик сохранен в backend и готов к live-действиям в этом мастере.', + 'wizard.save.saved': 'Сохранено ✓', + 'wizard.busy.input_sample': 'Сохранение входного sample…', + 'wizard.busy.output_sample': 'Сохранение выходного sample…', + 'wizard.busy.generate': 'Генерация черновика…', + 'wizard.busy.test': 'Запуск теста…', + 'wizard.busy.export_yaml': 'Экспорт YAML…', + 'wizard.busy.import_yaml': 'Импорт YAML…', + 'wizard.busy.publish': 'Публикация операции…', + 'wizard.busy.descriptor': 'Загрузка descriptor set…', + 'wizard.busy.wsdl': 'Inspection WSDL…', + 'wizard.sample.input_saved': 'Входной sample сохранен', + 'wizard.sample.input_saved_body': 'Входной sample теперь сохранен в текущей версии черновика.', + 'wizard.sample.output_saved': 'Выходной sample сохранен', + 'wizard.sample.output_saved_body': 'Выходной sample теперь сохранен в текущей версии черновика.', + 'wizard.sample.generated': 'Черновик сгенерирован', + 'wizard.sample.generated_body': 'Схемы и mapping были пересобраны из сохраненных sample-ов.', + 'wizard.test.completed': 'Тестовый прогон завершен', + 'wizard.test.failed': 'Тестовый прогон вернул ошибки', + 'wizard.test.completed_body': 'Текущий черновик успешно выполнился против upstream.', + 'wizard.test.failed_body': 'Черновик выполнился, но backend вернул ошибки валидации или runtime.', + 'wizard.test.window_completed': 'Оконный тест завершен', + 'wizard.test.window_completed_body': 'Собран ограниченный результат окна.', + 'wizard.test.window_truncated_note': 'Результат был усечен.', + 'wizard.test.window_more_note': 'Доступно продолжение, если перейти к streaming-представлениям.', + 'wizard.test.window_incomplete_note': 'Upstream-поток завершился раньше, чем удалось собрать всё окно.', + 'wizard.test.websocket_window_completed': 'WebSocket оконный тест завершен', + 'wizard.test.websocket_window_completed_body': 'В пределах ограниченного тестового окна собрано {items} WebSocket event(s).', + 'wizard.test.websocket_window_truncated_note': 'Ограниченное окно остановилось после достижения лимита по элементам или байтам.', + 'wizard.test.websocket_window_more_note': 'Сокет выдал больше событий, чем сохранило это тестовое окно. Увеличьте лимиты или переключитесь в session mode, если нужен более длинный поток.', + 'wizard.test.websocket_window_incomplete_note': 'Upstream WebSocket закрылся до того, как bounded окно успело собрать все настроенные события.', + 'wizard.test.websocket_session_started': 'WebSocket session-тест запущен', + 'wizard.test.websocket_session_started_body': 'WebSocket session {session_id} создана. Продолжайте в разделе Stream sessions через короткую паузу.', + 'wizard.test.websocket_async_job_started': 'WebSocket async job тест запущен', + 'wizard.test.websocket_async_job_started_body': 'Создан WebSocket async job {job_id}. Дальше отслеживайте статус и результат в представлении async jobs.', + 'wizard.test.websocket_failed': 'WebSocket тест завершился с ошибками', + 'wizard.test.websocket_failed_body': 'Тест сокета завершился до того, как Crank успел собрать bounded result window.', + 'wizard.test.websocket_failed_reconnect_body': 'Upstream WebSocket закрылся раньше, чем удалось собрать достаточно событий, и настроенная reconnect policy была исчерпана.', + 'wizard.test.websocket_failed_closed_body': 'Upstream WebSocket закрылся до завершения bounded test window.', + 'wizard.test.session_started': 'Session-тест запущен', + 'wizard.test.session_started_body': 'Сессия {session_id} создана. Проверьте статус в разделе Stream sessions через несколько секунд.', + 'wizard.test.async_job_started': 'Async job тест запущен', + 'wizard.test.async_job_started_body': 'Создан async job {job_id}. Отслеживайте статус и результат через представление async jobs.', + 'wizard.test.no_response': 'Тестового ответа пока нет', + 'wizard.test.no_response_body': 'Сначала выполните тест, затем скопируйте response preview в editor output sample.', + 'wizard.test.response_copied': 'Ответ скопирован', + 'wizard.test.response_copied_body': 'Последний тестовый ответ скопирован в editor output sample.', + 'wizard.boolean.yes': 'да', + 'wizard.boolean.no': 'нет', + 'wizard.yaml.exported': 'YAML экспортирован', + 'wizard.yaml.exported_body': 'YAML текущего черновика скачан из backend.', + 'wizard.yaml.none': 'YAML не предоставлен', + 'wizard.yaml.none_body': 'Вставьте YAML-документ или загрузите файл перед импортом.', + 'wizard.yaml.imported': 'YAML импортирован', + 'wizard.yaml.imported_body': 'Импортированный черновик операции сохранен. Мастер сейчас откроет его заново.', + 'wizard.yaml.loaded': 'YAML загружен', + 'wizard.yaml.loaded_body': 'YAML-документ загружен в editor импорта.', + 'wizard.publish.done': 'Операция опубликована', + 'wizard.publish.done_body': 'Версия {version} опубликована и теперь может быть привязана к агентам.', + 'wizard.grpc.discovery_title': 'gRPC discovery переведен в live descriptor flow', + 'wizard.grpc.discovery_body': 'Server reflection в этом развертывании не подключен. Сохраните черновик и используйте инструменты descriptor set на шаге 5, чтобы загрузить реальный descriptor set и открыть unary methods из backend.', + 'wizard.grpc.no_methods': 'Unary RPC methods не найдены.', + 'wizard.grpc.fields_unresolved': 'Поля не разрешены.', + 'wizard.grpc.fields_unresolved_body': 'Import-ы или внешние типы не разворачиваются на стороне клиента.', + 'wizard.grpc.services_found': 'Найдено {services_label} и {methods_label}.', + 'wizard.grpc.services_label.one': '{count} сервис', + 'wizard.grpc.services_label.few': '{count} сервиса', + 'wizard.grpc.services_label.many': '{count} сервисов', + 'wizard.grpc.services_label.other': '{count} сервиса', + 'wizard.grpc.methods_label.one': '{count} unary-метод', + 'wizard.grpc.methods_label.few': '{count} unary-метода', + 'wizard.grpc.methods_label.many': '{count} unary-методов', + 'wizard.grpc.methods_label.other': '{count} unary-метода', + 'wizard.grpc.services_discovered': 'Сервисов открыто: {services} · unary методов: {methods}', + 'wizard.grpc.services_none': 'Unary methods пока не открыты', + + // Common buttons + 'btn.save': 'Сохранить', + 'btn.cancel': 'Отмена', + 'btn.create': 'Создать', + 'btn.delete': 'Удалить', + 'btn.revoke': 'Отозвать', + 'btn.copy': 'Копировать', + + // Agents page + 'agents.title': 'Агенты', + 'agents.subtitle': 'Именованные MCP endpoint-ы, открывающие LLM ограниченный набор операций', + 'agents.subtitle.community': 'Отдельные MCP endpoint-ы для AI-агентов. В Community машинный доступ держится на статических ключах агента.', + 'agents.new': 'Новый агент', + 'agents.limit.title': 'Достигнут лимит агентов', + 'agents.limit.message': 'В этой Community-сборке доступно не более {limit} AI-агента. Чтобы добавить еще одного, удалите существующего агента или перейдите на другую редакцию.', + 'agents.stats.total': 'Всего агентов', + 'agents.stats.total_sub': 'в этом воркспейсе', + 'agents.stats.published': 'Опубликованы', + 'agents.stats.published_sub': '{value}% от общего числа', + 'agents.stats.tools': 'Открытых операций', + 'agents.stats.tools_sub': 'в {count} агентах', + 'agents.stats.calls': 'Вызовов сегодня', + 'agents.stats.calls_sub': 'по всем агентам', + 'agents.search': 'Поиск агентов...', + 'agents.results': '{shown} из {total} агентов', + 'agents.callout.default': 'У каждого агента свой MCP endpoint. Подключайте LLM-клиент к конкретному агенту, чтобы он видел только нужные инструменты, а не все {count} операций этого воркспейса.', + 'agents.callout.community': 'В Community у каждого агента свой MCP endpoint и свои статические ключи агента. Подключайте LLM-клиент к конкретному агенту, чтобы он видел только нужные инструменты, а не все {count} операций этого воркспейса.', + 'agents.loading': 'Загрузка агентов…', + 'agents.error.title': 'Не удалось загрузить агентов', + 'agents.error.api': 'Воркспейс или API недоступен', + 'agents.error.load': 'Не удалось загрузить агентов', + 'agents.empty.initial.title': 'Агентов пока нет', + 'agents.empty.initial.sub': 'Создайте первого агента, чтобы получить отдельный MCP endpoint с нужным набором инструментов.', + 'agents.empty.initial.sub_community': 'Создайте первого агента, чтобы получить один отдельный MCP endpoint и его статические машинные ключи для конкретного набора инструментов.', + 'agents.empty.initial.next_step': 'После создания выпустите отдельные машинные ключи для этого endpoint на странице API Keys.', + 'agents.empty.search.title': 'Нет агентов по запросу "{query}"', + 'agents.empty.search.sub': 'Попробуйте другой поисковый запрос.', + 'agents.card.tools': 'инструментов', + 'agents.card.keys': 'ключей', + 'agents.card.calls_today': 'вызовов сегодня', + 'agents.card.created': 'Создан {date}', + 'agents.card.copy_endpoint': 'Скопировать endpoint', + 'agents.card.endpoint_help': 'Используйте этот MCP endpoint в клиенте и управляйте его машинными ключами на странице API Keys.', + 'agents.card.endpoint_help_community': 'Используйте этот MCP endpoint в клиенте. В Community машинный доступ держится на статических ключах агента со страницы API Keys.', + 'agents.action.edit': 'Редактировать', + 'agents.action.archive': 'Архивировать', + 'agents.action.delete': 'Удалить', + 'agents.lifecycle.publish': 'Опубликовать', + 'agents.lifecycle.unpublish': 'Снять с публикации', + 'agents.lifecycle.restore_draft': 'Вернуть в черновик', + 'agents.lifecycle.published': 'Опубликован', + 'agents.lifecycle.archived': 'Архивирован', + 'agents.lifecycle.draft': 'Черновик', + 'agents.drawer.new_title': 'Новый агент', + 'agents.drawer.edit_title': 'Редактировать агента', + 'agents.drawer.new_sub': 'Настройте именованный MCP endpoint для вашей LLM', + 'agents.drawer.new_sub_community': 'Настройте один MCP endpoint и его границу на статических ключах агента для конкретного LLM-сценария', + 'agents.drawer.edit_sub': 'Обновите настройки агента и выбор инструментов', + 'agents.drawer.identity': 'Идентичность', + 'agents.drawer.display_name': 'Отображаемое имя', + 'agents.drawer.slug': 'Slug', + 'agents.drawer.description': 'Описание', + 'agents.drawer.optional': '(необязательно)', + 'agents.drawer.required': 'обязательно', + 'agents.drawer.status': 'Статус', + 'agents.drawer.endpoint': 'MCP endpoint', + 'agents.drawer.slug_hint': 'Сохраняйте slug стабильным после подключения клиентов. Его изменение меняет MCP path.', + 'agents.drawer.placeholder.name': 'Customer Support', + 'agents.drawer.placeholder.slug': 'customer-support', + 'agents.drawer.placeholder.description': 'Что делает этот агент? Для какой LLM или сценария он нужен?', + 'agents.drawer.operations': 'Операции', + 'agents.drawer.selected': 'Выбрано: {count}', + 'agents.drawer.operations_sub': 'Выберите операции, которые открывает этот агент. LLM, подключенная к агенту, увидит только эти инструменты.', + 'agents.drawer.operations_sub_community': 'Выберите операции, которые открывает этот агент. Любые ключи, выпущенные для этого агента, будут работать только против этого MCP endpoint.', + 'agents.drawer.filter_ops': 'Фильтр операций…', + 'agents.drawer.ops_no_match': 'Нет операций по запросу "{query}"', + 'agents.drawer.ops_selected': 'Выбрано операций: {count}', + 'agents.drawer.clear_all': 'Очистить все', + 'agents.drawer.recommendation': 'Сейчас выбрано {count} инструментов. LLM лучше работает, когда у агента меньше 15 инструментов. Подумайте о разбиении по сценариям.', + 'agents.drawer.cancel': 'Отмена', + 'agents.drawer.create': 'Создать агента', + 'agents.drawer.save': 'Сохранить изменения', + 'agents.toast.saved_title_create': 'Агент создан', + 'agents.toast.saved_title_update': 'Агент обновлен', + 'agents.toast.saved_message': '{name} сохранен, привязано инструментов: {count}.', + 'agents.toast.save_error_title': 'Не удалось обновить агента', + 'agents.toast.save_error_message': 'Не удалось сохранить агента', + 'agents.toast.delete_confirm': 'Удалить этого агента? Действие нельзя отменить.', + 'agents.toast.delete_title': 'Агент удален', + 'agents.toast.delete_message': 'Агент {name} удален.', + 'agents.toast.delete_error_title': 'Не удалось удалить', + 'agents.toast.delete_error_message': 'Не удалось удалить агента', + 'agents.toast.lifecycle_title': 'Жизненный цикл агента обновлен', + 'agents.toast.lifecycle_publish': '{name} опубликован.', + 'agents.toast.lifecycle_unpublish': '{name} возвращен в черновик.', + 'agents.toast.lifecycle_archive': '{name} архивирован.', + 'agents.toast.lifecycle_error_title': 'Не удалось обновить жизненный цикл', + 'agents.toast.lifecycle_error_message': 'Не удалось обновить состояние агента', + 'agents.toast.endpoint_title': 'MCP endpoint скопирован', + + // Demo content + 'demo.agent.revops-copilot.display_name': 'Помощник RevOps', + 'demo.agent.revops-copilot.description': 'Ассистент Revenue Operations с CRM- и billing-инструментами.', + 'demo.agent.support-triage.display_name': 'Триаж поддержки', + 'demo.agent.support-triage.description': 'Черновой агент поддержки, сфокусированный на unary gRPC-сценариях.', + 'demo.operation.crm_create_lead.display_name': 'Создать CRM-лид', + 'demo.operation.crm_create_lead.description': 'Создает запись лида в CRM-системе.', + 'demo.operation.billing_get_invoice.display_name': 'Статус инвойса', + 'demo.operation.billing_get_invoice.description': 'Получает состояние и сумму инвойса из billing-системы.', + 'demo.operation.support_lookup_ticket.display_name': 'Найти тикет поддержки', + 'demo.operation.support_lookup_ticket.description': 'Черновой unary gRPC lookup поддержки через descriptor set.', + 'demo.operation.marketing_archive_contact.display_name': 'Архивировать маркетинговый контакт', + 'demo.operation.marketing_archive_contact.description': 'Устаревший архивный сценарий, оставленный только для аудита.', + + // Login + 'login.title': 'Войти', + 'login.subtitle': 'Добро пожаловать обратно', + 'login.email': 'Email', + 'login.password': 'Пароль', + 'login.submit': 'Войти', + 'login.sso': 'Войти через Google', + 'login.email_label': 'Email адрес', + 'login.email_placeholder': 'you@acme.com', + 'login.forgot': 'Забыли пароль?', + 'login.divider': 'или продолжить через', + 'login.sso_short': 'Google SSO', + 'login.request_prefix': 'Нет аккаунта?', + 'login.request_link': 'Запросить доступ', + 'login.planned_badge': 'Запланировано', + 'login.password_only': 'Войдите с помощью email и пароля.', + 'login.coming_soon': 'Сброс пароля и SSO появятся позже.', + 'login.error.required': 'Введите email и пароль.', + 'login.error.invalid': 'Неверный email или пароль. Попробуйте еще раз.', + 'login.error.generic': 'Сейчас не удается войти. Попробуйте еще раз.', + } +}; + +Object.assign(TRANSLATIONS.en, { + + +}); + +Object.assign(TRANSLATIONS.ru, { + + +}); + +function t(key) { + var lang = localStorage.getItem('crank_lang') || 'en'; + var tr = TRANSLATIONS[lang] || TRANSLATIONS.en; + return tr[key] !== undefined ? tr[key] : (TRANSLATIONS.en[key] !== undefined ? TRANSLATIONS.en[key] : key); +} + +function tf(key, vars) { + return Object.keys(vars || {}).reduce(function(result, name) { + return result.replaceAll('{' + name + '}', vars[name]); + }, t(key)); +} + +function pluralForm(lang, count) { + if (typeof Intl !== 'undefined' && typeof Intl.PluralRules === 'function') { + return new Intl.PluralRules(lang).select(Number(count) || 0); + } + return Number(count) === 1 ? 'one' : 'other'; +} + +function tPlural(key, count, vars) { + var lang = localStorage.getItem('crank_lang') || 'en'; + var tr = TRANSLATIONS[lang] || TRANSLATIONS.en; + var form = pluralForm(lang, count); + var fallbackForm = pluralForm('en', count); + var resolvedKey = tr[key + '.' + form] !== undefined + ? key + '.' + form + : tr[key + '.other'] !== undefined + ? key + '.other' + : TRANSLATIONS.en[key + '.' + fallbackForm] !== undefined + ? key + '.' + fallbackForm + : TRANSLATIONS.en[key + '.other'] !== undefined + ? key + '.other' + : key; + var payload = Object.assign({ count: count }, vars || {}); + return Object.keys(payload).reduce(function(result, name) { + return result.replaceAll('{' + name + '}', payload[name]); + }, t(resolvedKey)); +} + +function hasTranslation(key) { + return t(key) !== key; +} + +function localizeDemoAgent(agent) { + if (!agent || !agent.slug) { + return agent; + } + + var displayNameKey = 'demo.agent.' + agent.slug + '.display_name'; + var descriptionKey = 'demo.agent.' + agent.slug + '.description'; + + if (!hasTranslation(displayNameKey) && !hasTranslation(descriptionKey)) { + return agent; + } + + return Object.assign({}, agent, { + display_name: hasTranslation(displayNameKey) ? t(displayNameKey) : agent.display_name, + description: hasTranslation(descriptionKey) ? t(descriptionKey) : agent.description, + }); +} + +function localizeDemoOperation(operation) { + if (!operation || !operation.name) { + return operation; + } + + var displayNameKey = 'demo.operation.' + operation.name + '.display_name'; + var descriptionKey = 'demo.operation.' + operation.name + '.description'; + + if (!hasTranslation(displayNameKey) && !hasTranslation(descriptionKey)) { + return operation; + } + + var next = Object.assign({}, operation); + + if (hasTranslation(displayNameKey)) { + next.display_name = t(displayNameKey); + if (Object.prototype.hasOwnProperty.call(operation, 'operation_display_name')) { + next.operation_display_name = t(displayNameKey); + } + } + + if (hasTranslation(descriptionKey)) { + if (Object.prototype.hasOwnProperty.call(operation, 'description')) { + next.description = t(descriptionKey); + } + if (Object.prototype.hasOwnProperty.call(operation, 'tool_description')) { + next.tool_description = t(descriptionKey); + } + } + + return next; +} + +window.localizeDemoAgent = localizeDemoAgent; +window.localizeDemoOperation = localizeDemoOperation; +window.tPlural = tPlural; + +function applyLang() { + // text content + document.querySelectorAll('[data-i18n]').forEach(function(el) { + el.textContent = t(el.getAttribute('data-i18n')); + }); + // placeholder + document.querySelectorAll('[data-i18n-ph]').forEach(function(el) { + el.placeholder = t(el.getAttribute('data-i18n-ph')); + }); + // title + document.querySelectorAll('[data-i18n-title]').forEach(function(el) { + el.title = t(el.getAttribute('data-i18n-title')); + }); + // aria-label + document.querySelectorAll('[data-i18n-aria-label]').forEach(function(el) { + el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria-label'))); + }); + // highlight active language button in settings + var lang = localStorage.getItem('crank_lang') || 'en'; + document.querySelectorAll('.lang-btn').forEach(function(btn) { + btn.classList.toggle('active', btn.dataset.lang === lang); + }); +} + +function setLang(lang) { + localStorage.setItem('crank_lang', lang); + applyLang(); + // Notify Alpine components to re-render reactive getters + window.dispatchEvent(new CustomEvent('crank:langchange', { detail: { lang: lang } })); +} + +document.addEventListener('DOMContentLoaded', applyLang); diff --git a/apps/ui/js/login.js b/apps/ui/js/login.js new file mode 100644 index 0000000..d056d93 --- /dev/null +++ b/apps/ui/js/login.js @@ -0,0 +1,54 @@ +(function() { + function tKey(key) { + return window.t ? t(key) : key; + } + + function showError(message) { + var errorElement = document.getElementById('login-error'); + errorElement.classList.add('is-visible'); + errorElement.textContent = message; + } + + function hideError() { + var errorElement = document.getElementById('login-error'); + errorElement.classList.remove('is-visible'); + } + + function initLoginPage() { + window.CrankAuth.guardLoginPage().catch(function(error) { + if (window.CrankDiagnostics && typeof window.CrankDiagnostics.report === 'function') { + window.CrankDiagnostics.report('guard-login-page', error, 'login'); + } + }); + + document.getElementById('login-form').addEventListener('submit', async function(event) { + event.preventDefault(); + + var email = document.getElementById('email').value.trim(); + var password = document.getElementById('password').value; + + if (!email || !password) { + showError(tKey('login.error.required')); + return; + } + + hideError(); + + try { + await window.CrankAuth.login(email, password); + } catch (error) { + if (error && error.status === 401) { + showError(tKey('login.error.invalid')); + return; + } + showError(tKey('login.error.generic')); + } + }); + } + + if (window.CrankDiagnostics && typeof window.CrankDiagnostics.bootstrap === 'function') { + window.CrankDiagnostics.bootstrap('login', initLoginPage); + return; + } + document.addEventListener('DOMContentLoaded', initLoginPage); +}()); diff --git a/apps/ui/js/logs.js b/apps/ui/js/logs.js new file mode 100644 index 0000000..b51f776 --- /dev/null +++ b/apps/ui/js/logs.js @@ -0,0 +1,419 @@ +document.addEventListener('DOMContentLoaded', function () { + var state = { + logs: [], + details: {}, + level: 'all', + search: '', + period: '7d', + openId: null, + liveMode: true, + timer: null, + workspaceId: null, + loading: false, + loadError: '', + }; + + var logList = document.getElementById('log-list'); + var logSearch = document.getElementById('log-search'); + var refreshBtn = document.getElementById('refresh-btn'); + var timeRangeSel = document.getElementById('time-range'); + var liveDot = document.querySelector('.live-dot'); + var liveLabel = document.querySelector('.live-label'); + + function tKey(key) { + return window.t ? t(key) : key; + } + + function currentWorkspaceId() { + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + return workspace ? workspace.id : null; + } + + function durationLabel(durationMs) { + if (!durationMs) { + return '0ms'; + } + if (durationMs >= 1000) { + return (durationMs / 1000).toFixed(1) + 's'; + } + return durationMs + 'ms'; + } + + function formatJson(value) { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + return JSON.stringify(value, null, 2); + } + + function formatTime(timestamp) { + var date = new Date(timestamp); + return date.toISOString().slice(11, 23); + } + + function element(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; + } + + function statusClass(statusCode) { + if (!statusCode) { + return ''; + } + if (statusCode < 300) { + return 'ok'; + } + if (statusCode < 500) { + return 'warn'; + } + return 'err'; + } + + function normalizeLog(record) { + var log = record.log; + var agent = window.localizeDemoAgent + ? window.localizeDemoAgent({ + slug: record.agent_slug, + display_name: record.agent_display_name, + description: '', + }) + : null; + var operation = window.localizeDemoOperation + ? window.localizeDemoOperation({ + name: record.operation_name, + operation_display_name: record.operation_display_name, + }) + : null; + + return { + id: log.id, + createdAt: log.created_at, + level: log.level, + source: log.source, + status: log.status, + statusCode: log.status_code, + durationMs: log.duration_ms, + toolName: log.tool_name, + message: log.message, + operationName: record.operation_name, + operationDisplayName: operation ? operation.operation_display_name : record.operation_display_name, + agentSlug: record.agent_slug, + agentDisplayName: agent ? agent.display_name : record.agent_display_name, + requestPreview: log.request_preview, + responsePreview: log.response_preview, + errorKind: log.error_kind, + requestId: log.request_id, + }; + } + + function renderEmpty(title, message) { + logList.innerHTML = ''; + var empty = element('div', 'empty-state'); + empty.appendChild(element('div', 'empty-state-title', title)); + empty.appendChild(element('div', 'empty-state-text', message)); + logList.appendChild(empty); + } + + function renderLogs() { + if (state.loading && state.logs.length === 0) { + renderEmpty(tKey('logs.loading.title'), tKey('logs.loading.sub')); + return; + } + + if (state.loadError) { + renderEmpty(tKey('logs.error.title'), state.loadError); + return; + } + + if (!state.logs.length) { + renderEmpty( + tKey('logs.empty.title'), + state.search || state.level !== 'all' + ? tKey('logs.empty.filtered') + : tKey('logs.empty.initial') + ); + return; + } + + var fragment = document.createDocumentFragment(); + + state.logs.forEach(function (item) { + var row = document.createElement('div'); + row.className = 'log-entry'; + row.setAttribute('data-id', item.id); + + var timeEl = document.createElement('span'); + timeEl.className = 'log-time'; + timeEl.textContent = formatTime(item.createdAt); + row.appendChild(timeEl); + + var levelEl = document.createElement('span'); + levelEl.className = 'log-level ' + item.level; + levelEl.textContent = item.level.toUpperCase(); + row.appendChild(levelEl); + + var msgDiv = document.createElement('div'); + msgDiv.className = 'log-msg'; + + var opSpan = document.createElement('span'); + opSpan.className = 'log-op-name'; + opSpan.textContent = item.operationDisplayName || item.operationName; + msgDiv.appendChild(opSpan); + msgDiv.appendChild(document.createTextNode('\u00a0\u00a0')); + + if (item.statusCode) { + var statusEl = document.createElement('span'); + statusEl.className = 'log-status ' + statusClass(item.statusCode); + statusEl.textContent = item.statusCode; + msgDiv.appendChild(statusEl); + msgDiv.appendChild(document.createTextNode(' \u00b7 ')); + } + + msgDiv.appendChild(document.createTextNode(item.message)); + row.appendChild(msgDiv); + + var durationEl = document.createElement('span'); + var durationClass = item.level === 'error' ? ' error' : (item.durationMs >= 1000 ? ' slow' : ''); + durationEl.className = 'log-duration' + durationClass; + durationEl.textContent = durationLabel(item.durationMs); + row.appendChild(durationEl); + + fragment.appendChild(row); + + var expanded = document.createElement('div'); + expanded.className = 'log-entry-expanded' + (item.id === state.openId ? ' open' : ''); + expanded.setAttribute('data-exp', item.id); + + if (item.id === state.openId) { + var detail = state.details[item.id] || item; + + if (detail.agentDisplayName || detail.agentSlug) { + var agentLabel = document.createElement('div'); + agentLabel.className = 'log-detail-label'; + agentLabel.textContent = tKey('logs.detail.agent'); + expanded.appendChild(agentLabel); + + var agentPre = document.createElement('pre'); + agentPre.className = 'log-detail-block'; + agentPre.textContent = detail.agentDisplayName || detail.agentSlug; + expanded.appendChild(agentPre); + } + + var requestLabel = document.createElement('div'); + requestLabel.className = 'log-detail-label'; + requestLabel.textContent = tKey('logs.detail.request'); + expanded.appendChild(requestLabel); + + var requestPre = document.createElement('pre'); + requestPre.className = 'log-detail-block'; + requestPre.textContent = formatJson(detail.requestPreview); + expanded.appendChild(requestPre); + + var responseLabel = document.createElement('div'); + responseLabel.className = 'log-detail-label'; + responseLabel.textContent = tKey('logs.detail.response'); + expanded.appendChild(responseLabel); + + var responsePre = document.createElement('pre'); + responsePre.className = 'log-detail-block'; + responsePre.textContent = formatJson(detail.responsePreview); + expanded.appendChild(responsePre); + + if (detail.errorKind || detail.requestId) { + var metaLabel = document.createElement('div'); + metaLabel.className = 'log-detail-label'; + metaLabel.textContent = tKey('logs.detail.meta'); + expanded.appendChild(metaLabel); + + var metaPre = document.createElement('pre'); + metaPre.className = 'log-detail-block'; + metaPre.textContent = formatJson({ + request_id: detail.requestId || null, + error_kind: detail.errorKind || null, + source: detail.source, + status: detail.status, + }); + expanded.appendChild(metaPre); + } + } + + fragment.appendChild(expanded); + }); + + logList.innerHTML = ''; + logList.appendChild(fragment); + + logList.querySelectorAll('.log-entry').forEach(function (row) { + row.addEventListener('click', async function () { + var id = this.getAttribute('data-id'); + state.openId = state.openId === id ? null : id; + renderLogs(); + if (state.openId) { + await loadLogDetail(id); + } + }); + }); + } + + function queryParams() { + var params = { + period: state.period, + limit: 100, + }; + if (state.level !== 'all') { + params.level = state.level; + } + if (state.search) { + params.search = state.search; + } + return params; + } + + async function loadLogs() { + if (!window.CrankApi) { + state.loadError = tKey('logs.error.api'); + renderLogs(); + return; + } + + state.workspaceId = currentWorkspaceId(); + if (!state.workspaceId) { + state.loadError = tKey('logs.error.workspace'); + renderLogs(); + return; + } + + state.loading = true; + state.loadError = ''; + renderLogs(); + + try { + var response = await window.CrankApi.listLogs(state.workspaceId, queryParams()); + state.logs = (response && response.items ? response.items : []).map(normalizeLog); + if (state.openId && !state.logs.some(function (item) { return item.id === state.openId; })) { + state.openId = null; + } + } catch (error) { + state.loadError = error.message || tKey('logs.error.load'); + } finally { + state.loading = false; + renderLogs(); + } + } + + async function loadLogDetail(logId) { + if (!window.CrankApi || !state.workspaceId || state.details[logId]) { + return; + } + + try { + var record = await window.CrankApi.getLog(state.workspaceId, logId); + state.details[logId] = normalizeLog(record); + if (state.openId === logId) { + renderLogs(); + } + } catch (_error) { + } + } + + function setLiveState() { + if (liveDot) { + liveDot.classList.toggle('is-paused', !state.liveMode); + } + if (liveLabel) { + liveLabel.textContent = state.liveMode ? tKey('logs.live') : tKey('logs.paused'); + liveLabel.classList.toggle('is-paused', !state.liveMode); + } + } + + function stopPolling() { + if (state.timer) { + clearInterval(state.timer); + state.timer = null; + } + } + + function startPolling() { + stopPolling(); + if (!state.liveMode) { + return; + } + state.timer = setInterval(loadLogs, 4000); + } + + function toggleLive() { + state.liveMode = !state.liveMode; + setLiveState(); + startPolling(); + if (window.CrankUi) { + window.CrankUi.info( + state.liveMode ? tKey('logs.live.on.body') : tKey('logs.live.off.body'), + state.liveMode ? tKey('logs.live.on.title') : tKey('logs.live.off.title') + ); + } + } + + document.querySelectorAll('.filter-chip[data-level]').forEach(function (button) { + button.addEventListener('click', function () { + state.level = this.getAttribute('data-level'); + document.querySelectorAll('.filter-chip[data-level]').forEach(function (item) { + item.classList.remove('active'); + }); + this.classList.add('active'); + loadLogs(); + }); + }); + + if (logSearch) { + logSearch.addEventListener('input', function () { + state.search = this.value.trim(); + loadLogs(); + }); + } + + if (refreshBtn) { + refreshBtn.addEventListener('click', function () { + loadLogs().then(function () { + if (!state.loadError && window.CrankUi) { + window.CrankUi.info(tKey('logs.refresh.body'), tKey('logs.refresh.title')); + } + }); + }); + } + + if (timeRangeSel) { + timeRangeSel.value = state.period; + timeRangeSel.addEventListener('change', function () { + state.period = this.value; + loadLogs(); + }); + } + + if (liveDot) { + liveDot.addEventListener('click', toggleLive); + } + + if (liveLabel) { + liveLabel.addEventListener('click', toggleLive); + } + + window.addEventListener('crank:workspacechange', function () { + state.details = {}; + state.openId = null; + loadLogs(); + }); + + setLiveState(); + startPolling(); + + if (window.whenWorkspacesReady) { + window.whenWorkspacesReady().finally(loadLogs); + } else { + loadLogs(); + } +}); diff --git a/apps/ui/js/nav.js b/apps/ui/js/nav.js new file mode 100644 index 0000000..6786834 --- /dev/null +++ b/apps/ui/js/nav.js @@ -0,0 +1,76 @@ +(function () { + function initNav() { + if (window.CrankAuth && typeof window.CrankAuth.renderShellIdentity === 'function') { + window.CrankAuth.renderShellIdentity(); + } + + var dropdown = document.querySelector('.user-dropdown'); + var avatar = document.querySelector('.nav-avatar'); + var hamburger = document.querySelector('.nav-hamburger'); + var mobileNav = document.querySelector('.mobile-nav'); + + if (dropdown) { + dropdown.hidden = true; + } + if (mobileNav) { + mobileNav.hidden = true; + } + + if (avatar && dropdown) { + avatar.addEventListener('click', function (event) { + event.stopPropagation(); + dropdown.hidden = !dropdown.hidden; + }); + } + + if (hamburger && mobileNav) { + hamburger.addEventListener('click', function (event) { + event.stopPropagation(); + mobileNav.hidden = !mobileNav.hidden; + hamburger.classList.toggle('open', !mobileNav.hidden); + }); + } + + document.addEventListener('click', function (event) { + if (dropdown && !event.target.closest('.user-menu')) { + dropdown.hidden = true; + } + if (mobileNav && !event.target.closest('.navbar') && !event.target.closest('.mobile-nav')) { + mobileNav.hidden = true; + if (hamburger) { + hamburger.classList.remove('open'); + } + } + }); + + document.querySelectorAll('[data-action="logout"]').forEach(function (button) { + button.addEventListener('click', function () { + window.CrankAuth.logout(); + }); + }); + + document.querySelectorAll('[data-action="profile"]').forEach(function (button) { + button.addEventListener('click', function () { + if (dropdown) { + dropdown.hidden = true; + } + window.location.href = ((window.CrankRoutes && window.CrankRoutes.settings) || '/settings') + '#profile'; + }); + }); + + document.querySelectorAll('[data-action="settings-link"]').forEach(function (button) { + button.addEventListener('click', function () { + if (dropdown) { + dropdown.hidden = true; + } + window.location.href = (window.CrankRoutes && window.CrankRoutes.settings) || '/settings'; + }); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initNav); + } else { + initNav(); + } +})(); diff --git a/apps/ui/js/overlay-loader.js b/apps/ui/js/overlay-loader.js new file mode 100644 index 0000000..dd08dea --- /dev/null +++ b/apps/ui/js/overlay-loader.js @@ -0,0 +1,75 @@ +(function() { + var manifestPromise = null; + var loadedScripts = Object.create(null); + + function overlayBasePath() { + return (window.APP_BASE || '/') + 'overlay/'; + } + + function fetchManifest() { + return fetch(overlayBasePath() + 'manifest.json', { cache: 'no-store' }) + .then(function(response) { + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error('overlay manifest request failed'); + } + return response.json(); + }) + .catch(function() { + return null; + }); + } + + function loadScript(path) { + if (loadedScripts[path]) { + return loadedScripts[path]; + } + loadedScripts[path] = new Promise(function(resolve, reject) { + var script = document.createElement('script'); + script.src = overlayBasePath() + path.replace(/^\/+/, ''); + script.async = false; + script.onload = function() { resolve(); }; + script.onerror = function() { reject(new Error('overlay script load failed')); }; + document.head.appendChild(script); + }).catch(function() { + return null; + }); + return loadedScripts[path]; + } + + function load() { + if (manifestPromise) { + return manifestPromise; + } + manifestPromise = fetchManifest().then(function(manifest) { + if (!(manifest && manifest.slots && window.CrankUiSlots)) { + return null; + } + var tasks = Object.entries(manifest.slots) + .filter(function(entry) { + return window.CrankUiSlots.isAllowed(entry[0]) && typeof entry[1] === 'string'; + }) + .map(function(entry) { + return loadScript(entry[1]); + }); + return Promise.all(tasks).then(function() { + return manifest; + }); + }); + return manifestPromise; + } + + async function render(root, context) { + await load(); + if (window.CrankUiSlots) { + window.CrankUiSlots.renderAll(root, context); + } + } + + window.CrankOverlay = { + load: load, + render: render, + }; +}()); diff --git a/apps/ui/js/secrets.js b/apps/ui/js/secrets.js new file mode 100644 index 0000000..2fecacf --- /dev/null +++ b/apps/ui/js/secrets.js @@ -0,0 +1,707 @@ +function initSecretsPage() { + var state = { + workspaceId: null, + secrets: [], + profiles: [], + derived: { + activeSecretCount: 0, + secretsById: {}, + usageBySecret: {}, + }, + search: '', + loading: false, + error: '', + modalMode: 'create', + modalSecretId: null, + }; + + var modal = document.getElementById('secret-modal'); + var tbody = document.getElementById('secrets-tbody'); + var cardList = document.getElementById('secrets-card-list'); + var profilesList = document.getElementById('auth-profiles-list'); + var summary = document.getElementById('secrets-summary'); + var profilesSummary = document.getElementById('secret-profiles-summary'); + var searchInput = document.getElementById('secret-search'); + var modalTitle = document.getElementById('secret-modal-title'); + var modalName = document.getElementById('secret-name'); + var modalKind = document.getElementById('secret-kind'); + var modalNote = document.getElementById('secret-modal-note'); + var modalSubmit = document.getElementById('secret-modal-submit-btn'); + var kindHint = document.getElementById('secret-kind-hint'); + var stringLabel = document.getElementById('secret-string-label'); + var stringField = document.getElementById('secret-string-value'); + var usernameField = document.getElementById('secret-username'); + var passwordField = document.getElementById('secret-password'); + var jsonField = document.getElementById('secret-json-value'); + + function tKey(key) { + return typeof t === 'function' ? t(key) : key; + } + + function tfKey(key, vars) { + return typeof tf === 'function' ? tf(key, vars) : tKey(key); + } + + function currentWorkspaceId() { + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + return workspace ? workspace.id : null; + } + + function currentWorkspace() { + return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + } + + function secretKindLabel(kind) { + return tKey('secrets.kind.' + String(kind || '').toLowerCase()); + } + + function authKindLabel(kind) { + return tKey('secrets.auth_kind.' + String(kind || '').toLowerCase()); + } + + function formatDate(value) { + if (!value) return '—'; + return new Date(value).toLocaleDateString(); + } + + function secretIdsForProfile(profile) { + var config = profile && profile.config ? profile.config : {}; + if (config.bearer) return [config.bearer.secret_id]; + if (config.basic) return [config.basic.username_secret_id, config.basic.password_secret_id]; + if (config.api_key_header) return [config.api_key_header.secret_id]; + if (config.api_key_query) return [config.api_key_query.secret_id]; + return []; + } + + function profileSummary(profile, secretsById) { + var config = profile && profile.config ? profile.config : {}; + if (config.bearer) { + return tfKey('secrets.profiles.summary_bearer', { + header: config.bearer.header_name, + secret: secretName(secretsById, config.bearer.secret_id), + }); + } + if (config.basic) { + return tfKey('secrets.profiles.summary_basic', { + username: secretName(secretsById, config.basic.username_secret_id), + password: secretName(secretsById, config.basic.password_secret_id), + }); + } + if (config.api_key_header) { + return tfKey('secrets.profiles.summary_api_key_header', { + header: config.api_key_header.header_name, + secret: secretName(secretsById, config.api_key_header.secret_id), + }); + } + if (config.api_key_query) { + return tfKey('secrets.profiles.summary_api_key_query', { + param: config.api_key_query.param_name, + secret: secretName(secretsById, config.api_key_query.secret_id), + }); + } + return tKey('secrets.profiles.summary_unknown'); + } + + function secretName(secretsById, secretId) { + var secret = secretsById[secretId]; + return secret ? secret.name : secretId; + } + + function buildUsageBySecret() { + var usage = {}; + state.profiles.forEach(function (profile) { + secretIdsForProfile(profile).forEach(function (secretId) { + if (!secretId) return; + if (!usage[secretId]) usage[secretId] = []; + usage[secretId].push(profile); + }); + }); + return usage; + } + + function recomputeDerivedState() { + var secretsById = {}; + var activeSecretCount = 0; + + state.secrets.forEach(function (secret) { + secretsById[secret.id] = secret; + if (secret.status === 'active') { + activeSecretCount += 1; + } + }); + + state.derived = { + activeSecretCount: activeSecretCount, + secretsById: secretsById, + usageBySecret: buildUsageBySecret(), + }; + } + + function resetModalFields() { + modalName.value = ''; + modalKind.value = 'token'; + stringField.value = ''; + usernameField.value = ''; + passwordField.value = ''; + jsonField.value = '{\n "value": ""\n}'; + } + + function openModal(mode, secret) { + state.modalMode = mode; + state.modalSecretId = secret ? secret.id : null; + resetModalFields(); + + if (mode === 'rotate' && secret) { + modalTitle.textContent = tKey('secrets.modal.rotate_title'); + modalName.value = secret.name; + modalName.disabled = true; + modalKind.value = secret.kind; + modalKind.disabled = true; + modalSubmit.textContent = tKey('secrets.modal.rotate_action'); + modalNote.textContent = tKey('secrets.modal.rotate_note'); + } else { + modalTitle.textContent = tKey('secrets.modal.create_title'); + modalName.disabled = false; + modalKind.disabled = false; + modalSubmit.textContent = tKey('secrets.modal.create_action'); + modalNote.textContent = tKey('secrets.modal.create_note'); + } + + updateKindFields(); + modal.classList.add('open'); + if (window.CrankDiagnostics && typeof window.CrankDiagnostics.ensureTestId === 'function') { + window.CrankDiagnostics.ensureTestId(modal, mode === 'rotate' ? 'secret-rotate-modal' : 'secret-create-modal'); + } + setTimeout(function () { + if (mode === 'rotate') { + stringField.focus(); + } else { + modalName.focus(); + } + }, 30); + } + + function closeModal() { + modal.classList.remove('open'); + state.modalMode = 'create'; + state.modalSecretId = null; + } + + function updateKindFields() { + var kind = modalKind.value; + document.querySelectorAll('[data-kind-field="string"]').forEach(function (element) { + element.hidden = !(kind === 'token' || kind === 'header'); + }); + document.querySelectorAll('[data-kind-field="basic"]').forEach(function (element) { + element.hidden = kind !== 'username_password'; + }); + document.querySelectorAll('[data-kind-field="json"]').forEach(function (element) { + element.hidden = kind !== 'generic'; + }); + + if (kind === 'token') { + stringLabel.textContent = tKey('secrets.modal.value'); + } else if (kind === 'header') { + stringLabel.textContent = tKey('secrets.modal.header_value'); + } else { + stringLabel.textContent = tKey('secrets.modal.value'); + } + kindHint.textContent = tKey('secrets.hint.' + kind); + } + + function buildSecretValue() { + var kind = modalKind.value; + if (kind === 'token' || kind === 'header') { + var value = stringField.value.trim(); + if (!value) { + throw new Error(tKey('secrets.validation.value_required')); + } + return value; + } + if (kind === 'username_password') { + var username = usernameField.value.trim(); + var password = passwordField.value; + if (!username || !password) { + throw new Error(tKey('secrets.validation.username_password_required')); + } + return { + username: username, + password: password, + }; + } + try { + return JSON.parse(jsonField.value); + } catch (_error) { + throw new Error(tKey('secrets.validation.json_invalid')); + } + } + + function renderSecrets() { + var usage = state.derived.usageBySecret; + var query = state.search.toLowerCase(); + var rows = state.secrets.filter(function (secret) { + return !query || secret.name.toLowerCase().includes(query) || String(secret.kind || '').toLowerCase().includes(query); + }); + + summary.textContent = tfKey('secrets.active.subtitle', { + active: state.derived.activeSecretCount, + total: state.secrets.length, + }); + + window.CrankDom.clear(tbody); + if (cardList) { + window.CrankDom.clear(cardList); + } + + if (state.loading && state.secrets.length === 0) { + var loadingRow = document.createElement('tr'); + var loadingCell = document.createElement('td'); + loadingCell.colSpan = 7; + loadingCell.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);'; + loadingCell.textContent = tKey('secrets.loading'); + loadingRow.appendChild(loadingCell); + tbody.appendChild(loadingRow); + renderSecretCards([], tKey('secrets.loading')); + return; + } + + if (state.error) { + var errorRow = document.createElement('tr'); + var errorCell = document.createElement('td'); + errorCell.colSpan = 7; + errorCell.style.cssText = 'text-align:center;padding:36px;color:var(--danger,#f85149);'; + errorCell.textContent = state.error; + errorRow.appendChild(errorCell); + tbody.appendChild(errorRow); + renderSecretCards([], state.error); + return; + } + + if (!rows.length) { + var emptyRow = document.createElement('tr'); + var emptyCell = document.createElement('td'); + emptyCell.colSpan = 7; + emptyCell.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);'; + emptyCell.textContent = state.secrets.length ? tKey('secrets.empty.search') : tKey('secrets.empty.none'); + emptyRow.appendChild(emptyCell); + tbody.appendChild(emptyRow); + renderSecretCards(rows); + return; + } + + rows.forEach(function (secret) { + var tr = document.createElement('tr'); + var references = usage[secret.id] || []; + + var nameCell = document.createElement('td'); + nameCell.className = 'col-name'; + nameCell.textContent = secret.name; + tr.appendChild(nameCell); + + var kindCell = document.createElement('td'); + kindCell.textContent = secretKindLabel(secret.kind); + tr.appendChild(kindCell); + + var versionCell = document.createElement('td'); + versionCell.className = 'col-mono'; + versionCell.textContent = 'v' + secret.current_version; + tr.appendChild(versionCell); + + var lastUsedCell = document.createElement('td'); + lastUsedCell.textContent = secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never'); + tr.appendChild(lastUsedCell); + + var usedByCell = document.createElement('td'); + usedByCell.textContent = window.tPlural('secrets.used_by_count', references.length, { count: references.length }); + tr.appendChild(usedByCell); + + var statusCell = document.createElement('td'); + var badge = document.createElement('span'); + badge.className = 'badge ' + (secret.status === 'disabled' ? 'badge-revoked' : 'badge-active'); + badge.textContent = tKey('secrets.status.' + secret.status); + statusCell.appendChild(badge); + tr.appendChild(statusCell); + + var actions = document.createElement('td'); + actions.className = 'col-actions'; + var rotateButton = document.createElement('button'); + rotateButton.className = 'icon-btn'; + rotateButton.textContent = tKey('secrets.action.rotate'); + rotateButton.setAttribute('data-testid', 'secret-rotate-action'); + rotateButton.setAttribute('data-secret-id', secret.id); + rotateButton.addEventListener('click', function () { + openModal('rotate', secret); + }); + actions.appendChild(rotateButton); + + var deleteButton = document.createElement('button'); + deleteButton.className = 'icon-btn danger'; + deleteButton.textContent = tKey('secrets.action.delete'); + deleteButton.setAttribute('data-testid', 'secret-delete-action'); + deleteButton.setAttribute('data-secret-id', secret.id); + deleteButton.addEventListener('click', async function () { + await deleteSecret(secret); + }); + actions.appendChild(deleteButton); + tr.appendChild(actions); + + tbody.appendChild(tr); + }); + + renderSecretCards(rows); + } + + function renderSecretCards(rows, statusText) { + if (!cardList) { + return; + } + + window.CrankDom.clear(cardList); + + if (statusText && !rows.length) { + var messageCard = document.createElement('div'); + messageCard.className = 'resource-card'; + var message = document.createElement('div'); + message.className = 'resource-meta-value'; + message.textContent = statusText; + message.style.color = state.error ? 'var(--danger,#f85149)' : 'var(--text-muted)'; + messageCard.appendChild(message); + cardList.appendChild(messageCard); + return; + } + + rows.forEach(function(secret) { + var references = state.derived.usageBySecret[secret.id] || []; + var card = document.createElement('div'); + card.className = 'resource-card'; + + var header = document.createElement('div'); + header.className = 'resource-card-header'; + var headerBody = document.createElement('div'); + var title = document.createElement('div'); + title.className = 'resource-card-title'; + title.textContent = secret.name; + var subtitle = document.createElement('div'); + subtitle.className = 'resource-card-subtitle'; + subtitle.textContent = secretKindLabel(secret.kind); + headerBody.appendChild(title); + headerBody.appendChild(subtitle); + + var actions = document.createElement('div'); + actions.className = 'resource-card-actions'; + var badge = document.createElement('span'); + badge.className = 'badge ' + (secret.status === 'disabled' ? 'badge-revoked' : 'badge-active'); + badge.textContent = tKey('secrets.status.' + secret.status); + actions.appendChild(badge); + header.appendChild(headerBody); + header.appendChild(actions); + card.appendChild(header); + + var metaGrid = document.createElement('div'); + metaGrid.className = 'resource-meta-grid'; + metaGrid.appendChild(buildSecretMetaItem('secrets.th.version', 'v' + secret.current_version)); + metaGrid.appendChild(buildSecretMetaItem( + 'secrets.th.last_used', + secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never') + )); + metaGrid.appendChild(buildSecretMetaItem( + 'secrets.th.used_by', + window.tPlural('secrets.used_by_count', references.length, { count: references.length }) + )); + card.appendChild(metaGrid); + + var actionRow = document.createElement('div'); + actionRow.className = 'resource-card-actions'; + var rotateButton = document.createElement('button'); + rotateButton.className = 'btn-secondary'; + rotateButton.type = 'button'; + rotateButton.textContent = tKey('secrets.action.rotate'); + rotateButton.addEventListener('click', function() { + openModal('rotate', secret); + }); + actionRow.appendChild(rotateButton); + + var deleteButton = document.createElement('button'); + deleteButton.className = 'btn-secondary'; + deleteButton.type = 'button'; + deleteButton.textContent = tKey('secrets.action.delete'); + deleteButton.addEventListener('click', async function() { + await deleteSecret(secret); + }); + actionRow.appendChild(deleteButton); + card.appendChild(actionRow); + + cardList.appendChild(card); + }); + } + + function buildSecretMetaItem(labelKey, valueText) { + var item = document.createElement('div'); + item.className = 'resource-meta-item'; + var label = document.createElement('div'); + label.className = 'resource-meta-label'; + label.textContent = tKey(labelKey); + var value = document.createElement('div'); + value.className = 'resource-meta-value'; + value.textContent = valueText; + item.appendChild(label); + item.appendChild(value); + return item; + } + + function renderProfiles() { + var usage = state.derived.usageBySecret; + var secretsById = state.derived.secretsById; + + profilesSummary.textContent = window.tPlural( + 'secrets.profiles.subtitle_count', + state.profiles.length, + { count: state.profiles.length } + ); + + window.CrankDom.clear(profilesList); + + if (state.error) { + profilesList.appendChild(window.CrankDom.createEmptyState( + tKey('secrets.profiles.error_title'), + state.error + )); + return; + } + + if (state.loading && state.profiles.length === 0) { + profilesList.appendChild(window.CrankDom.createEmptyState( + tKey('secrets.profiles.loading_title'), + tKey('secrets.profiles.loading_body') + )); + return; + } + + if (!state.profiles.length) { + profilesList.appendChild(window.CrankDom.createEmptyState( + tKey('secrets.profiles.empty_title'), + tKey('secrets.profiles.empty_body') + )); + return; + } + + state.profiles.forEach(function (profile) { + var secretIds = secretIdsForProfile(profile); + var card = document.createElement('div'); + card.className = 'resource-card'; + + var header = document.createElement('div'); + header.className = 'resource-card-header'; + var headerBody = document.createElement('div'); + + var title = document.createElement('div'); + title.className = 'resource-card-title'; + title.textContent = profile.name; + headerBody.appendChild(title); + + var subtitle = document.createElement('div'); + subtitle.className = 'resource-card-subtitle'; + subtitle.textContent = profileSummary(profile, secretsById); + headerBody.appendChild(subtitle); + + var pillRow = document.createElement('div'); + pillRow.className = 'resource-pill-row'; + var statusPill = document.createElement('span'); + statusPill.className = 'resource-status-pill active'; + statusPill.textContent = authKindLabel(profile.kind); + pillRow.appendChild(statusPill); + headerBody.appendChild(pillRow); + header.appendChild(headerBody); + card.appendChild(header); + + var metaGrid = document.createElement('div'); + metaGrid.className = 'resource-meta-grid'; + [ + [tKey('secrets.profiles.meta.created'), formatDate(profile.created_at)], + [tKey('secrets.profiles.meta.updated'), formatDate(profile.updated_at)] + ].forEach(function(entry) { + var item = document.createElement('div'); + item.className = 'resource-meta-item'; + var label = document.createElement('div'); + label.className = 'resource-meta-label'; + label.textContent = entry[0]; + var value = document.createElement('div'); + value.className = 'resource-meta-value'; + value.textContent = entry[1]; + item.appendChild(label); + item.appendChild(value); + metaGrid.appendChild(item); + }); + card.appendChild(metaGrid); + + var detail = document.createElement('div'); + detail.className = 'resource-detail-block'; + var detailTitle = document.createElement('div'); + detailTitle.className = 'resource-detail-title'; + detailTitle.textContent = tKey('secrets.profiles.references'); + detail.appendChild(detailTitle); + + var refList = document.createElement('div'); + refList.className = 'secret-ref-list'; + secretIds.forEach(function(secretId) { + var users = usage[secretId] || []; + var pill = document.createElement('span'); + pill.className = 'secret-ref-pill'; + var strong = document.createElement('strong'); + strong.textContent = secretName(secretsById, secretId); + var count = document.createElement('span'); + count.textContent = tfKey('secrets.profiles.reference_count', { count: users.length }); + pill.appendChild(strong); + pill.appendChild(count); + refList.appendChild(pill); + }); + detail.appendChild(refList); + card.appendChild(detail); + + profilesList.appendChild(card); + }); + } + + async function load() { + state.workspaceId = currentWorkspaceId(); + state.loading = true; + state.error = ''; + renderSecrets(); + renderProfiles(); + + if (!state.workspaceId) { + state.secrets = []; + state.profiles = []; + recomputeDerivedState(); + state.loading = false; + state.error = tKey('secrets.error.workspace'); + renderSecrets(); + renderProfiles(); + return; + } + + try { + var results = await Promise.all([ + window.CrankApi.listSecrets(state.workspaceId), + window.CrankApi.listAuthProfiles(state.workspaceId), + ]); + state.secrets = (results[0] && results[0].items) || []; + state.profiles = (results[1] && results[1].items) || []; + recomputeDerivedState(); + } catch (error) { + state.error = error.message || tKey('secrets.error.load'); + state.secrets = []; + state.profiles = []; + recomputeDerivedState(); + } finally { + state.loading = false; + renderSecrets(); + renderProfiles(); + } + } + + async function deleteSecret(secret) { + if (!confirm(tfKey('secrets.confirm.delete', { name: secret.name }))) { + return; + } + try { + await window.CrankApi.deleteSecret(state.workspaceId, secret.id); + await load(); + if (window.CrankUi) { + window.CrankUi.success( + tfKey('secrets.toast.delete_message', { name: secret.name }), + tKey('secrets.toast.delete_title') + ); + } + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || tKey('secrets.toast.delete_error_message'), + tKey('secrets.toast.delete_error_title') + ); + } + } + } + + async function submitModal() { + var kind = modalKind.value; + var value = buildSecretValue(); + modalSubmit.disabled = true; + modalSubmit.textContent = state.modalMode === 'rotate' + ? tKey('secrets.modal.rotating') + : tKey('secrets.modal.creating'); + + try { + if (state.modalMode === 'rotate') { + await window.CrankApi.rotateSecret(state.workspaceId, state.modalSecretId, { value: value }); + if (window.CrankUi) { + window.CrankUi.success( + tKey('secrets.toast.rotate_message'), + tKey('secrets.toast.rotate_title') + ); + } + } else { + var name = modalName.value.trim(); + if (!name) { + throw new Error(tKey('secrets.validation.name_required')); + } + await window.CrankApi.createSecret(state.workspaceId, { + name: name, + kind: kind, + value: value, + }); + if (window.CrankUi) { + window.CrankUi.success( + tKey('secrets.toast.create_message'), + tKey('secrets.toast.create_title') + ); + } + } + closeModal(); + await load(); + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error( + error.message || tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_message' : 'secrets.toast.create_error_message'), + tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_title' : 'secrets.toast.create_error_title') + ); + } + } finally { + modalSubmit.disabled = false; + modalSubmit.textContent = state.modalMode === 'rotate' + ? tKey('secrets.modal.rotate_action') + : tKey('secrets.modal.create_action'); + } + } + + document.getElementById('btn-create-secret').addEventListener('click', function () { + openModal('create'); + }); + document.getElementById('secret-modal-close-btn').addEventListener('click', closeModal); + document.getElementById('secret-modal-cancel-btn').addEventListener('click', closeModal); + modal.addEventListener('click', function (event) { + if (event.target === modal) { + closeModal(); + } + }); + modalKind.addEventListener('change', updateKindFields); + modalSubmit.addEventListener('click', function () { + void submitModal(); + }); + searchInput.addEventListener('input', function (event) { + state.search = event.target.value || ''; + renderSecrets(); + }); + document.addEventListener('workspace:changed', function () { + void load(); + }); + + updateKindFields(); + void load(); +} + +if (window.CrankDiagnostics && typeof window.CrankDiagnostics.bootstrap === 'function') { + window.CrankDiagnostics.bootstrap('secrets', initSecretsPage); +} else { + document.addEventListener('DOMContentLoaded', initSecretsPage); +} diff --git a/apps/ui/js/settings.js b/apps/ui/js/settings.js new file mode 100644 index 0000000..e987e36 --- /dev/null +++ b/apps/ui/js/settings.js @@ -0,0 +1,447 @@ +var settingsSession = null; +var settingsCapabilities = null; +function tKey(key) { + return typeof t === 'function' ? t(key) : key; +} +function tfKey(key, vars) { + return typeof tf === 'function' ? tf(key, vars) : key; +} + +function settingsWorkspaceId() { + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + return workspace ? workspace.id : null; +} + +function initials(displayName, email) { + var source = displayName || email || 'Crank'; + return source + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map(function(part) { return part.charAt(0).toUpperCase(); }) + .join('') || 'CR'; +} + +function setStatus(id, text, isError) { + var node = document.getElementById(id); + if (!node) return; + node.textContent = text || ''; + node.style.color = text + ? (isError ? 'var(--red)' : 'var(--green)') + : 'var(--text-muted)'; +} + +function titleCaseRole(role) { + var normalized = String(role || 'viewer').toLowerCase(); + if (normalized === 'operator') return tKey('workspace_setup.role.operator'); + if (normalized === 'owner') return tKey('workspace_setup.role.owner'); + if (normalized === 'admin') return tKey('workspace_setup.role.admin'); + if (normalized === 'viewer') return tKey('workspace_setup.role.viewer'); + return normalized.replace(/^\w/, function(character) { + return character.toUpperCase(); + }); +} + +function populateCurrentSession(session) { + var summary = document.getElementById('settings-session-summary'); + if (!summary) { + return; + } + + if (!(session && session.user)) { + summary.textContent = tKey('settings.session.unavailable'); + return; + } + + var membership = null; + if (Array.isArray(session.memberships)) { + membership = session.memberships.find(function(item) { + return item.workspace && item.workspace.id === session.current_workspace_id; + }) || session.memberships[0] || null; + } + + var workspaceLabel = membership && membership.workspace + ? (membership.workspace.display_name || membership.workspace.slug || membership.workspace.id) + : tKey('settings.session.current_workspace'); + var roleLabel = membership ? titleCaseRole(membership.role) : tKey('workspace_setup.role.viewer'); + summary.textContent = tfKey('settings.session.summary', { + email: session.user.email, + role: roleLabel, + workspace: workspaceLabel, + }); +} + +function capabilityLabel(key) { + return tKey('settings.capability.' + key); +} + +function renderWorkspaceProtocolOptions(capabilities, selectedProtocol) { + var select = document.getElementById('settings-ws-protocol'); + if (!select) { + return; + } + + var supportedProtocols = capabilities && Array.isArray(capabilities.supported_protocols) && capabilities.supported_protocols.length + ? capabilities.supported_protocols + : ['rest']; + var fallbackProtocol = supportedProtocols.indexOf('rest') !== -1 ? 'rest' : supportedProtocols[0]; + var effectiveSelection = supportedProtocols.indexOf(selectedProtocol) !== -1 + ? selectedProtocol + : fallbackProtocol; + + select.innerHTML = ''; + supportedProtocols.forEach(function (protocol) { + var option = document.createElement('option'); + option.value = protocol; + option.textContent = capabilityLabel(protocol); + option.selected = protocol === effectiveSelection; + select.appendChild(option); + }); +} + +function populateWorkspaceCapabilityHint(capabilities) { + var body = document.getElementById('settings-ws-protocol-hint'); + if (!body || !capabilities) { + return; + } + + var protocols = (capabilities.supported_protocols || []).map(capabilityLabel).join(', '); + body.textContent = tfKey('settings.ws.protocol_hint_capabilities', { + protocols: protocols || capabilityLabel('none'), + }); +} + +function populateNotificationsCapabilityHint(capabilities) { + var title = document.getElementById('settings-notifications-capability-title'); + var body = document.getElementById('settings-notifications-capability-body'); + if (!title || !body || !capabilities) { + return; + } + + title.textContent = tKey('settings.notifications.capability_title_' + capabilities.edition); + body.textContent = tfKey('settings.notifications.capability_body_' + capabilities.edition, { + edition: capabilities.edition, + }); +} + +function populateCapabilities(capabilities) { + var title = document.getElementById('settings-security-capability-title'); + var body = document.getElementById('settings-security-capability-body'); + if (!title || !body || !capabilities) { + return; + } + + var protocols = (capabilities.supported_protocols || []).map(capabilityLabel).join(', '); + var accessModes = (capabilities.machine_access_modes || []).map(capabilityLabel).join(', '); + var securityLevels = (capabilities.supported_security_levels || []).map(capabilityLabel).join(', '); + var limits = capabilities.limits || {}; + + title.textContent = tKey('settings.security.capability_title_' + capabilities.edition); + body.textContent = tfKey('settings.security.capability_summary', { + protocols: protocols || capabilityLabel('none'), + access_modes: accessModes || capabilityLabel('none'), + security_levels: securityLevels || capabilityLabel('none'), + max_workspaces: limits.max_workspaces == null ? capabilityLabel('unlimited') : String(limits.max_workspaces), + max_users: limits.max_users_per_workspace == null ? capabilityLabel('unlimited') : String(limits.max_users_per_workspace), + max_agents: limits.max_agents_per_workspace == null ? capabilityLabel('unlimited') : String(limits.max_agents_per_workspace), + }); + populateWorkspaceCapabilityHint(capabilities); + populateNotificationsCapabilityHint(capabilities); + renderWorkspaceProtocolOptions(capabilities, document.getElementById('settings-ws-protocol').value || 'rest'); +} + +function populateProfile(session) { + if (!session || !session.user) { + return; + } + + var user = session.user; + document.getElementById('profile-avatar').textContent = initials(user.display_name, user.email); + document.getElementById('profile-display-name').textContent = user.display_name || 'Crank'; + document.getElementById('profile-email').textContent = user.email || ''; + + var firstName = document.getElementById('field-firstname'); + var email = document.getElementById('field-email'); + if (firstName) { + firstName.value = user.display_name || ''; + } + if (email) { + email.value = user.email || ''; + } + + populateCurrentSession(session); +} + +async function loadProfile() { + if (!window.CrankAuth || !window.CrankApi) { + return; + } + + settingsSession = await window.CrankAuth.fetchSession(true); + populateProfile(settingsSession); +} + +async function loadCapabilities() { + if (!window.CrankApi || typeof window.CrankApi.getCapabilities !== 'function') { + return; + } + + try { + settingsCapabilities = await window.CrankApi.getCapabilities(); + populateCapabilities(settingsCapabilities); + } catch (_error) { + } +} + +async function renderOverlaySlots() { + if (!window.CrankOverlay || typeof window.CrankOverlay.render !== 'function') { + return; + } + + await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve()); + await window.CrankOverlay.render(document, { + workspace: window.getCurrentWorkspace ? window.getCurrentWorkspace() : null, + capabilities: settingsCapabilities, + locale: localStorage.getItem('crank_lang') || 'en', + }); +} + +function bindProfileSave() { + var button = document.getElementById('settings-profile-save-btn'); + if (!button || button.dataset.bound === 'true') { + return; + } + button.dataset.bound = 'true'; + + button.addEventListener('click', async function() { + var original = button.textContent; + button.disabled = true; + button.textContent = tKey('settings.profile.saving'); + setStatus('settings-profile-status', '', false); + + try { + var session = await window.CrankApi.updateProfile({ + display_name: document.getElementById('field-firstname').value.trim(), + email: document.getElementById('field-email').value.trim(), + }); + settingsSession = session; + if (window.CrankAuth && typeof window.CrankAuth.replaceSession === 'function') { + window.CrankAuth.replaceSession(session); + } + populateProfile(session); + setStatus('settings-profile-status', tKey('settings.profile.saved_status'), false); + button.textContent = tKey('settings.profile.saved'); + } catch (error) { + setStatus('settings-profile-status', error.message || tKey('settings.profile.save_error'), true); + button.textContent = original; + } finally { + setTimeout(function() { + button.disabled = false; + button.textContent = original; + }, 1200); + } + }); +} + +function bindPasswordSave() { + var button = document.getElementById('settings-password-save-btn'); + if (!button || button.dataset.bound === 'true') { + return; + } + button.dataset.bound = 'true'; + + button.addEventListener('click', async function() { + var currentPassword = document.getElementById('security-current-password').value; + var newPassword = document.getElementById('security-new-password').value; + var confirmPassword = document.getElementById('security-confirm-password').value; + + if (newPassword !== confirmPassword) { + setStatus('settings-password-status', tKey('settings.security.mismatch'), true); + return; + } + + var original = button.textContent; + button.disabled = true; + button.textContent = tKey('settings.profile.saving'); + setStatus('settings-password-status', '', false); + + try { + await window.CrankApi.changePassword({ + current_password: currentPassword, + new_password: newPassword, + }); + document.getElementById('security-current-password').value = ''; + document.getElementById('security-new-password').value = ''; + document.getElementById('security-confirm-password').value = ''; + setStatus('settings-password-status', tKey('settings.security.saved'), false); + button.textContent = tKey('settings.profile.saved'); + } catch (error) { + setStatus('settings-password-status', error.message || tKey('settings.security.save_error'), true); + button.textContent = original; + } finally { + setTimeout(function() { + button.disabled = false; + button.textContent = original; + }, 1200); + } + }); +} + +function injectLanguageSwitcher() { + var profileSection = document.getElementById('section-profile'); + if (!profileSection) { + return; + } + + var cardBody = profileSection.querySelector('.section-card-body'); + if (!cardBody) { + return; + } + + fetch('html/fragments/lang-switcher.html') + .then(function(response) { return response.text(); }) + .then(function(html) { + var template = document.createElement('template'); + template.innerHTML = html; + var fragment = template.content.cloneNode(true); + var saveRow = cardBody.querySelector('div[style*="justify-content:flex-end"]'); + if (saveRow) { + saveRow.parentNode.insertBefore(fragment, saveRow); + } else { + cardBody.appendChild(fragment); + } + if (typeof applyLang === 'function') { + applyLang(); + } + }); +} + +function bindSectionNavigation() { + var navItems = document.querySelectorAll('.settings-nav-item[data-section]'); + navItems.forEach(function (button) { + button.addEventListener('click', function () { + navItems.forEach(function (item) { item.classList.remove('active'); }); + this.classList.add('active'); + var target = document.getElementById('section-' + this.dataset.section); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); + + var initialSection = window.location.hash ? window.location.hash.slice(1) : 'profile'; + var initialButton = document.querySelector('.settings-nav-item[data-section="' + initialSection + '"]') + || document.querySelector('.settings-nav-item[data-section="profile"]'); + if (initialButton) { + setTimeout(function () { initialButton.click(); }, 50); + } + + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) { + return; + } + var section = entry.target.id.replace('section-', ''); + document.querySelectorAll('.settings-nav-item[data-section]').forEach(function (button) { + button.classList.toggle('active', button.dataset.section === section); + }); + }); + }, { threshold: 0.3 }); + + document.querySelectorAll('[id^="section-"]').forEach(function (section) { + if (!section.hidden) { + observer.observe(section); + } + }); +} + +async function loadWorkspaceSettings() { + if (!window.CrankApi || !window.whenWorkspacesReady) { + return; + } + + await window.whenWorkspacesReady(); + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + if (!workspace) { + return; + } + + try { + var record = await window.CrankApi.getWorkspace(workspace.id); + var item = record.workspace; + var settings = item.settings || {}; + + document.getElementById('settings-ws-slug').value = item.slug || ''; + document.getElementById('settings-ws-display-name').value = item.display_name || ''; + document.getElementById('settings-ws-description').value = settings.description || ''; + document.getElementById('settings-ws-timezone').value = settings.timezone || 'UTC'; + renderWorkspaceProtocolOptions(settingsCapabilities, settings.default_protocol || 'rest'); + + document.querySelectorAll('#section-workspace .lang-btn').forEach(function (button) { + button.classList.toggle('active', button.dataset.lang === (settings.language || (localStorage.getItem('crank_lang') || 'en'))); + }); + + var saveButton = document.getElementById('settings-ws-save-btn'); + if (saveButton.dataset.bound === 'true') { + return; + } + saveButton.dataset.bound = 'true'; + + saveButton.addEventListener('click', async function () { + var saveButton = this; + var original = saveButton.textContent; + saveButton.disabled = true; + saveButton.textContent = tKey('settings.workspace.saving'); + + try { + await window.CrankApi.updateWorkspace(item.id, { + slug: document.getElementById('settings-ws-slug').value.trim(), + display_name: document.getElementById('settings-ws-display-name').value.trim(), + settings: { + description: document.getElementById('settings-ws-description').value.trim(), + timezone: document.getElementById('settings-ws-timezone').value, + default_protocol: document.getElementById('settings-ws-protocol').value, + language: localStorage.getItem('crank_lang') || 'en', + color: (workspace.settings && workspace.settings.color) || '#0d9488', + }, + }); + + if (window.refreshWorkspaces) { + var workspaces = await window.refreshWorkspaces(); + var updated = workspaces.find(function (entry) { return entry.id === item.id; }); + if (updated && window.setCurrentWorkspace) { + await window.setCurrentWorkspace(updated); + } + } + + saveButton.textContent = tKey('settings.workspace.saved'); + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error(error.message || tKey('settings.workspace.save_error'), tKey('settings.workspace.save_error_title')); + } + saveButton.textContent = original; + } finally { + setTimeout(function () { + saveButton.disabled = false; + saveButton.textContent = original; + }, 1200); + } + }); + } catch (_error) { + } +} + +async function initSettingsPage() { + injectLanguageSwitcher(); + bindSectionNavigation(); + await loadProfile(); + await loadCapabilities(); + bindProfileSave(); + bindPasswordSave(); + await loadWorkspaceSettings(); + await renderOverlaySlots(); +} + +document.addEventListener('DOMContentLoaded', function () { + void initSettingsPage(); +}); diff --git a/apps/ui/js/slot-registry.js b/apps/ui/js/slot-registry.js new file mode 100644 index 0000000..065edbb --- /dev/null +++ b/apps/ui/js/slot-registry.js @@ -0,0 +1,55 @@ +(function() { + var allowedSlots = { + 'settings.sso_panel': true, + 'settings.totp_panel': true, + 'settings.audit_panel': true, + 'settings.billing_panel': true, + 'wizard.protocol_cards.graphql': true, + 'wizard.protocol_cards.grpc': true, + 'wizard.protocol_cards.soap': true, + 'wizard.protocol_cards.websocket': true, + }; + + var handlers = Object.create(null); + + function noop() {} + + function isAllowed(slotId) { + return !!allowedSlots[slotId]; + } + + function register(slotId, render) { + if (!isAllowed(slotId) || typeof render !== 'function') { + return false; + } + handlers[slotId] = render; + return true; + } + + function renderInto(target, slotId, context) { + if (!(target && slotId && isAllowed(slotId))) { + return; + } + var handler = handlers[slotId] || noop; + handler(target, context || {}); + } + + function renderAll(root, context) { + var scope = root || document; + scope.querySelectorAll('[data-slot]').forEach(function(target) { + renderInto(target, target.getAttribute('data-slot'), context); + }); + } + + function knownSlots() { + return Object.keys(allowedSlots); + } + + window.CrankUiSlots = { + isAllowed: isAllowed, + knownSlots: knownSlots, + register: register, + renderAll: renderAll, + renderInto: renderInto, + }; +}()); diff --git a/apps/ui/js/ui-feedback.js b/apps/ui/js/ui-feedback.js new file mode 100644 index 0000000..c76a9b5 --- /dev/null +++ b/apps/ui/js/ui-feedback.js @@ -0,0 +1,105 @@ +(function() { + var CONTAINER_ID = 'crank-toast-container'; + var nextToastId = 0; + + function ensureContainer() { + var container = document.getElementById(CONTAINER_ID); + if (container) return container; + + container = document.createElement('div'); + container.id = CONTAINER_ID; + container.className = 'toast-stack'; + document.body.appendChild(container); + return container; + } + + function dismiss(toast) { + if (!toast || !toast.parentNode) return; + toast.classList.add('closing'); + window.setTimeout(function() { + if (toast.parentNode) toast.parentNode.removeChild(toast); + }, 180); + } + + function notify(options) { + var config = options || {}; + var duration = config.duration === 0 ? 0 : (config.duration || 3600); + var toast = document.createElement('div'); + var copy = document.createElement('div'); + var close = document.createElement('button'); + var closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + var lineA = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + var lineB = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + + toast.id = 'toast_' + (++nextToastId); + toast.className = 'toast-card toast-' + (config.type || 'info'); + copy.className = 'toast-copy'; + if (config.title) { + var title = document.createElement('div'); + title.className = 'toast-title'; + title.textContent = config.title; + copy.appendChild(title); + } + if (config.message) { + var message = document.createElement('div'); + message.className = 'toast-message'; + message.textContent = config.message; + copy.appendChild(message); + } + + close.className = 'toast-close'; + close.type = 'button'; + close.setAttribute('aria-label', 'Dismiss'); + + closeIcon.setAttribute('width', '12'); + closeIcon.setAttribute('height', '12'); + closeIcon.setAttribute('viewBox', '0 0 12 12'); + closeIcon.setAttribute('fill', 'none'); + closeIcon.setAttribute('stroke', 'currentColor'); + closeIcon.setAttribute('stroke-width', '1.8'); + closeIcon.setAttribute('stroke-linecap', 'round'); + + lineA.setAttribute('x1', '1'); + lineA.setAttribute('y1', '1'); + lineA.setAttribute('x2', '11'); + lineA.setAttribute('y2', '11'); + lineB.setAttribute('x1', '11'); + lineB.setAttribute('y1', '1'); + lineB.setAttribute('x2', '1'); + lineB.setAttribute('y2', '11'); + + closeIcon.appendChild(lineA); + closeIcon.appendChild(lineB); + close.appendChild(closeIcon); + + toast.appendChild(copy); + toast.appendChild(close); + + close.addEventListener('click', function() { + dismiss(toast); + }); + + ensureContainer().appendChild(toast); + + if (duration > 0) { + window.setTimeout(function() { + dismiss(toast); + }, duration); + } + + return toast.id; + } + + window.CrankUi = { + notify: notify, + success: function(message, title) { + return notify({ type: 'success', title: title || 'Done', message: message }); + }, + error: function(message, title) { + return notify({ type: 'error', title: title || 'Request failed', message: message }); + }, + info: function(message, title) { + return notify({ type: 'info', title: title || 'Info', message: message }); + } + }; +}()); diff --git a/apps/ui/js/usage.js b/apps/ui/js/usage.js new file mode 100644 index 0000000..fc91401 --- /dev/null +++ b/apps/ui/js/usage.js @@ -0,0 +1,549 @@ +document.addEventListener('DOMContentLoaded', function () { + var state = { + period: '7d', + workspaceId: null, + usage: null, + derived: { + localizedOperations: [], + normalizedTimeline: [], + timelineMaxValue: 0, + totalCalls: 0, + }, + loading: false, + loadError: '', + }; + + var periodSelect = document.getElementById('period'); + var exportBtn = document.querySelector('.page-header-actions .btn-secondary'); + var chartWrap = document.getElementById('chart-bars'); + var tableBody = document.getElementById('usage-tbody'); + var cardList = document.getElementById('usage-card-list'); + var chartTemplate = document.getElementById('tmpl-chart-bar'); + var rowTemplate = document.getElementById('tmpl-usage-row'); + var subtitle = document.querySelector('.section-card-subtitle'); + var statCards = document.querySelectorAll('.stats-grid .stat-card'); + + function tKey(key) { + return window.t ? t(key) : key; + } + + function tfKey(key, vars) { + return window.tf ? tf(key, vars) : tKey(key); + } + + function currentLocale() { + return localStorage.getItem('crank_lang') === 'ru' ? 'ru-RU' : 'en-US'; + } + + function currentWorkspaceId() { + var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; + return workspace ? workspace.id : null; + } + + function element(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; + } + + function formatCount(value) { + return Number(value || 0).toLocaleString(currentLocale()); + } + + function formatMs(value) { + if (!value) { + return '0ms'; + } + if (value >= 1000) { + return (value / 1000).toFixed(1) + 's'; + } + return value + 'ms'; + } + + function periodLabel(period) { + return tKey('usage.period.label.' + period); + } + + function protocolColor(protocol) { + return 'var(--blue)'; + } + + function protocolLabel(protocol) { + return 'REST'; + } + + function localizedUsageOperation(operation) { + if (!window.localizeDemoOperation) { + return operation; + } + + return window.localizeDemoOperation({ + name: operation.operation_name, + operation_display_name: operation.operation_display_name, + description: operation.operation_description || null, + }); + } + + function chartBucketLabel(timestamp, period, index) { + var date = new Date(timestamp); + if (period === '7d') { + return date.toLocaleDateString(currentLocale(), { weekday: 'short' }); + } + if (period === '90d') { + return date.toLocaleDateString(currentLocale(), { month: 'short' }); + } + return tfKey('usage.chart.week', { index: index + 1 }); + } + + function toUtcBucketStart(date, period) { + var bucket = new Date(date.getTime()); + bucket.setUTCMilliseconds(0); + bucket.setUTCSeconds(0); + bucket.setUTCMinutes(0); + bucket.setUTCHours(0); + + if (period === '30d' || period === 'this_month') { + var day = bucket.getUTCDay(); + var offset = day === 0 ? 6 : (day - 1); + bucket.setUTCDate(bucket.getUTCDate() - offset); + return bucket; + } + + if (period === '90d') { + bucket.setUTCDate(1); + return bucket; + } + + return bucket; + } + + function shiftUtcBucket(date, period, amount) { + var shifted = new Date(date.getTime()); + if (period === '30d' || period === 'this_month') { + shifted.setUTCDate(shifted.getUTCDate() + (amount * 7)); + return shifted; + } + if (period === '90d') { + shifted.setUTCMonth(shifted.getUTCMonth() + amount); + return shifted; + } + shifted.setUTCDate(shifted.getUTCDate() + amount); + return shifted; + } + + function bucketKey(date) { + return date.toISOString().slice(0, 19) + 'Z'; + } + + function expectedBucketCount(period, endBucket) { + if (period === '7d') { + return 7; + } + if (period === '30d') { + return 5; + } + if (period === '90d') { + return 4; + } + if (period === 'this_month') { + var firstDay = new Date(Date.UTC(endBucket.getUTCFullYear(), endBucket.getUTCMonth(), 1)); + var firstWeek = toUtcBucketStart(firstDay, period); + var diffMs = endBucket.getTime() - firstWeek.getTime(); + return Math.max(1, Math.floor(diffMs / (7 * 24 * 60 * 60 * 1000)) + 1); + } + return 7; + } + + function normalizeTimeline(timeline, period) { + if (!timeline.length) { + return []; + } + + var byBucket = {}; + timeline.forEach(function(point) { + byBucket[point.bucket_start] = point; + }); + + var latestBucket = timeline.reduce(function(current, point) { + return point.bucket_start > current.bucket_start ? point : current; + }, timeline[0]); + var endBucket = toUtcBucketStart(new Date(latestBucket.bucket_start), period); + var bucketCount = expectedBucketCount(period, endBucket); + var startBucket = shiftUtcBucket(endBucket, period, -(bucketCount - 1)); + var normalized = []; + + for (var index = 0; index < bucketCount; index += 1) { + var bucketDate = shiftUtcBucket(startBucket, period, index); + var key = bucketKey(bucketDate); + normalized.push(byBucket[key] || { + bucket_start: key, + calls_ok: 0, + calls_error: 0, + }); + } + + return normalized; + } + + function recomputeDerivedState() { + var operations = state.usage ? (state.usage.operations || []) : []; + var timeline = state.usage ? normalizeTimeline(state.usage.timeline || [], state.period) : []; + var totalCalls = operations.reduce(function (sum, operation) { + return sum + operation.calls_total; + }, 0); + var timelineMaxValue = timeline.reduce(function (maxValue, point) { + return Math.max(maxValue, point.calls_ok + point.calls_error); + }, 0); + + state.derived = { + localizedOperations: operations.map(function (operation) { + return { + operation: operation, + localized: localizedUsageOperation(operation), + }; + }), + normalizedTimeline: timeline, + timelineMaxValue: timelineMaxValue, + totalCalls: totalCalls, + }; + } + + function buildEmptyState(title, message, compact) { + var empty = element('div', 'empty-state'); + if (compact) { + empty.style.padding = '0'; + } + empty.appendChild(element('div', 'empty-state-title', title)); + empty.appendChild(element('div', 'empty-state-text', message)); + return empty; + } + + function renderEmpty(title, message) { + chartWrap.innerHTML = ''; + var chartEmpty = buildEmptyState(title, message, false); + chartEmpty.style.width = '100%'; + chartWrap.appendChild(chartEmpty); + + tableBody.innerHTML = ''; + var row = document.createElement('tr'); + var cell = document.createElement('td'); + cell.colSpan = 7; + cell.style.padding = '24px'; + cell.appendChild(buildEmptyState(title, message, true)); + row.appendChild(cell); + tableBody.appendChild(row); + } + + function renderStats() { + var summary = state.usage ? state.usage.summary : null; + var cards = [ + { + value: formatCount(summary ? summary.rollup.calls_total : 0), + text: tfKey('usage.stats.across', { period: periodLabel(state.period) }), + }, + { + value: ((summary ? summary.success_rate : 0) || 0).toFixed(1) + '%', + text: tfKey('usage.stats.success_calls', { count: formatCount(summary ? summary.rollup.calls_ok : 0) }), + }, + { + value: formatMs(summary ? summary.rollup.p50_ms : 0), + text: tKey('usage.stats.median'), + }, + { + value: formatMs(summary ? summary.rollup.p99_ms : 0), + text: tfKey('usage.stats.error_calls', { count: formatCount(summary ? summary.rollup.calls_error : 0) }), + } + ]; + + cards.forEach(function (card, index) { + var node = statCards[index]; + if (!node) { + return; + } + node.querySelector('.stat-value').textContent = card.value; + var delta = node.querySelector('.stat-delta'); + delta.className = 'stat-delta'; + delta.textContent = card.text; + }); + } + + function renderChart() { + var timeline = state.derived.normalizedTimeline; + chartWrap.innerHTML = ''; + + if (!timeline.length) { + var chartEmpty = buildEmptyState( + tKey('usage.chart.empty.title'), + tKey('usage.chart.empty.sub'), + false + ); + chartEmpty.style.width = '100%'; + chartWrap.appendChild(chartEmpty); + return; + } + + var maxValue = state.derived.timelineMaxValue; + + timeline.forEach(function (point, index) { + var node = chartTemplate.content.cloneNode(true); + var okHeight = maxValue ? Math.round((point.calls_ok / maxValue) * 160) : 0; + var errorHeight = maxValue ? Math.round((point.calls_error / maxValue) * 160) : 0; + if (point.calls_ok > 0) { + okHeight = Math.max(okHeight, 6); + } + if (point.calls_error > 0) { + errorHeight = Math.max(errorHeight, 6); + } + node.querySelector('.chart-bar.success').style.height = okHeight + 'px'; + node.querySelector('.chart-bar.success').dataset.tip = tfKey('usage.chart.ok', { count: formatCount(point.calls_ok) }); + node.querySelector('.chart-bar.error').style.height = errorHeight + 'px'; + node.querySelector('.chart-bar.error').dataset.tip = tfKey('usage.chart.errors', { count: formatCount(point.calls_error) }); + node.querySelector('.chart-col-label').textContent = chartBucketLabel(point.bucket_start, state.period, index); + chartWrap.appendChild(node); + }); + } + + function renderTable() { + var operations = state.derived.localizedOperations; + tableBody.innerHTML = ''; + if (cardList) { + cardList.innerHTML = ''; + } + + if (!operations.length) { + var row = document.createElement('tr'); + var cell = document.createElement('td'); + cell.colSpan = 7; + cell.style.padding = '24px'; + cell.appendChild( + buildEmptyState( + tKey('usage.table.empty.title'), + tKey('usage.table.empty.sub'), + true + ) + ); + row.appendChild(cell); + tableBody.appendChild(row); + renderOperationCards([]); + return; + } + + operations.forEach(function (entry) { + var operation = entry.operation; + var localizedOperation = entry.localized; + var node = rowTemplate.content.cloneNode(true); + var errorRate = operation.calls_total === 0 + ? 0 + : ((operation.calls_error / operation.calls_total) * 100); + var share = state.derived.totalCalls === 0 + ? 0 + : ((operation.calls_total / state.derived.totalCalls) * 100); + + node.querySelector('.col-name').textContent = localizedOperation.operation_display_name || operation.operation_display_name; + node.querySelector('.col-op-slug').textContent = operation.operation_name; + + var proto = node.querySelector('.col-proto'); + proto.textContent = protocolLabel(operation.protocol); + proto.style.color = protocolColor(operation.protocol); + + node.querySelector('.col-calls').textContent = formatCount(operation.calls_total); + node.querySelector('.col-errors').textContent = formatCount(operation.calls_error); + + var errorRateEl = node.querySelector('.col-err-rate'); + errorRateEl.textContent = errorRate.toFixed(2) + '%'; + errorRateEl.style.color = errorRate > 5 ? 'var(--red)' : (errorRate > 1 ? 'var(--amber)' : 'var(--green)'); + + var latencyText = node.querySelector('.col-latency-text'); + latencyText.textContent = + formatMs(operation.p50_ms) + ' / ' + formatMs(operation.p95_ms) + ' / ' + formatMs(operation.p99_ms); + + var base = Math.max(operation.p99_ms, 1); + node.querySelector('.latency-p50').style.width = Math.max(4, Math.round((operation.p50_ms / base) * 64)) + 'px'; + node.querySelector('.latency-p95').style.width = Math.max(4, Math.round(((operation.p95_ms - operation.p50_ms) / base) * 64)) + 'px'; + node.querySelector('.latency-p99').style.width = Math.max(4, Math.round(((operation.p99_ms - operation.p95_ms) / base) * 64)) + 'px'; + + node.querySelector('.col-quota-label').textContent = tfKey('usage.table.share', { value: share.toFixed(1) }); + node.querySelector('.col-quota-fill').style.width = share.toFixed(1) + '%'; + + tableBody.appendChild(node); + }); + + renderOperationCards(operations); + } + + function renderOperationCards(operations) { + if (!cardList) { + return; + } + + cardList.innerHTML = ''; + + if (!operations.length) { + cardList.appendChild(buildEmptyState( + tKey('usage.table.empty.title'), + tKey('usage.table.empty.sub'), + false + )); + return; + } + + operations.forEach(function(entry) { + var operation = entry.operation; + var localizedOperation = entry.localized; + var errorRate = operation.calls_total === 0 + ? 0 + : ((operation.calls_error / operation.calls_total) * 100); + var share = state.derived.totalCalls === 0 + ? 0 + : ((operation.calls_total / state.derived.totalCalls) * 100); + + var card = element('div', 'resource-card'); + var header = element('div', 'resource-card-header'); + var headerMain = element('div'); + headerMain.appendChild(element( + 'div', + 'resource-card-title', + localizedOperation.operation_display_name || operation.operation_display_name + )); + headerMain.appendChild(element('div', 'resource-card-subtitle', operation.operation_name)); + + var actions = element('div', 'resource-card-actions'); + var protocolBadge = element('span', 'badge'); + protocolBadge.textContent = protocolLabel(operation.protocol); + protocolBadge.style.color = protocolColor(operation.protocol); + actions.appendChild(protocolBadge); + header.appendChild(headerMain); + header.appendChild(actions); + card.appendChild(header); + + var metaGrid = element('div', 'resource-meta-grid'); + metaGrid.appendChild(buildUsageMetaItem('usage.table.th.calls', formatCount(operation.calls_total))); + metaGrid.appendChild(buildUsageMetaItem('usage.table.th.errors', formatCount(operation.calls_error))); + metaGrid.appendChild(buildUsageMetaItem('usage.table.th.error_rate', errorRate.toFixed(2) + '%')); + metaGrid.appendChild(buildUsageMetaItem( + 'usage.table.th.latency', + formatMs(operation.p50_ms) + ' / ' + formatMs(operation.p95_ms) + ' / ' + formatMs(operation.p99_ms) + )); + metaGrid.appendChild(buildUsageMetaItem('usage.table.th.share', tfKey('usage.table.share', { value: share.toFixed(1) }))); + card.appendChild(metaGrid); + + cardList.appendChild(card); + }); + } + + function buildUsageMetaItem(labelKey, valueText) { + var item = element('div', 'resource-meta-item'); + item.appendChild(element('div', 'resource-meta-label', tKey(labelKey))); + item.appendChild(element('div', 'resource-meta-value', valueText)); + return item; + } + + function renderUsage() { + if (state.loading && !state.usage) { + renderEmpty(tKey('usage.loading.title'), tKey('usage.loading.sub')); + return; + } + + if (state.loadError) { + renderEmpty(tKey('usage.error.title'), state.loadError); + return; + } + + renderStats(); + renderChart(); + renderTable(); + if (subtitle) { + subtitle.textContent = tfKey('usage.table.subtitle', { period: periodLabel(state.period) }); + } + } + + async function loadUsage() { + if (!window.CrankApi) { + state.loadError = tKey('usage.error.api'); + renderUsage(); + return; + } + + state.workspaceId = currentWorkspaceId(); + if (!state.workspaceId) { + state.loadError = tKey('usage.error.workspace'); + renderUsage(); + return; + } + + state.loading = true; + state.loadError = ''; + renderUsage(); + + try { + state.usage = await window.CrankApi.getUsageOverview(state.workspaceId, { period: state.period }); + recomputeDerivedState(); + } catch (error) { + state.loadError = error.message || tKey('usage.error.load'); + state.usage = null; + recomputeDerivedState(); + } finally { + state.loading = false; + renderUsage(); + } + } + + function exportCsv() { + if (!state.usage) { + if (window.CrankUi) { + window.CrankUi.info(tKey('usage.export.empty.body'), tKey('usage.export.empty.title')); + } + return; + } + + var rows = [tKey('usage.csv.header')]; + state.usage.operations.forEach(function (operation) { + var localizedOperation = localizedUsageOperation(operation); + var errorRate = operation.calls_total === 0 + ? 0 + : ((operation.calls_error / operation.calls_total) * 100); + rows.push([ + '"' + (localizedOperation.operation_display_name || operation.operation_display_name) + '"', + '"' + protocolLabel(operation.protocol) + '"', + operation.calls_total, + operation.calls_error, + errorRate.toFixed(2), + operation.p50_ms, + operation.p95_ms, + operation.p99_ms, + ].join(',')); + }); + + var blob = new Blob([rows.join('\r\n')], { type: 'text/csv' }); + var url = URL.createObjectURL(blob); + var link = document.createElement('a'); + link.href = url; + link.download = 'crank-usage-' + state.period + '.csv'; + link.click(); + URL.revokeObjectURL(url); + if (window.CrankUi) { + window.CrankUi.success(tKey('usage.export.done.body'), tKey('usage.export.done.title')); + } + } + + if (periodSelect) { + periodSelect.value = state.period; + periodSelect.addEventListener('change', function () { + state.period = this.value; + loadUsage(); + }); + } + + if (exportBtn) { + exportBtn.addEventListener('click', exportCsv); + } + + window.addEventListener('crank:workspacechange', loadUsage); + + if (window.whenWorkspacesReady) { + window.whenWorkspacesReady().finally(loadUsage); + } else { + loadUsage(); + } +}); diff --git a/apps/ui/js/vendor/alpine/cdn.min.js b/apps/ui/js/vendor/alpine/cdn.min.js new file mode 100644 index 0000000..4313936 --- /dev/null +++ b/apps/ui/js/vendor/alpine/cdn.min.js @@ -0,0 +1,5 @@ +(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function Ke(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function qe(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(kn)}}function kn(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{Ke()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(qe);t._x_cleanups?.length;)t._x_cleanups.pop()()}var ue=new MutationObserver(pe),le=!1;function lt(){ue.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),le=!0}function fe(){In(),ue.disconnect(),le=!1}var ut=[];function In(){let t=ue.takeRecords();ut.push(()=>t.length>0&&pe(t));let e=ut.length;queueMicrotask(()=>{if(ut.length===e)for(;ut.length>0;)ut.shift()()})}function m(t){if(!le)return t();fe();let e=t();return lt(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,u=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},l=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?u():s.hasAttribute(a)?(l(),u()):l()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function ur(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message} + +${r?'Expression: "'+r+`" + +`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return lr(...t)}var lr=()=>{};function fr(t){lr=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let u=n.call(a,n,c).catch(l=>nt(l,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):u.then(l=>{ft(i,l,c,s,r)}).catch(l=>nt(l,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,u=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof u=="function"&&it?u.apply(o,s):u}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(Kn(n,r)).sort(qn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function Kn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function qn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function S(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&S("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||S("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `