commit ae09eeba30c0be2c3e58448daa40c0c5b39aa8a1 Author: github-ops Date: Wed Jun 17 06:15:46 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-0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb.json b/.sqlx/query-0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb.json new file mode 100644 index 0000000..38c9190 --- /dev/null +++ b/.sqlx/query-0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb.json @@ -0,0 +1,173 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2\n order by ov.version asc", + "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": "wizard_state_json", + "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "change_note", + "type_info": "Text" + }, + { + "ordinal": 24, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 25, + "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, + true, + false, + true + ] + }, + "hash": "0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb" +} 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-21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b.json b/.sqlx/query-21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b.json new file mode 100644 index 0000000..b636ccd --- /dev/null +++ b/.sqlx/query-21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b.json @@ -0,0 +1,174 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2 and ov.version = $3", + "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": "wizard_state_json", + "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "change_note", + "type_info": "Text" + }, + { + "ordinal": 24, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 25, + "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, + true, + false, + true + ] + }, + "hash": "21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b" +} 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-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-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-94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce.json b/.sqlx/query-94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce.json new file mode 100644 index 0000000..8731e92 --- /dev/null +++ b/.sqlx/query-94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce.json @@ -0,0 +1,172 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n where po.operation_id = $1", + "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": "wizard_state_json", + "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "change_note", + "type_info": "Text" + }, + { + "ordinal": 24, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 25, + "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, + true, + false, + true + ] + }, + "hash": "94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce" +} 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-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-c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec.json b/.sqlx/query-c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec.json new file mode 100644 index 0000000..362db33 --- /dev/null +++ b/.sqlx/query-c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec.json @@ -0,0 +1,209 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n w.id as workspace_id,\n w.slug as workspace_slug,\n a.id as agent_id,\n a.slug as agent_slug,\n b.tool_name,\n b.tool_title,\n coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",\n o.id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version\n join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version\n join operations o on o.id = ov.operation_id and o.workspace_id = w.id\n where w.slug = $1 and a.slug = $2 and b.enabled = true\n order by b.tool_name asc", + "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": "wizard_state_json", + "type_info": "Jsonb" + }, + { + "ordinal": 29, + "name": "change_note", + "type_info": "Text" + }, + { + "ordinal": 30, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 31, + "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, + true, + false, + true + ] + }, + "hash": "c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec" +} 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-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/.sqlx/query-ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4.json b/.sqlx/query-ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4.json new file mode 100644 index 0000000..b2d8158 --- /dev/null +++ b/.sqlx/query-ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4.json @@ -0,0 +1,170 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n order by o.name asc", + "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": "wizard_state_json", + "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "change_note", + "type_info": "Text" + }, + { + "ordinal": 24, + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 25, + "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, + true, + false, + true + ] + }, + "hash": "ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4" +} diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..a76a715 --- /dev/null +++ b/CLA.md @@ -0,0 +1,63 @@ +# Contributor License Agreement + +Этот документ описывает условия, на которых проект Crank принимает внешние вклады в код, документацию, тесты, примеры и другие материалы. + +Перед отправкой pull request автор вклада должен согласиться с этими условиями. Если вклад сделан от имени компании, автор подтверждает, что имеет право передать вклад на этих условиях. + +## 1. Что считается вкладом + +Вкладом считается любой материал, намеренно отправленный в проект Crank: + +- исходный код; +- тесты; +- документация; +- примеры конфигурации; +- исправления ошибок; +- предложения, если они оформлены как конкретные изменения в репозитории. + +## 2. Права на вклад + +Автор вклада подтверждает, что: + +- он является правообладателем вклада или имеет необходимые права для его передачи; +- вклад не нарушает права третьих лиц; +- вклад не содержит кода или материалов, которые нельзя использовать в проекте Crank на условиях этого соглашения. + +## 3. Лицензия на вклад + +Автор предоставляет владельцу проекта Crank бессрочную, всемирную, безотзывную, неисключительную, безвозмездную лицензию на использование вклада. + +Эта лицензия включает право: + +- использовать вклад; +- копировать вклад; +- изменять вклад; +- объединять вклад с другими материалами; +- распространять вклад; +- публиковать вклад; +- сублицензировать вклад; +- включать вклад в открытые, коммерческие и закрытые версии Crank. + +## 4. Патенты + +Если вклад затрагивает патентуемые решения, автор предоставляет владельцу проекта и пользователям Crank безвозмездную лицензию на патентные притязания автора, необходимые для использования вклада в составе Crank. + +## 5. Отсутствие гарантий + +Вклад передается без гарантий. Автор не отвечает за убытки, возникшие из-за использования вклада, если иное прямо не установлено законом или отдельным письменным соглашением. + +## 6. Лицензия проекта + +Crank Community распространяется по лицензии GNU Affero General Public License v3.0 only. + +Владелец проекта может использовать принятые вклады также в других редакциях Crank, включая коммерческие, облачные и закрытые редакции. + +## 7. Как подтвердить согласие + +Отправляя pull request, автор подтверждает согласие с этим CLA. + +В комментарии к pull request можно указать: + +```text +I agree to the Crank Contributor License Agreement. +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4d1eb29 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Участие в разработке + +Crank Community принимает исправления ошибок, улучшения документации, тесты и доработки открытой версии проекта. + +## Лицензия вкладов + +Crank Community распространяется по лицензии GNU Affero General Public License v3.0 only. + +Перед отправкой pull request нужно согласиться с [Contributor License Agreement](./CLA.md). Это нужно, чтобы проект мог использовать принятые изменения не только в Community-версии, но и в коммерческих редакциях Crank. + +## Перед pull request + +Проверьте форматирование и тесты: + +```bash +just fmt-check +just clippy +just test +``` + +Для изменений веб-интерфейса также выполните: + +```bash +cd apps/ui +npm ci +npm run build +npx playwright test +``` + +## Требования к изменениям + +- Не добавляйте функциональность вне границ Community-версии. +- Не добавляйте секреты, токены, приватные адреса и локальные настройки. +- Не коммитьте `AGENTS.md`, `TASKS.md`, `.env` и временные файлы. +- Для изменений SQL-запросов обновляйте `.sqlx`, если это требуется SQLx. +- Для пользовательских изменений обновляйте документацию или примеры. + +## Границы Community-версии + +В этом репозитории поддерживаются: + +- REST API как источник MCP-инструментов; +- простая авторизация администратора; +- ключи агентов для MCP-доступа; +- одно самостоятельное развертывание; +- PostgreSQL как основное хранилище; +- необязательный Valkey или Redis для служебного кэша. + +Функции коммерческих редакций не должны попадать в этот репозиторий. 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..a8f1e47 --- /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 = "AGPL-3.0-only" +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/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..de665f7 --- /dev/null +++ b/NOTICE @@ -0,0 +1,7 @@ +Crank +Copyright 2026 bsodfather + +This product includes software developed for the Crank project. + +Crank Community is licensed under the GNU Affero General Public License +version 3 only. Commercial licensing for other editions is handled separately. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f0a3b2 --- /dev/null +++ b/README.md @@ -0,0 +1,269 @@ +# Crank + +![Crank](./crank-community.png) + +Crank - это свободная платформа для создания MCP-инструментов из REST API эндпоинтов. + +Сервис ставится на собственный сервер, подключается к PostgreSQL и дает веб-интерфейс, в котором можно описать REST API, проверить вызов, опубликовать его как инструмент и подключить к MCP-клиенту. + +## Что умеет Crank + +- Создавать REST-инструменты через веб-интерфейс или YAML. +- Принимать параметры от MCP-клиента и подставлять их в путь, query string, заголовки или тело REST-запроса. +- Преобразовывать ответ REST API в структурированный результат для MCP-клиента. +- Публиковать только выбранные инструменты для конкретного агента. +- Хранить версии, черновики, примеры запросов и ответов, секреты, журналы вызовов и статистику в PostgreSQL. +- Работать с простой авторизацией администратора: email, пароль и браузерная сессия. +- Запускаться через Docker Compose. + +## Быстрый запуск через Docker + +Требования: + +- Docker; +- Docker Compose; +- свободные порты `3000`, `3001`, `3002`; +- доступ к опубликованным Docker-образам Crank. + +Создайте рабочую папку: + +```bash +mkdir -p crank +cd crank +``` + +Скачайте compose-файл и пример настроек: + +```bash +curl -fsSLo docker-compose.yml https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/docker-compose.images.yml +curl -fsSLo .env.example https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/.env.images.example +cp .env.example .env +``` + +Откройте скачанный `.env.example` и перенесите нужные значения в `.env`. + +Минимально нужно заменить: + +- `POSTGRES_PASSWORD`; +- `CRANK_MASTER_KEY`; +- `CRANK_SESSION_SECRET`; +- `CRANK_PASSWORD_PEPPER`; +- `CRANK_BOOTSTRAP_ADMIN_EMAIL`; +- `CRANK_BOOTSTRAP_ADMIN_PASSWORD`; +- `CRANK_BASE_URL`. + +Секреты можно сгенерировать так: + +```bash +openssl rand -hex 32 +``` + +Запустите Crank: + +```bash +docker compose --profile local-db up -d +``` + +Если registry требует авторизацию, сначала выполните `docker login git.itexp.me`. + +После запуска: + +- веб-интерфейс: `http://localhost:3000`; +- HTTP API панели управления: `http://localhost:3001`; +- MCP-сервер: `http://localhost:3002`. + +По умолчанию порты публикуются только на `127.0.0.1`. Это удобно, если перед Crank стоит nginx, Caddy или другой обратный прокси. Если нужно открыть порты наружу напрямую, укажите в `.env`: + +```env +CRANK_PUBLISH_BIND=0.0.0.0 +``` + +Проверить состояние контейнеров: + +```bash +docker compose ps +``` + +Посмотреть журналы: + +```bash +docker compose logs -f admin-api mcp-server ui +``` + +Остановить сервис: + +```bash +docker compose down +``` + +Обновить Crank до свежих образов: + +```bash +docker compose --profile local-db pull +docker compose --profile local-db up -d +``` + +## Запуск с внешним PostgreSQL + +Если PostgreSQL уже запущен отдельно, профиль `local-db` не нужен. + +В `.env` укажите параметры вашей базы: + +- `POSTGRES_HOST`; +- `POSTGRES_PORT`; +- `POSTGRES_DB`; +- `POSTGRES_USER`; +- `POSTGRES_PASSWORD`. + +Затем запустите только приложения Crank: + +```bash +docker compose up -d +``` + +Если используется PgBouncer, укажите его адрес в `POSTGRES_HOST` и порт в `POSTGRES_PORT`. + +## Запуск из исходников + +Если нужно собрать образы самостоятельно, клонируйте репозиторий и используйте корневой [`docker-compose.yml`](./docker-compose.yml): + +```bash +git clone https://github.com/bsodfather/crank-community.git crank +cd crank +cp .env.example .env +docker compose up -d --build +``` + +Этот вариант удобен для локальной проверки изменений перед отправкой патча. + +## Как пользоваться + +Обычный сценарий: + +1. Зайти в веб-интерфейс. +2. Создать REST-инструмент. +3. Описать входные параметры и правила вызова REST API. +4. Выполнить пробный запрос. +5. Опубликовать инструмент. +6. Привязать инструмент к агенту. +7. Создать ключ агента. +8. Подключить MCP-клиент к адресу агента. + +Каждый агент имеет свой каталог инструментов. MCP-клиент видит только те REST-инструменты, которые явно привязаны к этому агенту. + +## Что входит в эту версию + +Этот репозиторий содержит открытую версию Crank: + +- REST API как источник инструментов; +- MCP через Streamable HTTP; +- веб-интерфейс администратора; +- простая авторизация администратора; +- ключи агентов для доступа к MCP; +- PostgreSQL как основное хранилище; +- необязательный Valkey или Redis для служебного кэша. + +## Структура проекта + +```text +apps/ + admin-api/ HTTP API для веб-интерфейса + mcp-server/ MCP-сервер + ui/ веб-интерфейс + +crates/ + crank-core/ общая модель данных + crank-registry/ работа с PostgreSQL + crank-runtime/ выполнение REST-инструментов + crank-adapter-rest/ REST-адаптер + crank-community-auth/ пароли и сессии + crank-community-mcp/ слой MCP + crank-mapping/ преобразование данных через JSONPath + crank-schema/ проверка схем + +deploy/community/ + docker-compose.yml запуск с внешним PostgreSQL + docker-compose.images.yml запуск готовых образов без исходников + .env.example пример настроек для серверного запуска из исходников + .env.images.example пример настроек для запуска готовых образов +``` + +## Разработка + +Для разработки нужны: + +- Rust toolchain из [`rust-toolchain.toml`](./rust-toolchain.toml); +- Node.js и npm; +- PostgreSQL; +- Docker, если хотите запускать полный стенд контейнерами. + +Быстрее всего поднять окружение так же, как для обычного запуска: + +```bash +git clone https://github.com/bsodfather/crank-community.git crank +cd crank +cp .env.example .env +docker compose up -d postgres +``` + +Приложения читают настройки из переменных окружения. Можно использовать `.env` через свое окружение разработки, `direnv`, IDE или любой другой привычный способ загрузки переменных. + +Сервер панели управления: + +```bash +cargo run -p admin-api +``` + +MCP-сервер: + +```bash +cargo run -p mcp-server +``` + +Веб-интерфейс: + +```bash +cd apps/ui +npm ci +npm run dev +``` + +Проверки: + +```bash +just fmt-check +just clippy +just test +``` + +Сборка веб-интерфейса: + +```bash +cd apps/ui +npm ci +npm run build +``` + +E2E-проверки: + +```bash +cd apps/ui +npx playwright test +``` + +## Документация + +- [Настройки запуска](docs/runtime-config.md) +- [Развертывание](docs/deployment.md) +- [MCP-интерфейс](docs/mcp-interface.md) +- [Английский README](docs/en/README.md) + +## Участие в разработке + +Перед отправкой pull request нужно согласиться с [Contributor License Agreement](CLA.md). + +Правила участия описаны в [CONTRIBUTING.md](CONTRIBUTING.md). + +## Лицензия + +GNU Affero General Public License v3.0 only 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..c031140 --- /dev/null +++ b/apps/admin-api/src/app.rs @@ -0,0 +1,4103 @@ +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 agent_publish_skips_draft_operation_bindings_and_preserves_draft() { + let registry = test_registry().await; + let storage_root = test_storage_root("agent_publish_filters_drafts"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let published_operation = assert_success_json( + client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "crm_published_tool", + )) + .send() + .await + .unwrap(), + ) + .await; + let published_operation_id = published_operation["operation_id"] + .as_str() + .unwrap() + .to_owned(); + assert_success_json( + client + .post(format!( + "{base_url}/operations/{published_operation_id}/publish" + )) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(), + ) + .await; + + let draft_operation = assert_success_json( + client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "crm_draft_tool", + )) + .send() + .await + .unwrap(), + ) + .await; + let draft_operation_id = draft_operation["operation_id"].as_str().unwrap().to_owned(); + + let agent = assert_success_json( + client + .post(format!("{base_url}/agents")) + .json(&json!({ + "slug": "collaborative-agent", + "display_name": "Collaborative Agent", + "description": "Agent with published and draft tools", + "instructions": {}, + "tool_selection_policy": {} + })) + .send() + .await + .unwrap(), + ) + .await; + let agent_id = agent["agent_id"].as_str().unwrap().to_owned(); + + assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/bindings")) + .json(&json!([ + { + "operation_id": published_operation_id, + "operation_version": 1, + "tool_name": "crm_published_tool", + "tool_title": "Published Tool", + "tool_description_override": null, + "enabled": true + }, + { + "operation_id": draft_operation_id, + "operation_version": 1, + "tool_name": "crm_draft_tool", + "tool_title": "Draft Tool", + "tool_description_override": null, + "enabled": true + } + ])) + .send() + .await + .unwrap(), + ) + .await; + + let published = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(), + ) + .await; + let agent_detail = assert_success_json( + client + .get(format!("{base_url}/agents/{agent_id}")) + .send() + .await + .unwrap(), + ) + .await; + let published_version = assert_success_json( + client + .get(format!("{base_url}/agents/{agent_id}/versions/1")) + .send() + .await + .unwrap(), + ) + .await; + let draft_version = assert_success_json( + client + .get(format!("{base_url}/agents/{agent_id}/versions/2")) + .send() + .await + .unwrap(), + ) + .await; + + assert_eq!(published["published_version"], 1); + assert_eq!(agent_detail["current_draft_version"], 2); + assert_eq!(agent_detail["latest_published_version"], 1); + assert_eq!(published_version["bindings"].as_array().unwrap().len(), 1); + assert_eq!( + published_version["bindings"][0]["operation_id"], + published_operation_id + ); + assert_eq!(draft_version["bindings"].as_array().unwrap().len(), 2); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn updates_lists_and_deletes_agent() { + let registry = test_registry().await; + let storage_root = test_storage_root("agent_mutations"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let operation = client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "crm_create_lead_agents_page", + )) + .send() + .await + .unwrap(); + let operation = assert_success_json(operation).await; + let operation_id = operation["operation_id"].as_str().unwrap().to_owned(); + + client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(); + + let created = client + .post(format!("{base_url}/agents")) + .json(&json!({ + "slug": "support-team", + "display_name": "Support Team", + "description": "Support workflows", + "instructions": {}, + "tool_selection_policy": {} + })) + .send() + .await + .unwrap() + .json::() + .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(); + 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(), + }, + wizard_state: None, + } + } + + 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(), + }, + wizard_state: None, + } + } + + #[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(), + }, + wizard_state: None, + } + } + + #[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(), + }, + wizard_state: None, + } + } + + 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(), + }, + wizard_state: None, + } + } + + #[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..5c1123b --- /dev/null +++ b/apps/admin-api/src/error.rs @@ -0,0 +1,583 @@ +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::InvalidAgentVersionSequence { + agent_id, + expected, + actual, + } => Self::validation_with_context( + format!("agent {agent_id} expected next version {expected}, got {actual}"), + json!({ + "agent_id": agent_id, + "expected": expected, + "actual": actual, + }), + ), + RegistryError::ImmutableOperationFieldChanged { + operation_id, + field, + } => 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..60f1ef2 --- /dev/null +++ b/apps/admin-api/src/service.rs @@ -0,0 +1,4611 @@ +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, WizardState, Workspace, WorkspaceId, + WorkspaceStatus, +}; +use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; +use crank_registry::{ + AgentSummary, AgentVersionRecord, CreateAgentDraftVersionRequest, 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, + #[serde(default)] + pub wizard_state: Option, +} + +#[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, + #[serde(default)] + pub wizard_state: Option, +} + +#[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, + }), + wizard_state: payload.wizard_state, + created_at: now, + updated_at: now, + published_at: None, + }; + + self.registry + .create_operation(workspace_id, &snapshot, None) + .await?; + info!(operation_id = %operation_id.as_str(), version = 1, "operation created"); + + Ok(CreatedOperationResponse { + operation_id: operation_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + version: 1, + status: OperationStatus::Draft, + updated_at: format_timestamp(snapshot.updated_at), + }) + } + + #[instrument(skip(self, payload), fields(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, + }), + wizard_state: payload.operation.wizard_state, + created_at: summary.created_at, + updated_at: now, + published_at: None, + }; + + self.registry + .create_version(CreateVersionRequest { + workspace_id, + snapshot: &snapshot, + change_note: payload.change_note.as_deref(), + created_by: None, + }) + .await?; + info!(operation_id = %operation_id.as_str(), version, "operation version created"); + + Ok(CreatedOperationResponse { + operation_id: operation_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + version, + status: OperationStatus::Draft, + updated_at: format_timestamp(snapshot.updated_at), + }) + } + + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str()))] + pub async fn update_operation( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + payload: UpdateOperationPayload, + ) -> Result { + let existing = self + .get_operation_version( + workspace_id, + operation_id, + self.get_operation(workspace_id, operation_id) + .await? + .current_draft_version, + ) + .await?; + + let updated_at = OffsetDateTime::now_utc(); + let snapshot = RegistryOperation { + id: operation_id.clone(), + name: existing.snapshot.name, + display_name: payload.display_name, + category: payload.category, + protocol: existing.snapshot.protocol, + security_level: payload.security_level, + status: OperationStatus::Draft, + version: existing.version, + target: payload.target, + input_schema: payload.input_schema, + output_schema: payload.output_schema, + input_mapping: payload.input_mapping, + output_mapping: payload.output_mapping, + execution_config: payload.execution_config, + tool_description: payload.tool_description, + samples: existing.snapshot.samples, + generated_draft: existing.snapshot.generated_draft, + config_export: existing.snapshot.config_export, + wizard_state: payload.wizard_state, + created_at: existing.snapshot.created_at, + updated_at, + published_at: existing.snapshot.published_at, + }; + + self.validate_registry_operation(&snapshot)?; + self.registry + .update_operation_draft(workspace_id, &snapshot) + .await?; + + Ok(OperationMutationResult { + operation_id: operation_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + version: snapshot.version, + status: snapshot.status, + updated_at: format_timestamp(updated_at), + }) + } + + #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version))] + pub async fn publish_operation( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + version: u32, + ) -> Result { + 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 agent_version = self + .get_agent_version(workspace_id, agent_id, version) + .await?; + let published_bindings = self + .published_agent_bindings(workspace_id, &agent_version.bindings) + .await?; + + if published_bindings.is_empty() { + return Err(ApiError::conflict_with_context( + "agent cannot be published without published enabled tools", + json!({ + "agent_id": agent_id.as_str(), + "version": version, + "binding_count": agent_version.bindings.len() + }), + )); + } + + let published_at = OffsetDateTime::now_utc(); + if published_bindings != agent_version.bindings { + let draft_version = AgentVersion { + agent_id: agent_id.clone(), + version: agent_version.version + 1, + status: AgentStatus::Draft, + instructions: agent_version.snapshot.instructions.clone(), + tool_selection_policy: agent_version.snapshot.tool_selection_policy.clone(), + created_at: published_at, + }; + + self.registry + .create_agent_draft_version(CreateAgentDraftVersionRequest { + workspace_id, + agent_id, + version: &draft_version, + bindings: &agent_version.bindings, + updated_at: &published_at, + }) + .await?; + + self.registry + .save_agent_bindings(SaveAgentBindingsRequest { + workspace_id, + agent_id, + agent_version: agent_version.version, + bindings: &published_bindings, + }) + .await?; + } + + self.registry + .publish_agent(PublishAgentRequest { + workspace_id, + agent_id, + version, + published_at: &published_at, + published_by: None, + }) + .await?; + info!(agent_id = %agent_id.as_str(), version, "agent published"); + + Ok(PublishAgentResponse { + agent_id: agent_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + published_version: version, + published_at: format_timestamp(published_at), + }) + } + + async fn published_agent_bindings( + &self, + workspace_id: &WorkspaceId, + bindings: &[AgentOperationBinding], + ) -> Result, ApiError> { + let mut published = Vec::new(); + + for binding in bindings { + if !binding.enabled { + continue; + } + + let Some(summary) = self + .registry + .get_operation_summary(workspace_id, &binding.operation_id) + .await? + else { + continue; + }; + + let Some(operation_version) = summary.latest_published_version else { + continue; + }; + + published.push(AgentOperationBinding { + operation_id: summary.id, + operation_version, + tool_name: binding.tool_name.clone(), + tool_title: binding.tool_title.clone(), + tool_description_override: binding.tool_description_override.clone(), + enabled: true, + }); + } + + Ok(published) + } + + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))] + pub async fn unpublish_agent( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + ) -> Result { + 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(), + wizard_state: document.operation.wizard_state.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 + .registry + .list_platform_api_keys(workspace_id) + .await? + .into_iter() + .find(|record| record.api_key.name == name); + let key = match existing { + Some(record) => record, + None => { + self.create_agent_platform_api_key( + workspace_id, + agent_id, + PlatformApiKeyPayload { + name: name.to_owned(), + 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" + }), + }], + }, + wizard_state: Some(WizardState { + input_sample: Some(input), + output_sample: Some(output), + test_input: Some(demo_rest_input_sample()), + }), + } +} + +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(), + }, + wizard_state: Some(WizardState { + input_sample: Some(input), + output_sample: Some(response), + test_input: Some(request), + }), + } +} + +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..a712440 --- /dev/null +++ b/apps/mcp-server/src/main.rs @@ -0,0 +1,3510 @@ +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, + wizard_state: None, + created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + updated_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_at: Some(OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap()), + } + } + + #[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, + wizard_state: None, + created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + updated_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_at: Some( + OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + ), + } + } + + #[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, + wizard_state: None, + created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + updated_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_at: Some(OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap()), + } + } + + #[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..4a3a2d1 --- /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-community.png ./crank-community.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..0922e6d --- /dev/null +++ b/apps/ui/css/pages.css @@ -0,0 +1,1761 @@ +/* ══════════════════════════════════════════════════ + 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; } + +.data-table .table-action-btn { + min-height: 30px; + padding: 6px 10px; + font-size: 12.5px; +} + +/* ══════════════════════════════════════════════════ + 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..3a90046 --- /dev/null +++ b/apps/ui/css/wizard.css @@ -0,0 +1,2014 @@ +/* ══════════════════════════════════════════════════ + 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; + padding-bottom: 96px; +} + +/* ══════════════════════════════════════════════════ + 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); + padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); +} + +.action-bar-inner { + max-width: 1100px; + margin: 0 auto; + padding: 0 40px; + min-height: 52px; + display: flex; + align-items: center; + gap: 12px; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 38px; + 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; + line-height: 1; +} + +.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 { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + color: var(--text-muted); + border: none; + font-size: 13px; + font-weight: 400; + cursor: pointer; + font-family: 'Inter', sans-serif; + min-height: 38px; + padding: 9px 12px; + border-radius: 6px; + transition: all 0.15s; + line-height: 1; +} + +.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; +} + +.method-callout[hidden] { + display: none !important; +} + +/* ══════════════════════════════════════════════ + 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..3eef052 --- /dev/null +++ b/apps/ui/html/settings.html @@ -0,0 +1,293 @@ + + + + + + + Crank — Settings + + + + + + + + + + + + + +
+ + + +
+ + + + + +
+ +
+
+
+
+
Workspace settings
+
General information about your workspace
+
+
+
+
+ + +
Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.
+
+
+ + +
+
+ + +
+ +
+ +
+
+
+
+ + +
+
+
+
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..cfe09a5 --- /dev/null +++ b/apps/ui/html/workspace-setup.html @@ -0,0 +1,280 @@ + + + + + + + Crank — Workspace + + + + + + + + + +
+ + + + + +
+
+ +
+
Workspace settings
+
Configure your workspace identity 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.
+
+ +
+ + +
+
+ + + + + + + + +
+ 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..dda3a21 --- /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..9c70229 --- /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, .ws-switcher')) { + 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..09b4c0c --- /dev/null +++ b/apps/ui/js/i18n.js @@ -0,0 +1,2081 @@ +/* ═══════════════════════════════════════════════ + 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 — 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.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 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.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 — 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.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.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..c38faae --- /dev/null +++ b/apps/ui/js/secrets.js @@ -0,0 +1,709 @@ +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.type = 'button'; + rotateButton.className = 'btn-secondary table-action-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.type = 'button'; + deleteButton.className = 'btn-danger table-action-btn'; + 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..edb292f --- /dev/null +++ b/apps/ui/js/settings.js @@ -0,0 +1,400 @@ +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 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), + }); + populateNotificationsCapabilityHint(capabilities); +} + +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 || ''; + + 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(), + 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 `