diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index bc4666f7875..71708399892 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -254,7 +254,7 @@ If you prefer not to use Docker. **All commands run from the repository root unl cd packages/db && bun run db:migrate && cd ../.. ``` - For ad-hoc schema iteration during development you can also use `bun run db:push` from `packages/db`, but `db:migrate` is the canonical command for both local and CI/CD setups. + For ad-hoc schema iteration during development you can also use `bun run db:push` from `packages/db`, but `db:migrate` is the canonical command for staging and production. `db:push` reconciles directly to the current schema without running versioned migration guards. For disposable local/dev databases, `bun run db:push --force` accepts Drizzle's data-loss prompts, including column drops. 4. **Run the Development Servers:** diff --git a/.github/scripts/get-ecr-image-digest.sh b/.github/scripts/get-ecr-image-digest.sh new file mode 100644 index 00000000000..f8de53f925e --- /dev/null +++ b/.github/scripts/get-ecr-image-digest.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Read one ECR tag. Only ImageNotFound is optional; AWS and response errors fail. +set -euo pipefail +REPOSITORY="${1:?repository required}" +TAG="${2:?tag required}" +ALLOW_MISSING="${3:-}" +if [ -n "$ALLOW_MISSING" ] && [ "$ALLOW_MISSING" != '--allow-missing' ]; then + echo 'ERROR: expected --allow-missing or no third argument' >&2 + exit 1 +fi +export AWS_PAGER='' +aws ecr batch-get-image --repository-name "$REPOSITORY" --image-ids imageTag="$TAG" --output json | + ALLOW_MISSING="$ALLOW_MISSING" python3 -c ' +import json, os, re, sys +response = json.load(sys.stdin) +images, failures = response["images"], response["failures"] +if failures: + if not images and len(failures) == 1 and failures[0]["failureCode"] == "ImageNotFound" and os.environ["ALLOW_MISSING"]: + print("") + sys.exit(0) + raise SystemExit("ERROR: ECR image lookup failed: " + ", ".join(f["failureCode"] for f in failures)) +if len(images) != 1: + raise SystemExit("ERROR: expected exactly one ECR image") +digest = images[0]["imageId"]["imageDigest"] +if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise SystemExit("ERROR: invalid ECR image digest") +print(digest) +' diff --git a/.github/scripts/promote-app-image.sh b/.github/scripts/promote-app-image.sh new file mode 100644 index 00000000000..39cb88f23ff --- /dev/null +++ b/.github/scripts/promote-app-image.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Capture the cutover lower bound at the app tag move, after the image is built. +set -euo pipefail +REGISTRY="${1:?registry required}" +REPOSITORY="${2:?repository required}" +SOURCE_TAG="${3:?source tag required}" +DEPLOY_TAG="${4:?deploy tag required}" +: "${GITHUB_OUTPUT:?GitHub output file required}" +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +PREVIOUS=$(bash "$SCRIPT_DIR/get-ecr-image-digest.sh" "$REPOSITORY" "$DEPLOY_TAG" --allow-missing) +EPOCH=$(date +%s) +docker buildx imagetools create -t "$REGISTRY/$REPOSITORY:$DEPLOY_TAG" "$REGISTRY/$REPOSITORY:$SOURCE_TAG" +DIGEST=$(bash "$SCRIPT_DIR/get-ecr-image-digest.sh" "$REPOSITORY" "$DEPLOY_TAG") +CHANGED=true +if [ "$DIGEST" = "$PREVIOUS" ]; then CHANGED=false; fi +{ + echo "retag_epoch=$EPOCH" + echo "app_image_digest=$DIGEST" + echo "app_image_changed=$CHANGED" +} >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/wait-for-ecs-cutover.sh b/.github/scripts/wait-for-ecs-cutover.sh new file mode 100755 index 00000000000..4e9b10f5d66 --- /dev/null +++ b/.github/scripts/wait-for-ecs-cutover.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# Resolve a pushed app digest to CodePipeline -> CodeDeploy -> every ECS target's +# AllowTraffic event. An unchanged tag uses since-epoch=0 to verify the latest +# pipeline execution instead of assuming the tagged image is already serving. +# Usage: wait-for-ecs-cutover.sh +set -euo pipefail + +PIPELINE="${1:?pipeline name required}" +DIGEST="${2:?image digest required}" +SINCE_EPOCH="${3:?since-epoch required}" +POLL_INTERVAL="${POLL_INTERVAL:-15}" +OVERALL_TIMEOUT="${OVERALL_TIMEOUT:-4200}" +if ! [[ "$PIPELINE" =~ ^[A-Za-z0-9.@_-]+$ && "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ && "$SINCE_EPOCH" =~ ^[0-9]+$ && "$POLL_INTERVAL" =~ ^[1-9][0-9]*$ && "$OVERALL_TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then + echo 'ERROR: invalid pipeline, digest, epoch, or polling budget' >&2 + exit 1 +fi +export AWS_PAGER='' +export AWS_RETRY_MODE=standard +export AWS_MAX_ATTEMPTS=3 + +deadline=$(( $(date +%s) + OVERALL_TIMEOUT )) +log() { echo "[wait-for-ecs-cutover] $*"; } +check_deadline() { + if [ "$(date +%s)" -ge "$deadline" ]; then + log "ERROR: timed out after ${OVERALL_TIMEOUT}s waiting for $1" + exit 1 + fi +} +aws_read() { + aws --cli-connect-timeout 10 --cli-read-timeout 30 "$@" +} + +find_execution() { + local executions + executions=$(aws_read codepipeline list-pipeline-executions \ + --pipeline-name "$PIPELINE" --max-items 30 \ + --query 'pipelineExecutionSummaries' --output json) + printf '%s\n' "$executions" | SINCE="$SINCE_EPOCH" DIGEST="$DIGEST" python3 -c ' +import datetime, json, os, sys +since = int(os.environ["SINCE"]) +def epoch(execution): + value = execution["startTime"] + if isinstance(value, (int, float)): + return value + return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() +def matches(execution): + return any(r["actionName"] == "ECR_Source" and r.get("revisionId") == os.environ["DIGEST"] for r in execution.get("sourceRevisions", [])) +executions = sorted(json.load(sys.stdin), key=epoch, reverse=True) +if since == 0: + if not executions or not matches(executions[0]): + raise SystemExit("ERROR: unchanged app tag does not match the latest pipeline execution; cutover is unverified") + selected = executions[0] +else: + selected = executions[0] if executions and epoch(executions[0]) >= since else None + if selected and not matches(selected): + raise SystemExit("ERROR: latest pipeline execution does not match this app digest; deployment was superseded or its source is unverified") +print(selected["pipelineExecutionId"] if selected else "") +' +} + +EXECUTION_ID='' +while [ -z "$EXECUTION_ID" ]; do + check_deadline 'the matching pipeline execution' + EXECUTION_ID=$(find_execution) + if [ -z "$EXECUTION_ID" ]; then + log 'No matching execution since this push; waiting' + sleep "$POLL_INTERVAL" + fi +done +log "Matched pipeline execution: $EXECUTION_ID" + +DEPLOYMENT_ID='' +while [ -z "$DEPLOYMENT_ID" ] || [ "$DEPLOYMENT_ID" = 'None' ]; do + check_deadline 'the CodeDeploy deployment (the Deploy stage may be queued)' + status=$(aws_read codepipeline get-pipeline-execution \ + --pipeline-name "$PIPELINE" --pipeline-execution-id "$EXECUTION_ID" \ + --query 'pipelineExecution.status' --output text) + case "$status" in + Failed|Stopped|Stopping|Superseded|Cancelled) + log "ERROR: pipeline execution ended in $status; not promoting"; exit 1 ;; + InProgress|Succeeded) ;; + *) log "ERROR: unexpected pipeline status: $status"; exit 1 ;; + esac + # Action history does not publish the external deployment ID until cleanup + # finishes. Live state exposes it while traffic is shifting. Correlate both + # the stage execution and action attempt so old state cannot satisfy this run. + deploy_state=$(aws_read codepipeline get-pipeline-state --name "$PIPELINE" \ + --query "stageStates[?stageName=='Deploy'] | [0]" --output json) + deploy_actions=$(aws_read codepipeline list-action-executions \ + --pipeline-name "$PIPELINE" --filter pipelineExecutionId="$EXECUTION_ID" \ + --query "actionExecutionDetails[?stageName=='Deploy']" --output json) + DEPLOYMENT_ID=$(printf '%s\n' "$deploy_actions" | DEPLOY_STATE="$deploy_state" EXECUTION_ID="$EXECUTION_ID" python3 -c ' +import json, os, re, sys +state = json.loads(os.environ["DEPLOY_STATE"]) +actions = json.load(sys.stdin) +if not state or state.get("latestExecution", {}).get("pipelineExecutionId") != os.environ["EXECUTION_ID"] or not actions: + print("") + sys.exit(0) +if len({a["actionName"] for a in actions}) != 1: + raise SystemExit("ERROR: expected one Deploy action in the app pipeline") +latest = max(actions, key=lambda a: a["startTime"]) +matches = [a["latestExecution"] for a in state.get("actionStates", []) + if a["actionName"] == latest["actionName"] + and a.get("latestExecution", {}).get("actionExecutionId") == latest["actionExecutionId"]] +if len(matches) > 1: + raise SystemExit("ERROR: ambiguous live Deploy action") +if not matches: + print("") + sys.exit(0) +if latest["status"] not in ("InProgress", "Succeeded"): + raise SystemExit("ERROR: Deploy action ended in " + latest["status"]) +deployment_id = matches[0].get("externalExecutionId", "") +if deployment_id and not re.fullmatch(r"d-[A-Za-z0-9]+", deployment_id): + raise SystemExit("ERROR: invalid CodeDeploy deployment ID in pipeline state") +print(deployment_id) +') + if [ -z "$DEPLOYMENT_ID" ] || [ "$DEPLOYMENT_ID" = 'None' ]; then + if [ "$status" = 'Succeeded' ]; then + log 'ERROR: successful pipeline has no CodeDeploy deployment'; exit 1 + fi + sleep "$POLL_INTERVAL" + fi +done +log "CodeDeploy deployment: $DEPLOYMENT_ID" + +while true; do + check_deadline 'AllowTraffic on every ECS target' + status=$(aws_read deploy get-deployment --deployment-id "$DEPLOYMENT_ID" \ + --query 'deploymentInfo.status' --output text) + case "$status" in + Failed|Stopped) log "ERROR: deployment ended in $status; not promoting"; exit 1 ;; + Created|Queued|InProgress|Baking|Ready|Succeeded) ;; + *) log "ERROR: unexpected deployment status: $status"; exit 1 ;; + esac + target_ids=$(aws_read deploy list-deployment-targets --deployment-id "$DEPLOYMENT_ID" \ + --query 'targetIds' --output text) + if [ -n "$target_ids" ] && [ "$target_ids" != 'None' ]; then + all_ok=1 + for target in $target_ids; do + cutover=$(aws_read deploy get-deployment-target --deployment-id "$DEPLOYMENT_ID" --target-id "$target" \ + --query "deploymentTarget.ecsTarget.lifecycleEvents[?lifecycleEventName=='AllowTraffic'].status | [0]" \ + --output text) + case "$cutover" in + Succeeded) ;; + Failed|Skipped|Unknown) log "ERROR: target $target cutover status $cutover"; exit 1 ;; + Pending|InProgress|None|'') all_ok=0 ;; + *) log "ERROR: unexpected cutover status: $cutover"; exit 1 ;; + esac + done + if [ "$all_ok" = 1 ]; then + LATEST_EXECUTION_ID=$(find_execution) + if [ "$LATEST_EXECUTION_ID" != "$EXECUTION_ID" ]; then + log 'ERROR: a newer pipeline execution appeared during cutover; not promoting' + exit 1 + fi + log 'Traffic cutover complete on every ECS target' + exit 0 + fi + fi + log 'Traffic cutover is not complete; waiting' + sleep "$POLL_INTERVAL" +done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75d45ac5e46..9c110d101e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,10 +141,9 @@ jobs: environment: dev secrets: inherit - # Dev: build all 3 images for ECR only (no GHCR, no ARM64) + # Dev: build immutable images alongside the schema push and Trigger upload. build-dev: name: Build Dev ECR - needs: [detect-version, migrate-dev] if: github.event_name == 'push' && github.ref == 'refs/heads/dev' runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }} timeout-minutes: 30 @@ -221,18 +220,22 @@ jobs: provider: ${{ vars.CI_PROVIDER }} file: ${{ matrix.dockerfile }} platforms: linux/amd64 - tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev + tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:${{ github.sha }}-dev max-cache-size-mb: ${{ matrix.cache_mb }} - # Dev: deploy Trigger.dev background tasks to the preview "dev-sim" branch. - # Gated after migrate-dev for the same reason as build-dev — the new task - # code runs against the dev DB, so the schema must be pushed first. - deploy-trigger-dev: - name: Deploy Trigger.dev (Dev) - needs: [migrate-dev] - if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + # Build and upload tasks alongside tests and images. The unpromoted version + # cannot serve new runs; promote-images waits for it and successful migrations. + prepare-trigger: + name: Prepare Trigger.dev + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 + outputs: + version: ${{ steps.deploy.outputs.deploymentVersion }} + environment: ${{ steps.target.outputs.environment }} + preview_branch: ${{ steps.target.outputs.preview_branch }} steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -256,17 +259,46 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts - - name: Deploy to Trigger.dev + - name: Select Trigger environment + id: target + run: | + case "$GITHUB_REF" in + refs/heads/main) TRIGGER_ENV=prod; TRIGGER_BRANCH='' ;; + refs/heads/staging) TRIGGER_ENV=staging; TRIGGER_BRANCH='' ;; + refs/heads/dev) TRIGGER_ENV=preview; TRIGGER_BRANCH=dev-sim ;; + *) echo "ERROR: unsupported Trigger release ref: $GITHUB_REF" >&2; exit 1 ;; + esac + echo "environment=$TRIGGER_ENV" >> "$GITHUB_OUTPUT" + echo "preview_branch=$TRIGGER_BRANCH" >> "$GITHUB_OUTPUT" + + - name: Upload Trigger.dev version without promotion + id: deploy working-directory: ./apps/sim env: - TRIGGER_ACCESS_TOKEN: ${{ secrets.DEV_TRIGGER_ACCESS_TOKEN }} + TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} + TRIGGER_ENV: ${{ steps.target.outputs.environment }} + TRIGGER_BRANCH: ${{ steps.target.outputs.preview_branch }} run: | + set -eo pipefail if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then - echo "ERROR: DEV_TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2 + echo "ERROR: TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2 + exit 1 + fi + TARGET_ARGS=(--env "$TRIGGER_ENV") + if [ -n "$TRIGGER_BRANCH" ]; then + TARGET_ARGS+=(--branch "$TRIGGER_BRANCH") + fi + bunx trigger.dev@4.5.12 deploy "${TARGET_ARGS[@]}" --skip-promotion + + - name: Validate deployment version output + env: + VERSION: ${{ steps.deploy.outputs.deploymentVersion }} + run: | + if ! [[ "$VERSION" =~ ^[0-9]{8}\.[0-9]+$ ]]; then + echo "ERROR: Trigger.dev did not report a valid deploymentVersion output" >&2 exit 1 fi - bunx trigger.dev@4.5.12 deploy --env preview --branch dev-sim # Main/staging: build AMD64 images and push sha-tagged images to ECR + GHCR. # Runs in parallel with tests — only immutable sha tags are pushed here, and @@ -394,33 +426,53 @@ jobs: tags: ${{ steps.meta.outputs.tags }} max-cache-size-mb: ${{ matrix.cache_mb }} - # Promote the sha-tagged ECR images to the deploy tags once tests and - # migrations pass. Pushing the ECR latest/staging tag is what triggers + # Promote the sha-tagged ECR images once tests, migrations, and the Trigger + # upload pass. Pushing the ECR latest/staging tag is what triggers # CodePipeline, so this seconds-long manifest retag is the deploy gate — # the image builds themselves run in parallel with the tests. A single job # (not a matrix) so all four sha manifests are verified before any tag # moves; a missing image can't produce a partial mixed-version deploy. promote-images: name: Promote Images - needs: [migrate, build-amd64] + needs: [migrate, build-amd64, prepare-trigger, migrate-dev, build-dev] # Explicit results: see migrate's comment. if: >- - !cancelled() && - needs.migrate.result == 'success' && - needs.build-amd64.result == 'success' && - github.event_name == 'push' && - (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') + !cancelled() && github.event_name == 'push' && + needs.prepare-trigger.result == 'success' && + ( + ((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') && + needs.migrate.result == 'success' && + needs.build-amd64.result == 'success') || + (github.ref == 'refs/heads/dev' && + needs.migrate-dev.result == 'success' && + needs.build-dev.result == 'success') + ) runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 10 permissions: contents: read id-token: write + outputs: + # Whether the deploy tag was actually moved (false on a stale-run guard + # skip). promote-trigger keys off this so tasks are never promoted when + # the app itself wasn't. + promoted: ${{ steps.guard.outputs.fresh }} + # Epoch when the deploy tag was retagged (this push's ECS pipeline trigger). + # promote-trigger passes it to the poll script so a stale pipeline execution + # reusing the same image digest can't satisfy the cutover gate. + retag_epoch: ${{ steps.promote.outputs.retag_epoch }} + # Unchanged tags verify the latest execution's cutover without an epoch bound. + app_image_changed: ${{ steps.promote.outputs.app_image_changed }} + app_image_digest: ${{ steps.promote.outputs.app_image_digest }} steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: - role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} - aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }} + role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} + aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }} - name: Login to Amazon ECR id: login-ecr @@ -443,37 +495,156 @@ jobs: echo "fresh=false" >> $GITHUB_OUTPUT fi + # Fail before moving tags if the live cutover observer lacks permission. + # Requires codepipeline:GetPipelineState on the app pipeline. + - name: Verify pipeline state access + if: steps.guard.outputs.fresh == 'true' + env: + PIPELINE: sim-${{ github.ref == 'refs/heads/main' && 'production' || github.ref == 'refs/heads/dev' && 'dev' || 'staging' }}-us-east-1-app-deployment + run: aws codepipeline get-pipeline-state --name "$PIPELINE" --query pipelineName --output text > /dev/null + - name: Promote images to deploy tags + id: promote if: steps.guard.outputs.fresh == 'true' env: + SOURCE_TAG: ${{ github.ref == 'refs/heads/dev' && format('{0}-dev', github.sha) || github.sha }} ECR_REPOS: >- - ${{ secrets.ECR_APP }} ${{ secrets.ECR_MIGRATIONS }} ${{ secrets.ECR_REALTIME }} ${{ secrets.ECR_PII }} + ${{ secrets.ECR_APP }} run: | REGISTRY="${{ steps.login-ecr.outputs.registry }}" if [ "${{ github.ref }}" = "refs/heads/main" ]; then ECR_TAG="latest" + elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then + ECR_TAG="dev" else ECR_TAG="staging" fi + APP_REPO="${{ secrets.ECR_APP }}" + # Verify every sha image exists before moving any deploy tag, so a # missing/expired image aborts the whole promotion up front. for repo in $ECR_REPOS; do - echo "🔍 Verifying ${repo}:${{ github.sha }}" - docker buildx imagetools inspect "${REGISTRY}/${repo}:${{ github.sha }}" > /dev/null + echo "🔍 Verifying ${repo}:${SOURCE_TAG}" + docker buildx imagetools inspect "${REGISTRY}/${repo}:${SOURCE_TAG}" > /dev/null done + # Move the app last so a preceding tag failure cannot start app rollout + # and then skip the downstream Trigger promotion job. for repo in $ECR_REPOS; do - echo "🚀 Promoting ${repo}:${{ github.sha }} to ${ECR_TAG}" - docker buildx imagetools create \ - -t "${REGISTRY}/${repo}:${ECR_TAG}" \ - "${REGISTRY}/${repo}:${{ github.sha }}" + echo "🚀 Promoting ${repo}:${SOURCE_TAG} to ${ECR_TAG}" + if [ "$repo" = "$APP_REPO" ]; then + bash .github/scripts/promote-app-image.sh "$REGISTRY" "$APP_REPO" "$SOURCE_TAG" "$ECR_TAG" + else + docker buildx imagetools create \ + -t "${REGISTRY}/${repo}:${ECR_TAG}" \ + "${REGISTRY}/${repo}:${SOURCE_TAG}" + fi done + # Promote the parked Trigger.dev version after observing the ECS + # traffic cutover (CodeDeploy AllowTraffic on every target). The image retag + # triggers the ECS pipeline; this job correlates it via the digest + retag epoch + # (rejecting a stale execution reusing the digest) and promotes at cutover. + # Skipped when promote-images skipped the tag move (stale run) — tasks then + # correctly stay on the old version. If the app deploy fails or never cuts over, + # promote never fires and this job fails visibly. + promote-trigger: + name: Promote Trigger.dev + needs: [promote-images, prepare-trigger] + # Explicit results also suppress skip propagation from optional ancestors. + if: >- + !cancelled() && + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') && + needs.promote-images.result == 'success' && + needs.prepare-trigger.result == 'success' && + needs.promote-images.outputs.promoted == 'true' + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + # Leave setup/promotion headroom above the cutover poll (dev: 20 min; + # staging/prod: 70 min, including a deploy queued behind a long bake). + timeout-minutes: ${{ github.ref == 'refs/heads/dev' && 40 || 90 }} + permissions: + contents: read + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.1 + + - name: Cache Bun dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + ~/.bun/install/cache + node_modules + **/node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 + with: + role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} + aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }} + # Match each environment's session budget; both outlast their polls. + role-duration-seconds: ${{ github.ref == 'refs/heads/dev' && 2400 || 5400 }} + + # An unchanged tag may belong to a failed or still-running earlier deploy. + # Verify its latest cutover rather than treating tag equality as success. + - name: Wait for ECS traffic cutover + env: + OVERALL_TIMEOUT: ${{ github.ref == 'refs/heads/dev' && 1200 || 4200 }} + APP_IMAGE_CHANGED: ${{ needs.promote-images.outputs.app_image_changed }} + DIGEST: ${{ needs.promote-images.outputs.app_image_digest }} + PIPELINE: sim-${{ github.ref == 'refs/heads/main' && 'production' || github.ref == 'refs/heads/dev' && 'dev' || 'staging' }}-us-east-1-app-deployment + RETAG_EPOCH: ${{ needs.promote-images.outputs.retag_epoch }} + run: | + set -eo pipefail + case "$APP_IMAGE_CHANGED" in + true) ;; + false) RETAG_EPOCH=0 ;; + *) echo "ERROR: invalid app image change metadata" >&2; exit 1 ;; + esac + bash .github/scripts/wait-for-ecs-cutover.sh "$PIPELINE" "$DIGEST" "$RETAG_EPOCH" + + - name: Promote Trigger.dev version + working-directory: ./apps/sim + env: + TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} + TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} + TRIGGER_ENV: ${{ needs.prepare-trigger.outputs.environment }} + TRIGGER_BRANCH: ${{ needs.prepare-trigger.outputs.preview_branch }} + VERSION: ${{ needs.prepare-trigger.outputs.version }} + run: | + set -eo pipefail + if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then + echo "ERROR: TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2 + exit 1 + fi + if [ -z "$VERSION" ]; then + echo "ERROR: no deployed version passed from prepare-trigger" >&2 + exit 1 + fi + echo "Promoting Trigger.dev version $VERSION ($TRIGGER_ENV)" + TARGET_ARGS=(--env "$TRIGGER_ENV") + if [ -n "$TRIGGER_BRANCH" ]; then + TARGET_ARGS+=(--branch "$TRIGGER_BRANCH") + fi + bunx trigger.dev@4.5.12 promote "$VERSION" "${TARGET_ARGS[@]}" + # Build ARM64 images for GHCR (main branch only, runs in parallel with # tests). Pushes only the immutable sha tag — latest-arm64/version-arm64 # are applied by create-ghcr-manifests after the gate, so a failing run diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index faa2b464522..5b06c42bc14 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -71,6 +71,8 @@ jobs: if [ "${ENVIRONMENT}" = "dev" ]; then echo "Dev environment — pushing schema directly (db:push)" + # Dev deliberately forces direct schema reconciliation; staging and + # production use guarded versioned migrations in the other branch. # drizzle-kit push needs a TTY to resolve ambiguous renames (--force only # covers data-loss). In CI it throws "Interactive prompts require a TTY # terminal" but still exits 0, so the job goes green without applying the @@ -81,7 +83,6 @@ jobs: echo "ERROR: db:push needs an interactive rename decision; land it as a versioned migration instead of relying on push." >&2 exit 1 fi - bun run ./scripts/apply-dev-workspace-file-size-cutover.ts else echo "Applying versioned migrations (db:migrate)" bun run ./scripts/migrate.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index d7122f1e889..191e29a9bce 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -17,6 +17,15 @@ jobs: matrix: provision: [push, migrate] services: + redis: + image: redis:8.2-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 postgres: image: pgvector/pgvector:pg17 env: @@ -85,6 +94,12 @@ jobs: working-directory: packages/db run: bun run db:migrate + - name: Verify retired-column contract migration in PostgreSQL + working-directory: packages/db + env: + RETIRED_COLUMNS_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + run: bunx vitest run scripts/retired-columns.postgres.test.ts + - name: Verify OAuth lifecycle and SCIM membership guards in PostgreSQL working-directory: apps/sim # These suites share a schema and install triggers; parallel files can deadlock DDL against cleanup. @@ -99,11 +114,23 @@ jobs: lib/auth/sso/application/admit-sso-user.postgres.test.ts lib/auth/sso/primary-provider.postgres.test.ts - - name: Verify cumulative billing timeout recovery in PostgreSQL + - name: Verify billing and organization activity in PostgreSQL working-directory: apps/sim env: BILLING_USAGE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - run: bunx vitest run lib/billing/core/usage-log.postgres.test.ts + BILLING_USAGE_TEST_REDIS_URL: redis://127.0.0.1:6379 + run: >- + bunx vitest run + lib/billing/core/usage-log.postgres.test.ts + lib/billing/core/organization-activity.postgres.test.ts + lib/billing/core/usage-analytics-queries.postgres.test.ts + lib/billing/calculations/usage-reservation.test.ts + + - name: Verify fork previews ignore execution file history in PostgreSQL + working-directory: apps/sim + env: + FORK_REVISION_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + run: bunx vitest run ee/workspace-forking/application/revision.postgres.test.ts - name: Verify cumulative billing timeout recovery on PostgreSQL 16 if: matrix.provision == 'push' @@ -112,6 +139,13 @@ jobs: BILLING_USAGE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5433/sim_billing_test run: bunx vitest run lib/billing/core/usage-log.postgres.test.ts + - name: Verify file search dispatch deadlines on PostgreSQL 17 + working-directory: apps/sim + env: + TZ: America/Los_Angeles + KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + run: bunx vitest run --mode integration lib/workspace-files/search/dispatcher.integration.ts + - name: Verify SCIM and administration over real HTTP working-directory: apps/sim env: @@ -199,6 +233,7 @@ jobs: lib/knowledge/__integration__/search-source-progress.integration.ts lib/knowledge/__integration__/search-source-pagination.integration.ts lib/knowledge/__integration__/search-reference-batching.integration.ts + lib/knowledge/__integration__/kb-block-search.integration.ts lib/core/outbox/service.integration.ts lib/knowledge/__integration__/connector-upload.integration.ts lib/uploads/contexts/organization-logo/application.integration.ts diff --git a/README.md b/README.md index 78c103799e2..f25af677768 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,6 @@ npx sim-setup ``` -Open [http://localhost:3000](http://localhost:3000) - ### Desktop: [macOS](https://sim.ai/api/desktop/update/download) Download Sim for macOS diff --git a/apps/desktop/e2e/browser-tools.spec.ts b/apps/desktop/e2e/browser-tools.spec.ts index d7edc1bf521..2f4eaf9b148 100644 --- a/apps/desktop/e2e/browser-tools.spec.ts +++ b/apps/desktop/e2e/browser-tools.spec.ts @@ -18,7 +18,15 @@ const SCOPE = 'browser-tools-e2e' const FORM = `Form fixture + + + + + + + + Other website @@ -27,6 +35,23 @@ const FORM = `Form fixture ` +const CLICK_FIXTURE = `Click fixture + + +` + test.describe('browser tools', () => { const calls = new Map< string, @@ -56,9 +81,11 @@ test.describe('browser tools', () => { } response.writeHead(200, { 'Content-Type': 'text/html' }) response.end( - path === '/form' - ? FORM - : 'Sim fixture

Browser tools fixture

' + path === '/click' + ? CLICK_FIXTURE + : path === '/form' + ? FORM + : 'Sim fixture

Browser tools fixture

' ) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) @@ -69,7 +96,7 @@ test.describe('browser tools', () => { test.beforeEach(async () => { app = await electron.launch({ - args: ['.'], + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], cwd: DESKTOP_DIR, env: { ...process.env, @@ -78,15 +105,27 @@ test.describe('browser tools', () => { }, }) window = await app.firstWindow() + await app.evaluate(({ app, BrowserWindow }) => { + const host = BrowserWindow.getAllWindows()[0] + host.webContents.setBackgroundThrottling(false) + app.focus({ steal: true }) + host.focus() + }) + await expect + .poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFocused())) + .toBe(true) await expect(window.getByRole('heading')).toHaveText('Browser tools fixture') await window.evaluate(async (scope) => { const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop await api.browserAgent.activateScope(scope) - api.browserAgent.setPanelBounds( - { x: 0, y: 80, width: innerWidth, height: innerHeight - 80 }, - null, - scope - ) + const updateBounds = () => + api.browserAgent.setPanelBounds( + { x: 0, y: 80, width: innerWidth, height: innerHeight - 80 }, + null, + scope + ) + updateBounds() + setInterval(updateBounds, 200) }, SCOPE) }) @@ -119,7 +158,9 @@ test.describe('browser tools', () => { const result = response.result as { snapshot: { outline: string } } expect(result.snapshot.outline).toContain('Name') return (name: string) => { - const line = result.snapshot.outline.split('\n').find((line) => line.includes(`"${name}"`)) + const line = result.snapshot.outline + .split('\n') + .find((line) => line.includes(`"${name}"`) && /\[ref=\d+\]/.test(line)) const match = line?.match(/\[ref=(\d+)\]/) if (!match) throw new Error(`No reference for ${name}: ${result.snapshot.outline}`) return Number(match[1]) @@ -143,6 +184,404 @@ test.describe('browser tools', () => { }, origin) } + test('sets and clears multiple selections without partial writes for invalid options', async () => { + const ref = await openForm() + const selected = await execute('browser_select_option', { + elementId: ref('Regions'), + values: ['A', 'B'], + }) + expect(selected.ok, selected.error).toBe(true) + expect(selected.result).toMatchObject({ + values: ['a', 'b'], + effectObserved: true, + readback: { values: ['a', 'b'] }, + }) + const invalid = await execute('browser_select_option', { + elementId: ref('Regions'), + values: ['B', 'C'], + }) + expect(invalid.ok).toBe(false) + const values = await app.evaluate(async ({ webContents }, origin) => { + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL() === `${origin}/form`) + if (!contents) throw new Error('Missing form fixture') + return contents.executeJavaScript( + 'Array.from(document.getElementById("regions").selectedOptions, option => option.value)' + ) + }, origin) + expect(values).toEqual(['a', 'b']) + const cleared = await execute('browser_select_option', { + elementId: ref('Regions'), + values: [], + }) + expect(cleared.result).toMatchObject({ + values: [], + effectObserved: true, + readback: { values: [] }, + }) + }) + + test('fills structured native fields and leaves invalid dates unchanged', async () => { + const ref = await openForm() + for (const [name, text] of [ + ['Date', '2026-09-15'], + ['Time', '15:48'], + ['Appointment', '2026-09-15T15:48:00'], + ['Month', '2026-09'], + ['Week', '2026-W38'], + ['Color', '#AABBCC'], + ['Range', '75'], + ]) { + const response = await execute('browser_type', { elementId: ref(name), text }) + expect(response.ok, response.error).toBe(true) + expect(response.result).toMatchObject({ + trusted: false, + dispatched: true, + effectObserved: true, + }) + } + const invalid = await execute('browser_type', { elementId: ref('Date'), text: '2026-02-30' }) + expect(invalid.ok).toBe(false) + expect(invalid.error).toContain('Invalid value') + const state = await app.evaluate(async ({ webContents }, origin) => { + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL() === `${origin}/form`) + if (!contents) throw new Error('Missing form fixture') + return contents.executeJavaScript( + '({date:document.getElementById("date").value,time:document.getElementById("time").value,appointment:document.getElementById("appointment").value,month:document.getElementById("month").value,week:document.getElementById("week").value,color:document.getElementById("color").value,range:document.getElementById("range").value,events:document.getElementById("date").dataset.events})' + ) + }, origin) + expect(state).toEqual({ + date: '2026-09-15', + time: '15:48', + appointment: '2026-09-15T15:48', + month: '2026-09', + week: '2026-W38', + color: '#aabbcc', + range: '75', + events: '1', + }) + }) + + for (const mode of ['menu', 'sticky']) { + test(`clicks a ${mode} target without losing its identity`, async () => { + const opened = await execute('browser_open_url', { url: `${origin}/click?mode=${mode}` }) + expect(opened.ok, opened.error).toBe(true) + await app.evaluate( + async ({ webContents }, { origin, mode }) => { + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL().startsWith(`${origin}/click`)) + if (!contents) throw new Error('Missing click fixture') + await contents.executeJavaScript(` + history.scrollRestoration = 'manual'; + document.getElementById('target').style.top = ${mode === 'sticky' ? '1010' : 'innerHeight - 100'} + 'px'; + scrollTo(0, ${mode === 'sticky' ? '1000' : '0'}); + new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { + document.body.dataset.scrolls = '0'; document.body.dataset.armed = 'true'; resolve(); + }))) + `) + }, + { origin, mode } + ) + const snapshot = await execute('browser_snapshot', {}) + expect(snapshot.ok, snapshot.error).toBe(true) + const outline = (snapshot.result as { outline: string }).outline + const line = outline.split('\n').find((line) => line.includes('"Choose option"')) + const match = line?.match(/\[ref=(\d+)\]/) + if (!match) throw new Error(`Missing target: ${outline}`) + const result = await execute('browser_click', { elementId: Number(match[1]) }) + expect(result.ok, result.error).toBe(true) + const state = await app.evaluate(async ({ webContents }, origin) => { + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL().startsWith(`${origin}/click`)) + if (!contents) throw new Error('Missing click fixture') + return contents.executeJavaScript( + '({clicks:document.body.dataset.clicks,scrolls:document.body.dataset.scrolls,scrollY})' + ) + }, origin) + expect(state.clicks).toBe('1') + if (mode === 'menu') expect(state).toMatchObject({ scrolls: '0', scrollY: 0 }) + else expect(state.scrollY).toBeLessThan(1000) + }) + } + + test('recovers a permanently pending native capture without losing the page', async () => { + await openForm() + const before = await app.evaluate(async ({ webContents }, origin) => { + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL() === `${origin}/form`) + if (!contents) throw new Error('Missing capture fixture') + await contents.executeJavaScript(` + document.getElementById('name').value = 'Unsaved work'; + document.getElementById('name').focus(); + `) + contents.capturePage = () => new Promise(() => {}) + return { id: contents.id, url: contents.getURL() } + }, origin) + for (const [color, dominantChannel] of [ + ['rgb(240, 20, 30)', 0], + ['rgb(30, 40, 230)', 2], + ['rgb(20, 220, 50)', 1], + ] as const) { + await app.evaluate( + async ({ webContents }, { id, color }) => { + const contents = webContents.fromId(id) + if (!contents) throw new Error('Capture fixture was replaced') + await contents.executeJavaScript(` + document.body.style.background = ${JSON.stringify(color)}; + new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) + `) + }, + { id: before.id, color } + ) + const response = await execute('browser_screenshot', {}) + expect(response.ok, response.error).toBe(true) + const shot = response.result as { dataUrl: string } + const pixel = await app.evaluate(({ nativeImage }, dataUrl) => { + const bitmap = nativeImage.createFromDataURL(dataUrl).toBitmap() + return [bitmap[2], bitmap[1], bitmap[0]] + }, shot.dataUrl) + expect(pixel[dominantChannel]).toBeGreaterThan(180) + for (let channel = 0; channel < 3; channel++) { + if (channel !== dominantChannel) + expect(pixel[dominantChannel] - pixel[channel]).toBeGreaterThan(80) + } + } + const after = await app.evaluate(async ({ webContents }, id) => { + const contents = webContents.fromId(id) + if (!contents) throw new Error('Capture fixture was replaced') + return { + id: contents.id, + url: contents.getURL(), + page: await contents.executeJavaScript( + `({value:document.getElementById('name').value,focus:document.activeElement.id})` + ), + } + }, before.id) + expect(after).toEqual({ ...before, page: { value: 'Unsaved work', focus: 'name' } }) + }) + + test('maps a fractional narrow crop back to its actual viewport position', async () => { + await openForm() + const target = await app.evaluate(async ({ webContents }, origin) => { + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL() === `${origin}/form`) + if (!contents) throw new Error('Missing crop fixture') + return contents.executeJavaScript(` + const button = document.createElement('button'); + button.textContent = 'Narrow target'; + button.style.cssText = 'position:absolute;left:20.1px;top:60.1px;width:1.1px;height:100px;padding:0;border:0;overflow:hidden'; + button.onclick = () => { document.body.dataset.cropClicks = Number(document.body.dataset.cropClicks || 0) + 1 }; + document.body.append(button); + const rect = button.getBoundingClientRect(); + ({x:rect.x,y:rect.y,width:rect.width,height:rect.height,devicePixelRatio}); + `) as Promise<{ + x: number + y: number + width: number + height: number + devicePixelRatio: number + }> + }, origin) + const snapshot = await execute('browser_snapshot', {}) + expect(snapshot.ok, snapshot.error).toBe(true) + const line = (snapshot.result as { outline: string }).outline + .split('\n') + .find((line) => line.includes('"Narrow target"')) + const match = line?.match(/\[ref=(\d+)\]/) + if (!match) throw new Error('Missing narrow target reference') + const response = await execute('browser_screenshot', { elementId: Number(match[1]) }) + expect(response.ok, response.error).toBe(true) + const shot = response.result as { + imageSize: { width: number; height: number } + clip: { x: number; y: number; width: number; height: number } + scale: number + } + expect(shot.clip.x).toBeLessThanOrEqual(target.x) + expect(shot.clip.y).toBeLessThanOrEqual(target.y) + expect(shot.clip.x + shot.clip.width).toBeGreaterThanOrEqual(target.x + target.width) + expect(shot.clip.y + shot.clip.height).toBeGreaterThanOrEqual(target.y + target.height) + expect(target.x - shot.clip.x).toBeLessThan(1 / target.devicePixelRatio) + expect(target.y - shot.clip.y).toBeLessThan(1 / target.devicePixelRatio) + expect(shot.scale).toBeCloseTo(shot.imageSize.width / shot.clip.width) + const clicked = await execute('browser_click_at', { + x: shot.clip.x + shot.clip.width / 2, + y: shot.clip.y + shot.clip.height / 2, + }) + expect(clicked.ok, clicked.error).toBe(true) + const count = await app.evaluate(async ({ webContents }, origin) => { + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL() === `${origin}/form`) + return contents?.executeJavaScript('document.body.dataset.cropClicks') + }, origin) + expect(count).toBe('1') + }) + + for (const mode of ['hidden', 'minimized']) { + test(`recovers a stalled capture after restoring a ${mode} window`, async () => { + test.skip(mode === 'minimized' && process.platform !== 'darwin', 'Requires minimize events') + await openForm() + await app.evaluate( + async ({ BrowserWindow, webContents }, { origin, mode }) => { + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL() === `${origin}/form`) + if (!contents) throw new Error('Missing capture fixture') + contents.capturePage = () => new Promise(() => {}) + await contents.executeJavaScript("document.getElementById('name').value = 'Unsaved work'") + const win = BrowserWindow.getAllWindows()[0] + win.blur() + if (mode === 'hidden') win.hide() + else { + const minimized = new Promise((resolve) => win.once('minimize', resolve)) + win.minimize() + await minimized + } + }, + { origin, mode } + ) + const state = () => + app.evaluate(async ({ BrowserWindow, webContents }, origin) => { + const win = BrowserWindow.getAllWindows()[0] + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL() === `${origin}/form`) + if (!contents) throw new Error('Missing capture fixture') + return { + id: contents.id, + visible: win.isVisible(), + minimized: win.isMinimized(), + focused: BrowserWindow.getFocusedWindow()?.id ?? null, + bounds: win.getBounds(), + value: await contents.executeJavaScript("document.getElementById('name').value"), + } + }, origin) + const before = await state() + const start = Date.now() + const hiddenCapture = await execute('browser_screenshot', {}) + expect(Date.now() - start).toBeLessThan(12_000) + if (!hiddenCapture.ok) + expect(hiddenCapture.error).toContain('Screenshot frame capture timed out') + expect(await state()).toEqual(before) + await app.evaluate(({ BrowserWindow }, mode) => { + const win = BrowserWindow.getAllWindows()[0] + if (mode === 'minimized') win.restore() + else win.showInactive() + }, mode) + for (let attempt = 0; attempt < 2; attempt++) { + const response = await execute('browser_screenshot', {}) + expect(response.ok, response.error).toBe(true) + } + expect(await state()).toMatchObject({ id: before.id, value: 'Unsaved work' }) + }) + } + + for (const mode of ['visible', 'hidden', 'minimized']) { + test(`captures a ${mode} window without changing its state`, async () => { + test.skip( + mode === 'minimized' && process.platform !== 'darwin', + 'Requires a window manager with minimize events' + ) + await openForm() + await app.evaluate(async ({ BrowserWindow }, mode) => { + const win = BrowserWindow.getAllWindows()[0] + win.blur() + if (mode === 'hidden') win.hide() + if (mode === 'minimized') { + const minimized = new Promise((resolve) => win.once('minimize', () => resolve())) + win.minimize() + await minimized + } + }, mode) + const state = () => + app.evaluate(async ({ BrowserWindow, webContents }, origin) => { + const win = BrowserWindow.getAllWindows()[0] + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL() === `${origin}/form`) + if (!contents) throw new Error('Missing screenshot fixture') + return { + visible: win.isVisible(), + minimized: win.isMinimized(), + bounds: win.getBounds(), + focused: BrowserWindow.getFocusedWindow()?.id ?? null, + page: await contents.executeJavaScript( + '({width:innerWidth,height:innerHeight,scrollX,scrollY,html:document.body.innerHTML,focus:document.activeElement?.id})' + ), + } + }, origin) + const before = await state() + for (let i = 0; i < 3; i++) { + const response = await execute('browser_screenshot', {}) + expect(response.ok, response.error).toBe(true) + const shot = response.result as { + dataUrl: string + scale: number + viewport: { width: number; height: number } + } + expect(shot.dataUrl.length).toBeGreaterThan(1000) + expect(shot.viewport.width).toBeGreaterThan(0) + expect(shot.viewport.height).toBeGreaterThan(0) + const image = await app.evaluate(({ nativeImage }, dataUrl) => { + const image = nativeImage.createFromDataURL(dataUrl) + return { empty: image.isEmpty(), ...image.getSize() } + }, shot.dataUrl) + expect(image).toEqual({ + empty: false, + width: Math.round(shot.viewport.width * shot.scale), + height: Math.round(shot.viewport.height * shot.scale), + }) + expect(await state()).toEqual(before) + } + }) + } + + test('captures fresh pixels after resizing and repainting the viewport', async () => { + await openForm() + const viewportWidth = () => + app.evaluate(async ({ webContents }, origin) => { + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL() === `${origin}/form`) + return contents?.executeJavaScript('innerWidth') + }, origin) + const beforeWidth = await viewportWidth() + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1280, 900)) + await expect.poll(viewportWidth).not.toBe(beforeWidth) + for (const color of ['red', 'blue']) { + await app.evaluate( + async ({ webContents }, { origin, color }) => { + const contents = webContents + .getAllWebContents() + .find((wc) => wc.getURL() === `${origin}/form`) + if (!contents) throw new Error('Missing screenshot fixture') + await contents.executeJavaScript( + `document.body.style.background = ${JSON.stringify(color)}; + new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))` + ) + }, + { origin, color } + ) + const response = await execute('browser_screenshot', {}) + expect(response.ok, response.error).toBe(true) + const shot = response.result as { dataUrl: string } + const pixel = await app.evaluate(({ nativeImage }, dataUrl) => { + const image = nativeImage.createFromDataURL(dataUrl) + return Array.from(image.toBitmap().subarray(0, 4)) + }, shot.dataUrl) + const dominant = pixel[color === 'red' ? 2 : 0] + const other = pixel[color === 'red' ? 0 : 2] + expect(dominant - other, `${color}: ${pixel}`).toBeGreaterThan(150) + } + }) + test('opens with references, fills in order, and scrolls a horizontal pane', async () => { const ref = await openForm() const fill = await execute('browser_fill_form', { diff --git a/apps/desktop/src/main/browser-agent/cdp.test.ts b/apps/desktop/src/main/browser-agent/cdp.test.ts index c65c987f361..fdc0884e3f7 100644 --- a/apps/desktop/src/main/browser-agent/cdp.test.ts +++ b/apps/desktop/src/main/browser-agent/cdp.test.ts @@ -1,8 +1,14 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) -import { nativeImage, type WebContents, WebContentsView, type WebFrameMain } from 'electron' +import { + type NativeImage, + type nativeImage, + type WebContents, + WebContentsView, + type WebFrameMain, +} from 'electron' import { captureScreenshot, clickAt, @@ -491,13 +497,15 @@ describe('browser-agent CDP theme', () => { * snapping back. Resolution is bounded on the returned image instead. */ describe('browser-agent screenshot capture', () => { - function captureFixture(imageSize: { width: number; height: number } | null) { + function captureFixture( + imageSize: { width: number; height: number } | null, + imageContent = 'sim' + ) { const contents = new WebContentsView().webContents vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { if (method === 'Page.getLayoutMetrics') { return Promise.resolve({ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 } }) } - if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' }) return Promise.resolve(undefined) }) const resized = { @@ -509,25 +517,15 @@ describe('browser-agent screenshot capture', () => { resize: vi.fn(() => resized), toJPEG: vi.fn(() => Buffer.from('cropped')), } - // Shared module-level mock: without this, a later fixture reads the - // earlier test's decoded image. - vi.mocked(nativeImage.createFromBuffer).mockReset() - vi.mocked(nativeImage.createFromBuffer).mockReturnValue({ + const image = { isEmpty: vi.fn(() => imageSize === null), getSize: vi.fn(() => imageSize ?? { width: 0, height: 0 }), crop: vi.fn(() => cropped), resize: vi.fn(() => resized), - toJPEG: vi.fn(() => Buffer.alloc(0)), - } as unknown as ReturnType) - return { contents, resized, cropped } - } - - function screenshotParams(contents: WebContents): Record { - const call = vi - .mocked(contents.debugger.sendCommand) - .mock.calls.find(([method]) => method === 'Page.captureScreenshot') - if (!call) throw new Error('no capture was requested') - return call[1] as Record + toJPEG: vi.fn(() => Buffer.from(imageContent)), + } as unknown as ReturnType + vi.mocked(contents.capturePage).mockResolvedValue(image) + return { contents, resized, cropped, image } } it('never sends a clip, which would emulate the live page for the capture', async () => { @@ -535,16 +533,23 @@ describe('browser-agent screenshot capture', () => { await captureScreenshot(contents) - expect(screenshotParams(contents)).not.toHaveProperty('clip') + expect(contents.capturePage).toHaveBeenCalledWith(undefined, { stayHidden: true }) + expect(contents.debugger.sendCommand).not.toHaveBeenCalledWith( + 'Page.captureScreenshot', + expect.anything() + ) }) it('crops the decoded image in memory without sending a CDP clip', async () => { - const { contents, cropped } = captureFixture({ width: 4096, height: 2048 }) + const { contents, cropped, image } = captureFixture({ width: 4096, height: 2048 }) const shot = await captureScreenshot(contents, { x: 100, y: 50, width: 200, height: 100 }) - const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value - expect(screenshotParams(contents)).not.toHaveProperty('clip') + expect(contents.capturePage).toHaveBeenCalledWith(undefined, { stayHidden: true }) + expect(contents.debugger.sendCommand).not.toHaveBeenCalledWith( + 'Page.captureScreenshot', + expect.anything() + ) expect(image.crop).toHaveBeenCalledWith({ x: 200, y: 100, width: 400, height: 200 }) expect(cropped.resize).not.toHaveBeenCalled() expect(shot).toEqual({ @@ -552,9 +557,54 @@ describe('browser-agent screenshot capture', () => { scale: 2, viewport: { width: 2048, height: 1024 }, imageSize: { width: 400, height: 200 }, + clip: { x: 100, y: 50, width: 200, height: 100 }, }) }) + it('reports the actual CSS crop after rounding a narrow fractional element to pixels', async () => { + const { contents, cropped, image } = captureFixture({ width: 4096, height: 2048 }) + cropped.getSize.mockReturnValue({ width: 3, height: 201 }) + + const shot = await captureScreenshot(contents, { x: 0.1, y: 0.2, width: 1.1, height: 100 }) + + expect(image.crop).toHaveBeenCalledWith({ x: 0, y: 0, width: 3, height: 201 }) + expect(shot).toMatchObject({ + clip: { x: 0, y: 0, width: 1.5, height: 100.5 }, + imageSize: { width: 3, height: 201 }, + scale: 2, + }) + expect(100 / shot.scale).toBe(50) + expect(cropped.resize).not.toHaveBeenCalled() + }) + + it.each([ + { + requested: { x: -10, y: -20, width: 30, height: 40 }, + crop: { x: 0, y: 0, width: 40, height: 40 }, + captured: { x: 0, y: 0, width: 20, height: 20 }, + }, + { + requested: { x: 2040, y: 1020, width: 30, height: 40 }, + crop: { x: 4080, y: 2040, width: 16, height: 8 }, + captured: { x: 2040, y: 1020, width: 8, height: 4 }, + }, + ])( + 'reports only the encoded portion of a crop clamped to the viewport: $requested', + async ({ requested, crop, captured }) => { + const { contents, cropped, image } = captureFixture({ width: 4096, height: 2048 }) + cropped.getSize.mockReturnValue({ width: crop.width, height: crop.height }) + + const shot = await captureScreenshot(contents, requested) + + expect(image.crop).toHaveBeenCalledWith(crop) + expect(shot).toMatchObject({ + clip: captured, + imageSize: { width: crop.width, height: crop.height }, + scale: 2, + }) + } + ) + /** * A 2048px CSS viewport bounded to 1024px is scale 0.5, and the capture * arrives at device resolution (4096px on a 2x display). The resize is what @@ -562,11 +612,10 @@ describe('browser-agent screenshot capture', () => { * (cssX = imageX / scale) assumes. */ it('downscales the returned image to the CSS-relative size', async () => { - const { contents, resized } = captureFixture({ width: 4096, height: 2048 }) + const { contents, resized, image } = captureFixture({ width: 4096, height: 2048 }) const shot = await captureScreenshot(contents) - const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value expect(image.resize).toHaveBeenCalledWith({ width: 1024, height: 512, quality: 'good' }) expect(resized.toJPEG).toHaveBeenCalled() expect(shot).toEqual({ @@ -577,12 +626,11 @@ describe('browser-agent screenshot capture', () => { }) }) - it('skips the re-encode when the capture already matches the target size', async () => { - const { contents } = captureFixture({ width: 1024, height: 512 }) + it('skips resizing when the capture already matches the target size', async () => { + const { contents, image } = captureFixture({ width: 1024, height: 512 }) const shot = await captureScreenshot(contents) - const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value expect(image.resize).not.toHaveBeenCalled() expect(shot).toEqual({ dataUrl: 'data:image/jpeg;base64,c2lt', @@ -592,16 +640,265 @@ describe('browser-agent screenshot capture', () => { }) }) - it('returns the raw capture when the image cannot be decoded', async () => { + it('rejects an empty native capture', async () => { const { contents } = captureFixture(null) + await expect(captureScreenshot(contents)).rejects.toThrow('empty image') + }) - const shot = await captureScreenshot(contents) + describe('stalled native capture recovery', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) - expect(shot).toEqual({ - dataUrl: 'data:image/jpeg;base64,c2lt', - scale: 0.5, - viewport: { width: 2048, height: 1024 }, - imageSize: null, + function observeFrames(contents: WebContents) { + const frames: Array<(image: NativeImage) => void> = [] + vi.mocked(contents.beginFrameSubscription).mockImplementation((...args: unknown[]) => { + const callback = args.at(-1) as (image: NativeImage) => void + frames.push((image) => callback(image)) + }) + return frames + } + + it('recovers repeatedly with fresh frames without overlapping native surface copies', async () => { + const { contents } = captureFixture({ width: 1024, height: 512 }) + vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) + const frames = observeFrames(contents) + + for (let index = 0; index < 5; index++) { + const { image } = captureFixture({ width: 1024, height: 512 }, `frame-${index}`) + const capture = captureScreenshot(contents) + await vi.advanceTimersByTimeAsync(index === 0 ? 5_000 : 0) + expect(contents.capturePage).toHaveBeenCalledOnce() + expect(contents.beginFrameSubscription).toHaveBeenLastCalledWith( + false, + expect.any(Function) + ) + expect(frames).toHaveLength(index + 1) + frames[index](image) + await expect(capture).resolves.toMatchObject({ + dataUrl: `data:image/jpeg;base64,${Buffer.from(`frame-${index}`).toString('base64')}`, + imageSize: { width: 1024, height: 512 }, + }) + expect(contents.endFrameSubscription).toHaveBeenCalledTimes(index + 1) + expect(vi.getTimerCount()).toBe(0) + } + const registered = vi + .mocked(contents.once) + .mock.calls.filter(([event]) => String(event) === 'destroyed') + for (const [, listener] of registered) { + expect(contents.removeListener).toHaveBeenCalledWith('destroyed', listener) + } + expect(contents.reload).not.toHaveBeenCalled() + expect(contents.loadURL).not.toHaveBeenCalled() + }) + + it('bounds both waits and allows another frame attempt after a timeout', async () => { + const { contents, image } = captureFixture({ width: 1024, height: 512 }) + vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) + const frames = observeFrames(contents) + const failed = expect(captureScreenshot(contents)).rejects.toThrow('frame capture timed out') + await vi.advanceTimersByTimeAsync(9_999) + expect(contents.endFrameSubscription).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + await failed + expect(contents.endFrameSubscription).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + + const recovered = captureScreenshot(contents) + await vi.advanceTimersByTimeAsync(0) + expect(contents.capturePage).toHaveBeenCalledOnce() + frames[1](image) + await expect(recovered).resolves.toMatchObject({ imageSize: { width: 1024, height: 512 } }) + expect(contents.endFrameSubscription).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) + + it('ignores a timed-out frame callback while a later subscription is active', async () => { + const { contents, image } = captureFixture({ width: 1024, height: 512 }, 'fresh') + const stale = captureFixture({ width: 1024, height: 512 }, 'stale').image + vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) + const frames = observeFrames(contents) + const failed = expect(captureScreenshot(contents)).rejects.toThrow('frame capture timed out') + await vi.advanceTimersByTimeAsync(10_000) + await failed + + const recovered = captureScreenshot(contents) + const settled = vi.fn() + void recovered.then(settled) + await vi.advanceTimersByTimeAsync(0) + frames[0](stale) + await vi.advanceTimersByTimeAsync(0) + expect(settled).not.toHaveBeenCalled() + expect(contents.endFrameSubscription).toHaveBeenCalledOnce() + frames[1](image) + await expect(recovered).resolves.toMatchObject({ + dataUrl: `data:image/jpeg;base64,${Buffer.from('fresh').toString('base64')}`, + }) + expect(contents.endFrameSubscription).toHaveBeenCalledTimes(2) + }) + + it.each(['resolve', 'reject'] as const)( + 'ignores a late native %s and resumes native captures afterward', + async (outcome) => { + const { contents, image } = captureFixture({ width: 1024, height: 512 }, 'current') + const stale = captureFixture({ width: 1024, height: 512 }, 'stale').image + let settleNative: () => void = () => {} + vi.mocked(contents.capturePage).mockImplementationOnce( + () => + new Promise((resolve, reject) => { + settleNative = () => + outcome === 'resolve' ? resolve(stale) : reject(new Error('late failure')) + }) + ) + const frames = observeFrames(contents) + const capture = captureScreenshot(contents) + const settled = vi.fn() + void capture.then(settled) + await vi.advanceTimersByTimeAsync(5_000) + settleNative() + await vi.advanceTimersByTimeAsync(0) + expect(settled).not.toHaveBeenCalled() + expect(contents.endFrameSubscription).not.toHaveBeenCalled() + frames[0](image) + await expect(capture).resolves.toMatchObject({ + dataUrl: `data:image/jpeg;base64,${Buffer.from('current').toString('base64')}`, + }) + await expect(captureScreenshot(contents)).resolves.toMatchObject({ + dataUrl: `data:image/jpeg;base64,${Buffer.from('current').toString('base64')}`, + }) + expect(contents.capturePage).toHaveBeenCalledTimes(2) + expect(contents.beginFrameSubscription).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + } + ) + + it('rejects concurrent captures without replacing the active subscription or blocking another tab', async () => { + const { contents, image } = captureFixture({ width: 1024, height: 512 }) + const other = captureFixture({ width: 1024, height: 512 }) + vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) + const frames = observeFrames(contents) + const capture = captureScreenshot(contents) + await vi.advanceTimersByTimeAsync(0) + await expect(captureScreenshot(contents)).rejects.toThrow('already in progress') + expect(contents.capturePage).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(5_000) + await expect(captureScreenshot(contents)).rejects.toThrow('already in progress') + expect(contents.beginFrameSubscription).toHaveBeenCalledOnce() + expect(contents.endFrameSubscription).not.toHaveBeenCalled() + await expect(captureScreenshot(other.contents)).resolves.toMatchObject({ + imageSize: { width: 1024, height: 512 }, + }) + frames[0](image) + await capture + expect(contents.endFrameSubscription).toHaveBeenCalledOnce() + }) + + it.each(['cancel', 'destroy'] as const)( + 'releases frame resources on %s and ignores a subsequent frame', + async (reason) => { + const { contents, image } = captureFixture({ width: 1024, height: 512 }) + vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) + const frames = observeFrames(contents) + const controller = new AbortController() + const removeAbort = vi.spyOn(controller.signal, 'removeEventListener') + const failed = expect( + captureScreenshot(contents, undefined, controller.signal) + ).rejects.toThrow(reason === 'cancel' ? 'cancelled' : 'tab was closed') + await vi.advanceTimersByTimeAsync(5_000) + const destroyed = vi + .mocked(contents.once) + .mock.calls.filter(([event]) => String(event) === 'destroyed') + .at(-1)?.[1] as unknown as (() => void) | undefined + expect(destroyed).toBeDefined() + if (reason === 'cancel') controller.abort() + else { + vi.mocked(contents.isDestroyed).mockReturnValue(true) + destroyed?.() + } + await failed + expect(contents.removeListener).toHaveBeenCalledWith('destroyed', destroyed) + expect(removeAbort).toHaveBeenCalledTimes(2) + expect(contents.endFrameSubscription).toHaveBeenCalledTimes(reason === 'cancel' ? 1 : 0) + frames[0](image) + await vi.advanceTimersByTimeAsync(0) + expect(contents.endFrameSubscription).toHaveBeenCalledTimes(reason === 'cancel' ? 1 : 0) + expect(vi.getTimerCount()).toBe(0) + if (reason === 'cancel') { + const recovered = captureScreenshot(contents) + await vi.advanceTimersByTimeAsync(0) + frames[1](image) + await recovered + expect(contents.capturePage).toHaveBeenCalledOnce() + expect(contents.endFrameSubscription).toHaveBeenCalledTimes(2) + } + } + ) + + it.each(['beginFrameSubscription', 'invalidate'] as const)( + 'cleans up a synchronous %s failure and permits another frame attempt', + async (method) => { + const { contents, image } = captureFixture({ width: 1024, height: 512 }) + vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) + const frames = observeFrames(contents) + vi.mocked(contents[method]).mockImplementationOnce(() => { + throw new Error('frame setup failed') + }) + const failed = expect(captureScreenshot(contents)).rejects.toThrow('frame setup failed') + await vi.advanceTimersByTimeAsync(5_000) + await failed + expect(contents.endFrameSubscription).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + + const recovered = captureScreenshot(contents) + await vi.advanceTimersByTimeAsync(0) + frames.at(-1)?.(image) + await expect(recovered).resolves.toMatchObject({ imageSize: { width: 1024, height: 512 } }) + expect(contents.capturePage).toHaveBeenCalledOnce() + expect(contents.endFrameSubscription).toHaveBeenCalledTimes(2) + } + ) + }) + + it.each(['cancel', 'destroy'] as const)( + 'releases capture listeners and timer on %s', + async (reason) => { + vi.useFakeTimers() + try { + const { contents } = captureFixture({ width: 1024, height: 512 }) + vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) + const controller = new AbortController() + const failed = expect( + captureScreenshot(contents, undefined, controller.signal) + ).rejects.toThrow(reason === 'cancel' ? 'cancelled' : 'tab was closed') + await vi.advanceTimersByTimeAsync(0) + const destroyed = vi + .mocked(contents.once) + .mock.calls.find(([event]) => String(event) === 'destroyed')?.[1] as unknown as + | (() => void) + | undefined + expect(destroyed).toBeDefined() + if (reason === 'cancel') controller.abort() + else destroyed?.() + await failed + expect(contents.removeListener).toHaveBeenCalledWith('destroyed', destroyed) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + } + ) + + it('does not start capture after cancellation or keep a synchronous failure pending', async () => { + const { contents } = captureFixture({ width: 1024, height: 512 }) + const controller = new AbortController() + controller.abort() + await expect(captureScreenshot(contents, undefined, controller.signal)).rejects.toThrow() + expect(contents.capturePage).not.toHaveBeenCalled() + vi.mocked(contents.capturePage).mockImplementationOnce(() => { + throw new Error('native failure') + }) + await expect(captureScreenshot(contents)).rejects.toThrow('native failure') + await expect(captureScreenshot(contents)).resolves.toMatchObject({ + imageSize: { width: 1024, height: 512 }, }) }) @@ -611,7 +908,6 @@ describe('browser-agent screenshot capture', () => { if (method === 'Page.getLayoutMetrics') { return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } }) } - if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' }) return Promise.resolve(undefined) }) @@ -652,7 +948,6 @@ describe('browser-agent screenshot capture', () => { }, }) } - if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' }) return Promise.resolve(undefined) }) @@ -697,7 +992,7 @@ describe('browser-agent screenshot capture', () => { ], ['availability', {}, {}], ])( - 'rejects a capture when viewport %s change during CDP capture', + 'rejects a capture when viewport %s change during native capture', async (_label, before, after) => { const { contents } = captureFixture({ width: 1024, height: 512 }) let metricsRead = 0 @@ -706,7 +1001,6 @@ describe('browser-agent screenshot capture', () => { metricsRead++ return Promise.resolve(metricsRead === 1 ? before : after) } - if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' }) return Promise.resolve(undefined) }) diff --git a/apps/desktop/src/main/browser-agent/cdp.ts b/apps/desktop/src/main/browser-agent/cdp.ts index e966850aaf2..cef27dbd389 100644 --- a/apps/desktop/src/main/browser-agent/cdp.ts +++ b/apps/desktop/src/main/browser-agent/cdp.ts @@ -11,7 +11,7 @@ import type { BrowserTheme } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' -import { nativeImage, type WebContents, type WebFrameMain } from 'electron' +import type { NativeImage, WebContents, WebFrameMain } from 'electron' const logger = createLogger('BrowserAgentCdp') @@ -370,14 +370,13 @@ export async function evaluateInIsolatedFrame( */ const MAX_SCREENSHOT_EDGE = 1024 const SCREENSHOT_QUALITY = 70 -/** - * Quality of the intermediate capture, before the in-process downscale - * re-encodes at {@link SCREENSHOT_QUALITY}. Higher than the final quality so - * the two lossy passes together land near where one pass did — the model reads - * text out of these frames, and compression artifacts on glyphs cost more than - * the transient bytes do. - */ -const SCREENSHOT_CAPTURE_QUALITY = 90 +const UNSCALED_SCREENSHOT_QUALITY = 90 +const SCREENSHOT_CAPTURE_TIMEOUT_MS = 5_000 +/** Native surface copies cannot be cancelled; never accumulate them on a stalled tab. */ +const pendingScreenshotCaptures = new WeakSet() +const activeScreenshotCaptures = new WeakSet() + +class ScreenshotCaptureTimeoutError extends Error {} interface CdpViewport { clientWidth: number @@ -401,7 +400,8 @@ export interface ScreenshotCapture { dataUrl: string scale: number viewport: ScreenshotSize | null - imageSize: ScreenshotSize | null + imageSize: ScreenshotSize + clip?: ScreenshotClip } export interface ScreenshotClip { @@ -456,8 +456,105 @@ function sameScreenshotViewport( ) } +async function captureNativeViewportImage( + contents: WebContents, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + if (contents.isDestroyed()) throw new Error('The screenshot tab was closed') + pendingScreenshotCaptures.add(contents) + let timer: ReturnType | undefined + let onAbort = () => {} + let onDestroyed = () => {} + try { + const interrupted = new Promise((_resolve, reject) => { + onAbort = () => reject(new Error('Screenshot capture was cancelled')) + onDestroyed = () => reject(new Error('The screenshot tab was closed')) + signal?.addEventListener('abort', onAbort, { once: true }) + contents.once('destroyed', onDestroyed) + timer = setTimeout( + () => + reject( + new ScreenshotCaptureTimeoutError('Screenshot pixel capture timed out after 5 seconds') + ), + SCREENSHOT_CAPTURE_TIMEOUT_MS + ) + }) + const capture = (async () => { + try { + return await contents.capturePage(undefined, { stayHidden: true }) + } finally { + pendingScreenshotCaptures.delete(contents) + } + })() + return await Promise.race([capture, interrupted]) + } finally { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + contents.removeListener('destroyed', onDestroyed) + } +} + +/** Observes one complete frame; unlike a native surface copy, this wait can be cancelled. */ +async function captureViewportFrame( + contents: WebContents, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + if (contents.isDestroyed()) throw new Error('The screenshot tab was closed') + let timer: ReturnType | undefined + let onAbort = () => {} + let onDestroyed = () => {} + let subscribed = false + try { + return await new Promise((resolve, reject) => { + onAbort = () => reject(new Error('Screenshot capture was cancelled')) + onDestroyed = () => reject(new Error('The screenshot tab was closed')) + signal?.addEventListener('abort', onAbort, { once: true }) + contents.once('destroyed', onDestroyed) + timer = setTimeout( + () => reject(new Error('Screenshot frame capture timed out after 5 seconds')), + SCREENSHOT_CAPTURE_TIMEOUT_MS + ) + subscribed = true + contents.beginFrameSubscription(false, (image) => resolve(image)) + contents.invalidate() + }) + } finally { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + contents.removeListener('destroyed', onDestroyed) + if (subscribed && !contents.isDestroyed()) contents.endFrameSubscription() + } +} + +/** Captures pixels without reloading the page, changing geometry, or exposing a hidden window. */ +async function captureViewportImage( + contents: WebContents, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + if (contents.isDestroyed()) throw new Error('The screenshot tab was closed') + if (activeScreenshotCaptures.has(contents)) { + throw new Error('A screenshot capture is already in progress on this tab') + } + activeScreenshotCaptures.add(contents) + try { + if (!pendingScreenshotCaptures.has(contents)) { + try { + return await captureNativeViewportImage(contents, signal) + } catch (error) { + if (!(error instanceof ScreenshotCaptureTimeoutError)) throw error + } + } + return await captureViewportFrame(contents, signal) + } finally { + activeScreenshotCaptures.delete(contents) + } +} + /** - * Screenshot via CDP (works while the view is hidden), bounded in resolution. + * Native viewport capture, bounded in time and resolution. * * The capture is deliberately UNCLIPPED. Chromium implements `clip` by applying * device-emulation parameters (viewport offset and scale) to the widget and @@ -468,12 +565,14 @@ function sameScreenshotViewport( * snapshot capture refuses to scale a visible surface for the same reason. * * Bounding resolution therefore happens here instead, on the returned image. - * Optional element crops also happen in memory. Convert output coordinates - * with cssX = (clip?.x ?? 0) + imageX / scale, and the equivalent Y formula. + * Optional element crops also happen in memory. The returned clip records the + * rounded/clamped CSS bounds. Map each image axis using those bounds and the + * returned imageSize, since resizing can round the two dimensions differently. */ export async function captureScreenshot( contents: WebContents, - clip?: ScreenshotClip + clip?: ScreenshotClip, + signal?: AbortSignal ): Promise { const metrics = await send<{ cssLayoutViewport?: CdpViewport @@ -492,10 +591,7 @@ export async function captureScreenshot( const scale = width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1 - const result = await send<{ data: string }>(contents, 'Page.captureScreenshot', { - format: 'jpeg', - quality: SCREENSHOT_CAPTURE_QUALITY, - }) + const image = await captureViewportImage(contents, signal) const metricsAfterCapture = await send<{ cssLayoutViewport?: CdpViewport layoutViewport?: CdpViewport @@ -503,15 +599,12 @@ export async function captureScreenshot( if (!sameScreenshotViewport(captureViewport, screenshotViewportMetrics(metricsAfterCapture))) { throw new Error('The page viewport changed or could not be verified during screenshot capture') } - const captured = `data:image/jpeg;base64,${result.data}` const targetWidth = Math.round(width * scale) const targetHeight = Math.round(height * scale) - const image = nativeImage.createFromBuffer(Buffer.from(result.data, 'base64')) const size = image.isEmpty() ? { width: 0, height: 0 } : image.getSize() if (size.width === 0 || size.height === 0) { - if (clip) throw new Error('The screenshot could not be decoded for element cropping') - return { dataUrl: captured, scale, viewport: cssViewport, imageSize: null } + throw new Error('Screenshot pixel capture returned an empty image') } if (clip && cssViewport) { const xScale = size.width / cssViewport.width @@ -533,6 +626,12 @@ export async function captureScreenshot( if (croppedSize.width === 0 || croppedSize.height === 0) { throw new Error('The requested screenshot element produced an empty crop') } + const capturedClip = { + x: cropX / xScale, + y: cropY / yScale, + width: croppedSize.width / xScale, + height: croppedSize.height / yScale, + } const cropScale = Math.min( 1, MAX_SCREENSHOT_EDGE / Math.max(croppedSize.width, croppedSize.height) @@ -548,13 +647,19 @@ export async function captureScreenshot( const outputSize = output.getSize() return { dataUrl: `data:image/jpeg;base64,${output.toJPEG(SCREENSHOT_QUALITY).toString('base64')}`, - scale: outputSize.width / clip.width, + scale: outputSize.width / capturedClip.width, viewport: cssViewport, imageSize: outputSize, + clip: capturedClip, } } if (size.width === targetWidth && size.height === targetHeight) { - return { dataUrl: captured, scale, viewport: cssViewport, imageSize: size } + return { + dataUrl: `data:image/jpeg;base64,${image.toJPEG(UNSCALED_SCREENSHOT_QUALITY).toString('base64')}`, + scale, + viewport: cssViewport, + imageSize: size, + } } const resized = image.resize({ width: targetWidth, height: targetHeight, quality: 'good' }) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 5cb48a1cd3c..94ef24f1bba 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -1,10 +1,10 @@ import { BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS } from '@sim/browser-protocol' -import type { MenuItemConstructorOptions } from 'electron' +import type { MenuItemConstructorOptions, WebContents } from 'electron' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) -import { BrowserWindow, Menu, nativeImage } from 'electron' +import { BrowserWindow, Menu, type nativeImage } from 'electron' import * as cdp from '@/main/browser-agent/cdp' import * as driverModule from '@/main/browser-agent/driver' import * as session from '@/main/browser-agent/session' @@ -485,8 +485,11 @@ describe('executeTool', () => { ) await Promise.resolve() expect(captureScreenshot).toHaveBeenCalledOnce() + const signal = captureScreenshot.mock.calls[0][2] + expect(signal?.aborted).toBe(false) driver.disposeBrowserScope('chat-test') + expect(signal?.aborted).toBe(true) automationTab.mockClear() await expect(screenshot).resolves.toMatchObject({ ok: false, @@ -2142,6 +2145,50 @@ describe('credential protection', () => { return { contents, values, writes, dialogs, selectionReads: () => selectionReads } } + it.each([ + { values: ['a', 'b'], labels: ['A', 'B'], expected: true }, + { values: ['a'], labels: ['A'], expected: false }, + { values: ['a', 'b'], labels: ['A', 'Other'], expected: false }, + ])( + 'verifies the entire multiple selection %j', + async ({ values: readbackValues, labels, expected }) => { + const contents = await openPage() + respondWith(contents, { + selectOptionInElement: { + selected: 'A', + value: 'a', + values: ['a', 'b'], + labels: ['A', 'B'], + }, + readSelectElementState: { selected: 'A', value: 'a', values: readbackValues, labels }, + }) + const result = await driver.executeTool('chat-test', 'browser_select_option', { + elementId: 0, + values: ['a', 'b'], + }) + expect(result, JSON.stringify(result)).toMatchObject({ + ok: true, + result: { effectObserved: expected, readback: { values: readbackValues } }, + }) + } + ) + + it.each([ + { value: 'a', values: ['b'] }, + { values: [1] }, + { values: Array.from({ length: 101 }, () => 'a') }, + {}, + ])('rejects invalid selection arguments before dispatch', async (params) => { + const contents = await openPage() + vi.mocked(contents.executeJavaScript).mockClear() + const result = await driver.executeTool('chat-test', 'browser_select_option', { + elementId: 0, + ...params, + }) + expect(result.ok).toBe(false) + expect(contents.executeJavaScript).not.toHaveBeenCalled() + }) + const formFields = [ { elementId: 1, kind: 'select', value: 'first' }, { elementId: 2, kind: 'select', value: 'second' }, @@ -2282,8 +2329,11 @@ describe('credential protection', () => { expect(form.writes).toEqual([0]) }) - function mockScreenshotImage(size: { width: number; height: number } | null): void { - vi.mocked(nativeImage.createFromBuffer).mockReturnValueOnce({ + function mockScreenshotImage( + contents: WebContents, + size: { width: number; height: number } | null + ): void { + vi.mocked(contents.capturePage).mockResolvedValue({ isEmpty: vi.fn(() => size === null), getSize: vi.fn(() => size ?? { width: 0, height: 0 }), resize: vi.fn(() => ({ toJPEG: vi.fn(() => Buffer.from('resized')) })), @@ -2469,6 +2519,97 @@ describe('credential protection', () => { expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1) }) + it('sets structured input values without dispatching text or select-all keystrokes', async () => { + const contents = await openPage() + respondWith(contents, { + focusElementForTyping: { focused: true, kind: 'input', valueInput: true, x: 24, y: 48 }, + setFocusedInputValue: { dispatched: true }, + readActiveElementState: { activeElement: 'input', valueLength: 10 }, + readPageActionState: {}, + }) + const result = await driver.executeTool('chat-test', 'browser_type', { + elementId: 0, + text: '2026-09-15', + }) + expect(result).toMatchObject({ ok: true, result: { dispatched: true, trusted: false } }) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) + expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0) + }) + + it('does not retry a rejected structured value through synthetic typing', async () => { + const contents = await openPage() + respondWith(contents, { + focusElementForTyping: { focused: true, kind: 'input', valueInput: true, x: 24, y: 48 }, + setFocusedInputValue: { error: 'Invalid value; the field was not changed.' }, + readActiveElementState: {}, + readPageActionState: {}, + }) + const result = await driver.executeTool('chat-test', 'browser_type', { + elementId: 0, + text: 'invalid-date', + }) + expect(result).toMatchObject({ ok: false, error: expect.stringContaining('Invalid value') }) + expect( + vi + .mocked(contents.executeJavaScript) + .mock.calls.filter(([expression]) => isPageCall(String(expression), 'typeIntoElement')) + ).toHaveLength(0) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) + }) + + it('reports an interrupted structured write as uncertain without replaying it', async () => { + const contents = await openPage() + let writes = 0 + vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { + if (isPageCall(expression, 'focusElementForTyping')) + return Promise.resolve({ focused: true, valueInput: true, x: 24, y: 48 }) + if (isPageCall(expression, 'setFocusedInputValue')) { + writes++ + return Promise.reject(new Error('Execution context was destroyed')) + } + return Promise.resolve({}) + }) + const result = await driver.executeTool('chat-test', 'browser_type', { + elementId: 0, + text: '2026-09-15', + }) + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining('may have reached the field and was not retried'), + }) + expect(writes).toBe(1) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) + expect( + vi + .mocked(contents.executeJavaScript) + .mock.calls.some(([expression]) => isPageCall(String(expression), 'typeIntoElement')) + ).toBe(false) + }) + + it('refuses a field whose input mode changes before dispatch', async () => { + const contents = await openPage() + let reads = 0 + vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { + if (isPageCall(expression, 'focusElementForTyping')) + return Promise.resolve({ focused: true, valueInput: ++reads === 1, x: 24, y: 48 }) + return Promise.resolve({}) + }) + const result = await driver.executeTool('chat-test', 'browser_type', { + elementId: 0, + text: '2026-09-15', + }) + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining('field type changed'), + }) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) + expect( + vi + .mocked(contents.executeJavaScript) + .mock.calls.some(([expression]) => isPageCall(String(expression), 'setFocusedInputValue')) + ).toBe(false) + }) + it('accepts empty text and sends it through native insertion to clear a field', async () => { const contents = await openPage() respondWith(contents, { @@ -3366,7 +3507,7 @@ describe('credential protection', () => { const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 9999, y: 5 }) expect(result.ok).toBe(false) - expect(result.error).toMatch(/divide image pixels by its scale/) + expect(result.error).toMatch(/X\/Y coordinate mapping and crop origin/) }) it('inserts text into the focused editable at the caret', async () => { @@ -3966,7 +4107,11 @@ describe('credential protection', () => { try { const result = await driver.executeTool('chat-test', 'browser_screenshot', { elementId: 0 }) - expect(capture).toHaveBeenCalledWith(contents, { x: 20, y: 30, width: 200, height: 100 }) + expect(capture).toHaveBeenCalledWith( + contents, + { x: 20, y: 30, width: 200, height: 100 }, + expect.any(AbortSignal) + ) expect(result).toMatchObject({ ok: true, result: { element: 'button', clip: { x: 20, y: 30, width: 200, height: 100 } }, @@ -3976,6 +4121,44 @@ describe('credential protection', () => { } }) + it('returns the encoded crop geometry while checking the original element bounds for movement', async () => { + const contents = await openPage() + const measuredClip = { x: 0.1, y: 0.2, width: 1.1, height: 100 } + const capturedClip = { x: 0, y: 0, width: 1.5, height: 100.5 } + respondWith(contents, { + getElementScreenshotRect: { ...measuredClip, element: 'div', refRecovered: false }, + }) + const capture = vi.spyOn(cdp, 'captureScreenshot').mockResolvedValue({ + dataUrl: 'data:image/jpeg;base64,c2lt', + scale: 2, + viewport: { width: 800, height: 600 }, + imageSize: { width: 3, height: 201 }, + clip: capturedClip, + }) + + try { + const result = await driver.executeTool('chat-test', 'browser_screenshot', { elementId: 0 }) + + expect(capture).toHaveBeenCalledWith(contents, measuredClip, expect.any(AbortSignal)) + expect(result).toMatchObject({ + ok: true, + result: { + element: 'div', + clip: capturedClip, + scale: 2, + imageSize: { width: 3, height: 201 }, + }, + }) + expect( + vi + .mocked(contents.executeJavaScript) + .mock.calls.filter(([expression]) => isPageCall(expression, 'getElementScreenshotRect')) + ).toHaveLength(2) + } finally { + capture.mockRestore() + } + }) + it('rejects navigation during an element screenshot measurement', async () => { const contents = await openPage() vi.mocked(contents.executeJavaScript).mockImplementation(async (expression: string) => { @@ -4009,16 +4192,13 @@ describe('credential protection', () => { it('returns the screenshot scale for coordinate mapping', async () => { const contents = await openPage() - mockScreenshotImage({ width: 1024, height: 512 }) + mockScreenshotImage(contents, { width: 1024, height: 512 }) vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { if (method === 'Page.getLayoutMetrics') { return Promise.resolve({ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 }, }) } - if (method === 'Page.captureScreenshot') { - return Promise.resolve({ data: 'c2lt' }) - } return Promise.resolve(undefined) }) respondWith(contents, { getViewportInfo: { width: 2048, height: 1024 } }) @@ -4029,6 +4209,7 @@ describe('credential protection', () => { ok: true, result: { scale: 0.5, + imageSize: { width: 1024, height: 512 }, viewport: { url: 'https://example.com/login', title: 'Example', @@ -4046,14 +4227,11 @@ describe('credential protection', () => { it('uses the in-page CSS viewport when CDP exposes only deprecated device metrics', async () => { const contents = await openPage() - mockScreenshotImage({ width: 1024, height: 512 }) + mockScreenshotImage(contents, { width: 1024, height: 512 }) vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { if (method === 'Page.getLayoutMetrics') { return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } }) } - if (method === 'Page.captureScreenshot') { - return Promise.resolve({ data: 'c2lt' }) - } return Promise.resolve(undefined) }) respondWith(contents, { @@ -4102,12 +4280,11 @@ describe('credential protection', () => { const fullTitle = `Example ${'t'.repeat(600)}` vi.mocked(contents.getURL).mockReturnValue(fullUrl) vi.mocked(contents.getTitle).mockReturnValue(fullTitle) - mockScreenshotImage({ width: 1024, height: 512 }) + mockScreenshotImage(contents, { width: 1024, height: 512 }) vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { if (method === 'Page.getLayoutMetrics') { return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } }) } - if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' }) return Promise.resolve(undefined) }) respondWith(contents, { @@ -4135,33 +4312,31 @@ describe('credential protection', () => { }) }) - it('rejects an undecodable screenshot instead of returning an unverified scale', async () => { + it('rejects an empty screenshot instead of returning an unverified scale', async () => { const contents = await openPage() - mockScreenshotImage(null) + mockScreenshotImage(contents, null) vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { if (method === 'Page.getLayoutMetrics') { return Promise.resolve({ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 }, }) } - if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' }) return Promise.resolve(undefined) }) const result = await driver.executeTool('chat-test', 'browser_screenshot', {}) expect(result.ok).toBe(false) - expect(result.error).toMatch(/verify the screenshot dimensions/) + expect(result.error).toMatch(/empty image/) }) it('rejects a screenshot when no CSS viewport can be established', async () => { const contents = await openPage() - mockScreenshotImage({ width: 1024, height: 512 }) + mockScreenshotImage(contents, { width: 1024, height: 512 }) vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { if (method === 'Page.getLayoutMetrics') { return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } }) } - if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' }) return Promise.resolve(undefined) }) respondWith(contents, { getViewportInfo: null }) @@ -4174,12 +4349,11 @@ describe('credential protection', () => { it('rejects coordinate mapping when the viewport changes during capture', async () => { const contents = await openPage() - mockScreenshotImage({ width: 1024, height: 256 }) + mockScreenshotImage(contents, { width: 1024, height: 256 }) vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { if (method === 'Page.getLayoutMetrics') { return Promise.resolve({ layoutViewport: { clientWidth: 1024, clientHeight: 256 } }) } - if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' }) return Promise.resolve(undefined) }) respondWith(contents, { @@ -4199,20 +4373,22 @@ describe('credential protection', () => { it('rejects a screenshot when the document navigates during capture', async () => { const contents = await openPage() - mockScreenshotImage({ width: 1024, height: 512 }) + mockScreenshotImage(contents, { width: 1024, height: 512 }) vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { if (method === 'Page.getLayoutMetrics') { return Promise.resolve({ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 }, }) } - if (method === 'Page.captureScreenshot') { - emitContentsEvent(contents, 'did-navigate') - return Promise.resolve({ data: 'c2lt' }) - } return Promise.resolve(undefined) }) + const image = await contents.capturePage() + vi.mocked(contents.capturePage).mockImplementation(async () => { + emitContentsEvent(contents, 'did-navigate') + return image + }) + const result = await driver.executeTool('chat-test', 'browser_screenshot', {}) expect(result.ok).toBe(false) @@ -4223,7 +4399,7 @@ describe('credential protection', () => { 'rejects a screenshot when the page %s changes during capture', async (identityField) => { const contents = await openPage() - mockScreenshotImage({ width: 1024, height: 512 }) + mockScreenshotImage(contents, { width: 1024, height: 512 }) const initialUrl = contents.getURL() const initialTitle = contents.getTitle() let currentUrl = initialUrl @@ -4236,14 +4412,16 @@ describe('credential protection', () => { cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 }, }) } - if (method === 'Page.captureScreenshot') { - if (identityField === 'url') currentUrl = 'https://example.com/changed' - else currentTitle = 'Changed title' - return Promise.resolve({ data: 'c2lt' }) - } return Promise.resolve(undefined) }) + const image = await contents.capturePage() + vi.mocked(contents.capturePage).mockImplementation(async () => { + if (identityField === 'url') currentUrl = 'https://example.com/changed' + else currentTitle = 'Changed title' + return image + }) + const result = await driver.executeTool('chat-test', 'browser_screenshot', {}) expect(result.ok).toBe(false) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 807aaf81444..f593a1884ed 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -66,6 +66,7 @@ import { readSelectElementState, scrollPage, selectOptionInElement, + setFocusedInputValue, typeIntoElement, } from '@/main/browser-agent/page-functions' import * as session from '@/main/browser-agent/session' @@ -1236,7 +1237,7 @@ function unwrapPageResult(result: unknown): unknown { } if (code === 'outside-viewport') { throw new ToolError( - 'That point is outside the visible viewport. Coordinates are CSS pixels within the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale, and scroll the target into view first.' + "That point is outside the visible viewport. Coordinates are CSS pixels within the current viewport — when reading them off a browser_screenshot, follow its caption's X/Y coordinate mapping and crop origin, and scroll the target into view first." ) } if (code === 'ambiguous-editable') { @@ -1290,6 +1291,7 @@ function unwrapPageResult(result: unknown): unknown { `No option matched that label or value. Available options: ${options.join(', ')}` ) } + throw new ToolError(String(code)) } return result } @@ -2278,7 +2280,8 @@ async function executeToolInner( params: Record, assertCurrentExecution: () => void, executionDeadline: number | undefined, - invocationEpoch: number + invocationEpoch: number, + signal?: AbortSignal ): Promise { switch (tool) { case 'browser_navigate': { @@ -2630,15 +2633,11 @@ async function executeToolInner( } : undefined assertCaptureIsCurrent() - const shot = await cdp.captureScreenshot(contents, clip).catch((error) => { - logger.warn('Browser screenshot capture failed', { error: getErrorMessage(error) }) - return null - }) - if (!shot) { + const shot = await cdp.captureScreenshot(contents, clip, signal).catch((error) => { throw new ToolError( - 'Could not capture the page. Use browser_snapshot or browser_read_text instead.' + `Could not capture the page: ${getErrorMessage(error)}. Use browser_snapshot or browser_read_text instead.` ) - } + }) assertCaptureIsCurrent() if (elementId !== undefined && elementClip) { const currentClip = toRecord( @@ -2664,11 +2663,6 @@ async function executeToolInner( 'The screenshot result was too large to return safely. Use browser_snapshot or browser_read_text instead.' ) } - if (!shot.imageSize) { - throw new ToolError( - 'Could not verify the screenshot dimensions. Retry browser_screenshot or use browser_snapshot instead.' - ) - } const viewport = shot.viewport ? { url: capturedViewportUrl, @@ -2720,13 +2714,14 @@ async function executeToolInner( } return { dataUrl: shot.dataUrl, + imageSize: shot.imageSize, viewport, scale, ...(clip ? { element: elementClip?.element, refRecovered: elementClip?.refRecovered === true, - clip, + clip: shot.clip ?? clip, } : {}), } @@ -3344,16 +3339,19 @@ async function executeToolInner( assertCurrentExecution() assertElementActionCurrent(contents, elementId, target) } - let trusted = true + const valueInput = initialSurface.valueInput === true + let trusted = !valueInput let nativeInserted = false let nativeInsertAttempted = false try { assertCurrentExecution() assertElementActionCurrent(contents, elementId, target) - await dispatchKeyCombo( - contents, - parseKeyCombo(process.platform === 'darwin' ? 'Cmd+A' : 'Control+A') - ) + if (!valueInput) { + await dispatchKeyCombo( + contents, + parseKeyCombo(process.platform === 'darwin' ? 'Cmd+A' : 'Control+A') + ) + } // The guard above vetted the element we asked to focus, but the insert // below goes wherever focus actually is now, a round trip later. Login // forms that auto-advance from username to password move it in exactly @@ -3406,8 +3404,32 @@ async function executeToolInner( } assertCurrentExecution() assertElementActionCurrent(contents, elementId, target) + if ((finalSurface.valueInput === true) !== valueInput) { + throw new ToolError('The field type changed before input. Take a fresh browser_snapshot.') + } nativeInsertAttempted = true - await cdp.insertText(contents, text) + if (valueInput) { + const written = unwrapPageResult( + await execInPage( + target, + setFocusedInputValue, + [elementId, text], + false, + executionDeadline + ).catch((error) => { + throw new ToolError( + `The structured field write did not acknowledge completion (${getErrorMessage(error)}). It may have reached the field and was not retried; inspect the page before continuing.` + ) + }) + ) + if (!isRecordLike(written) || written.dispatched !== true) { + throw new ToolError( + 'The field did not acknowledge the value write. Inspect it before retrying.' + ) + } + } else { + await cdp.insertText(contents, text) + } nativeInserted = true let submitted = false @@ -3845,6 +3867,19 @@ async function executeToolInner( } case 'browser_select_option': { + const values = params.values + if (values !== undefined && params.value !== undefined) { + throw new ToolError('Provide value or values, not both.') + } + if ( + values !== undefined && + (!Array.isArray(values) || + values.length > 100 || + values.some((value) => typeof value !== 'string')) + ) { + throw new ToolError('values must be an array of at most 100 strings.') + } + const selection = values === undefined ? requireStr(params, 'value') : (values as string[]) const contents = session.requireAutomationTab().view.webContents const elementId = requireNum(params, 'elementId') const target = pageTargetForElement(contents, elementId) @@ -3880,7 +3915,7 @@ async function executeToolInner( await execInPage( target, selectOptionInElement, - [elementId, requireStr(params, 'value')], + [elementId, selection], false, executionDeadline ) @@ -3894,10 +3929,22 @@ async function executeToolInner( } await sleep(50) const state = unwrapPageResult(await execInPage(target, readSelectElementState, [elementId])) + const selectedValues = selected.values + const readbackValues = isRecordLike(state) ? state.values : undefined + const selectedLabels = selected.labels + const readbackLabels = isRecordLike(state) ? state.labels : undefined const effectObserved = isRecordLike(state) && selected.selected === state.selected && - selected.value === state.value + selected.value === state.value && + (!Array.isArray(selectedValues) || + (Array.isArray(readbackValues) && + selectedValues.length === readbackValues.length && + selectedValues.every((value, index) => value === readbackValues[index]) && + Array.isArray(selectedLabels) && + Array.isArray(readbackLabels) && + selectedLabels.length === readbackLabels.length && + selectedLabels.every((label, index) => label === readbackLabels[index]))) return { ...selected, effectObserved, @@ -4172,7 +4219,7 @@ async function executeToolInner( ) if (!isRecordLike(pointTarget) || pointTarget.found !== true) { throw new ToolError( - 'Nothing is rendered at that point. Coordinates are CSS pixels in the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale.' + "Nothing is rendered at that point. Coordinates are CSS pixels in the current viewport — when reading them off a browser_screenshot, follow its caption's X/Y coordinate mapping and crop origin." ) } if (pointTarget.fileInput === true) { @@ -4416,7 +4463,7 @@ async function executeToolInner( ) if (!isRecordLike(probe) || probe.found !== true) { throw new ToolError( - `Nothing is rendered at the ${which} point. Coordinates are CSS pixels in the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale.` + `Nothing is rendered at the ${which} point. Coordinates are CSS pixels in the current viewport — when reading them off a browser_screenshot, follow its caption's X/Y coordinate mapping and crop origin.` ) } return { @@ -4599,9 +4646,13 @@ export async function executeTool( throw new ToolError('This browser action was cancelled before it started.') } state.activeToolCallId = toolCallId ?? null + const executionController = new AbortController() let cancelActiveExecution: () => void = () => {} const cancellation = new Promise((_resolve, reject) => { - cancelActiveExecution = () => reject(new ToolError('This browser action was cancelled.')) + cancelActiveExecution = () => { + executionController.abort() + reject(new ToolError('This browser action was cancelled.')) + } }) state.activeToolCancel = cancelActiveExecution return await session.withBrowserScope(resolvedScopeId, async () => { @@ -4629,12 +4680,14 @@ export async function executeTool( params, assertCurrentExecution, executionDeadline, - invocationEpoch + invocationEpoch, + executionController.signal ) const guardedExecution = watchdogMs === null ? execution : raceAgainstWatchdog(execution, watchdogMs, () => { + executionController.abort() if (state.toolExecutionEpoch === executionEpoch) state.toolExecutionEpoch++ if ( tool === 'browser_snapshot' || @@ -4654,6 +4707,7 @@ export async function executeTool( }) return result } finally { + executionController.abort() if (keepHiddenPageActive && !state.disposed) { session.setAutomationActive(false) } diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index f5b3148ba3c..d7226734454 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -21,6 +21,7 @@ import { readSelectElementState, scrollPage, selectOptionInElement, + setFocusedInputValue, typeIntoElement, } from '@/main/browser-agent/page-functions' @@ -132,6 +133,72 @@ afterEach(() => { document.body.innerHTML = '' }) +describe('conditional click scrolling', () => { + it('leaves a reachable target in place', () => { + const target = visible(document.createElement('button')) + document.body.append(target) + register(target) + target.scrollIntoView = vi.fn() + expect(runSerialized(clickElement, [0, false])).toMatchObject({ x: 50, y: 10 }) + expect(target.scrollIntoView).not.toHaveBeenCalled() + }) + + it('rechecks the hit target after scrolling past a sticky obstruction', () => { + const target = visible(document.createElement('button')) + const obstruction = visible(document.createElement('div')) + document.body.append(target, obstruction) + register(target) + let scrolled = false + target.scrollIntoView = vi.fn(() => { + scrolled = true + }) + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: () => (scrolled ? target : obstruction), + }) + expect(runSerialized(clickElement, [0, false])).toMatchObject({ x: 50, y: 10 }) + expect(target.scrollIntoView).toHaveBeenCalledOnce() + }) + + it('reveals a parent control when only its nested button is initially reachable', () => { + const card = visible(document.createElement('div')) + card.setAttribute('role', 'button') + const nested = visible(document.createElement('button')) + card.append(nested) + document.body.append(card) + register(card) + let scrolled = false + card.scrollIntoView = vi.fn(() => { + scrolled = true + }) + const nestedClick = vi.fn() + nested.addEventListener('click', nestedClick) + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: () => (scrolled ? card : nested), + }) + expect(runSerialized(clickElement, [0, false])).toMatchObject({ x: 50, y: 10 }) + expect(card.scrollIntoView).toHaveBeenCalledOnce() + expect(nestedClick).not.toHaveBeenCalled() + }) + + it('rejects a target removed by scrolling without dispatching input', () => { + const target = visible(document.createElement('button')) + const obstruction = visible(document.createElement('div')) + document.body.append(target, obstruction) + register(target) + const click = vi.fn() + target.addEventListener('click', click) + target.scrollIntoView = () => target.remove() + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: () => obstruction, + }) + expect(runSerialized(clickElement, [0])).toMatchObject({ error: 'stale' }) + expect(click).not.toHaveBeenCalled() + }) +}) + describe('serialization contract', () => { // The driver ships each of these to the page as `String(fn)`, so a reference // to anything in module scope — a shared helper, an import, a constant — @@ -692,6 +759,23 @@ describe('collectSnapshot', () => { expect(lines[0]).not.toContain('[ref=999]') }) + it('shares the text budget across inline fragments and leaves room for later controls', () => { + document.body.innerHTML = `${Array.from( + { length: 650 }, + (_, index) => `

Before ${index} inline ${index} after ${index}

` + ).join( + '' + )}${Array.from({ length: 100 }, (_, index) => ``).join('')}` + for (const element of document.querySelectorAll('*')) visible(element) + + const snapshot = collectSnapshot() as { outline: string; truncated: boolean } + expect(snapshot.truncated).toBe(true) + expect(snapshot.outline.match(/^- text /gm)).toHaveLength(120) + expect(snapshot.outline.match(/^- button /gm)).toHaveLength(100) + expect(snapshot.outline).toMatch(/button "Action 99" \[ref=\d+\]/) + expect(snapshot.outline).toMatch(/textbox "Final field" \[ref=\d+\]/) + }) + it('indexes only refs that were emitted before snapshot line truncation', () => { document.body.innerHTML = `${Array.from( { length: 599 }, @@ -733,6 +817,55 @@ describe('collectSnapshot', () => { expect(clickElement(ref)).toEqual({ error: 'file-input' }) }) + it('sets a complete multiple selection atomically and can clear it', () => { + document.body.innerHTML = + '' + const select = document.querySelector('select') as HTMLSelectElement + register(select) + const events = vi.fn() + select.addEventListener('change', events) + expect(selectOptionInElement(0, ['B', 'D'])).toEqual({ error: 'disabled' }) + expect(readSelectElementState(0)).toMatchObject({ values: ['a'] }) + expect(events).not.toHaveBeenCalled() + expect(selectOptionInElement(0, ['C', 'missing'])).toMatchObject({ error: 'no-option' }) + expect(readSelectElementState(0)).toMatchObject({ values: ['a'] }) + expect(selectOptionInElement(0, ['C', 'B'])).toMatchObject({ values: ['b', 'c'] }) + expect(readSelectElementState(0)).toMatchObject({ values: ['b', 'c'] }) + expect(events).toHaveBeenCalledOnce() + expect(selectOptionInElement(0, [])).toMatchObject({ selected: '', value: '', values: [] }) + expect(readSelectElementState(0)).toMatchObject({ values: [] }) + }) + + it('captures requested labels before event handlers replace a duplicate-value option', () => { + document.body.innerHTML = + '' + const select = document.querySelector('select') as HTMLSelectElement + register(select) + select.addEventListener('change', () => { + select.options[1].selected = false + select.options[2].selected = true + select.options[1].label = 'Rewritten' + }) + expect(selectOptionInElement(0, ['Fixed', 'Wanted'])).toMatchObject({ + values: ['fixed', 'shared'], + labels: ['Fixed', 'Wanted'], + }) + expect(readSelectElementState(0)).toMatchObject({ + values: ['fixed', 'shared'], + labels: ['Fixed', 'Other'], + }) + }) + + it('does not use multiple-selection arguments on a single-selection dropdown', () => { + document.body.innerHTML = + '' + const select = document.querySelector('select') as HTMLSelectElement + register(select) + expect(selectOptionInElement(0, ['B'])).toHaveProperty('error') + expect(select.value).toBe('a') + expect(selectOptionInElement(0, 'B')).toMatchObject({ value: 'b' }) + }) + it('keeps plain visible leaf text available as an actionable ref', () => { document.body.innerHTML = '
announce
' visible(document.querySelector('span') as HTMLSpanElement) @@ -740,6 +873,64 @@ describe('collectSnapshot', () => { expect(outlineOf(collectSnapshot())).toContain('text "announce" [ref=') }) + it('preserves mixed inline text in reading order without duplicating control labels', () => { + document.body.innerHTML = + '
Type "hello" in upper case.
' + for (const el of document.querySelectorAll('body, div, strong, button, span, b')) visible(el) + const outline = outlineOf(collectSnapshot()) + const labels = Array.from(outline.matchAll(/- text ("(?:[^"\\]|\\.)*")/g), (match) => + JSON.parse(match[1]) + ) + expect(labels).toEqual(['Type "', 'hello', '" in upper case.']) + expect(outline).toContain('button "Save draft"') + expect(outline).not.toContain('Hidden') + }) + + it('does not emit stale textarea defaults after the current value changes', () => { + document.body.innerHTML = '' + const input = visible(document.querySelector('textarea') as HTMLTextAreaElement) + input.value = 'Current draft' + expect(outlineOf(collectSnapshot())).not.toContain('Old draft') + input.value = '' + expect(outlineOf(collectSnapshot())).not.toContain('Old draft') + }) + + it('preserves direct text in open shadow roots and respects hidden hosts', () => { + document.body.innerHTML = '
' + const host = visible(document.querySelector('div') as HTMLDivElement) + const shadow = host.attachShadow({ mode: 'open' }) + shadow.innerHTML = 'Before middle after' + visible(shadow.querySelector('strong') as HTMLElement) + const outline = outlineOf(collectSnapshot()) + expect(outline.indexOf('text "Before"')).toBeLessThan(outline.indexOf('text "middle"')) + expect(outline.indexOf('text "middle"')).toBeLessThan(outline.indexOf('text "after"')) + host.hidden = true + expect(outlineOf(collectSnapshot())).not.toContain('Before') + }) + + it('gives interactive headings actionable refs while preserving static headings', () => { + document.body.innerHTML = + '

Overview

' + for (const el of document.querySelectorAll('h3, h2')) visible(el) + const clicked = vi.fn() + document.querySelector('h3')?.addEventListener('click', clicked) + const outline = outlineOf(collectSnapshot()) + expect(outline).toContain('tab "Details"') + expect(outline).toContain('aria-expanded=false') + expect(outline).toContain('heading "Overview" (h2)') + expect(clickElement(refFor(outline, 'Details'))).toMatchObject({ dispatched: true }) + expect(clicked).toHaveBeenCalledOnce() + }) + + it('exposes structured input types and multiple-selection controls', () => { + document.body.innerHTML = + '' + for (const el of document.querySelectorAll('input, select')) visible(el) + const outline = outlineOf(collectSnapshot()) + expect(outline).toContain('type="date"') + expect(outline).toMatch(/combobox "Countries" \[ref=\d+\] multiple/) + }) + it('retains sender and timestamp text omitted from a row accessibility label', () => { document.body.innerHTML = `
@@ -2042,3 +2233,91 @@ describe('describeFocusedEditable', () => { expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'canvas' }) }) }) + +describe('setFocusedInputValue', () => { + for (const [type, value] of [ + ['date', '2026-09-15'], + ['time', '15:48'], + ['datetime-local', '2026-09-15T15:48'], + ['month', '2026-09'], + ['week', '2026-W38'], + ['color', '#aabbcc'], + ['range', '42'], + ]) { + it(`sets a validated ${type} value through the native setter`, () => { + document.body.innerHTML = `` + const input = visible(document.querySelector('input') as HTMLInputElement) + register(input) + input.focus() + const events: string[] = [] + input.addEventListener('input', () => events.push('input')) + input.addEventListener('change', () => events.push('change')) + expect(focusElementForTyping(0)).toMatchObject({ valueInput: true }) + expect(runSerialized(setFocusedInputValue, [0, value])).toEqual({ dispatched: true }) + expect(input.value).toBe(value) + expect(events).toEqual(['input', 'change']) + }) + } + + it('accepts native datetime normalization and bypasses an overridden value setter', () => { + document.body.innerHTML = '' + const input = document.querySelector('input') as HTMLInputElement + register(input) + input.focus() + const setter = vi.fn() + Object.defineProperty(input, 'value', { + configurable: true, + get() { + return Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.get?.call(this) + }, + set: setter, + }) + expect(setFocusedInputValue(0, '2026-09-15T15:48:00')).toEqual({ dispatched: true }) + expect(input.value).toBe('2026-09-15T15:48') + expect(setter).not.toHaveBeenCalled() + }) + + it('does not write to a newly focused input inside a registered container', () => { + document.body.innerHTML = '
' + const container = visible(document.querySelector('div') as HTMLDivElement) + visible(document.querySelector('input') as HTMLInputElement) + register(container) + expect(focusElementForTyping(0)).toMatchObject({ valueInput: true }) + const other = document.createElement('input') + other.type = 'date' + container.append(other) + other.focus() + expect(setFocusedInputValue(0, '2026-09-15')).toHaveProperty('error') + expect(other.value).toBe('') + }) + + it('rejects malformed values before changing the field or emitting events', () => { + document.body.innerHTML = '' + const input = document.querySelector('input') as HTMLInputElement + register(input) + input.focus() + const changed = vi.fn() + input.addEventListener('input', changed) + expect(setFocusedInputValue(0, '2026-02-30')).toMatchObject({ + error: expect.stringContaining('Invalid value'), + }) + expect(input.value).toBe('2026-01-01') + expect(changed).not.toHaveBeenCalled() + }) + + it('refuses changed focus, readonly fields, and credential hints', () => { + document.body.innerHTML = '' + const [input, other] = Array.from(document.querySelectorAll('input')) + register(input) + other.focus() + expect(setFocusedInputValue(0, '2026-09-15')).toEqual({ error: 'different' }) + input.focus() + input.readOnly = true + expect(setFocusedInputValue(0, '2026-09-15')).toEqual({ error: 'readonly' }) + input.readOnly = false + input.autocomplete = 'current-password' + expect(setFocusedInputValue(0, '2026-09-15')).toEqual({ error: 'password' }) + expect(input.value).toBe('') + expect(other.value).toBe('') + }) +}) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 419ae827182..f2e4ff93a4e 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -163,8 +163,8 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn const lines: string[] = [] let truncated = false let refCount = 0 - let textRefCount = 0 - const textRefCap = 120 + let textLineCount = 0 + const textLineCap = 120 let visitedNodes = 0 const previousElementId = window.__simAgentNextElementId const safePreviousElementId = @@ -480,6 +480,9 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn if (el.getAttribute('aria-required') === 'true') parts.push('aria-required') if (tag === 'INPUT') { const input = el as HTMLInputElement + if (!['text', 'checkbox', 'radio', 'submit', 'button', 'reset'].includes(input.type)) { + parts.push(`type=${quote(input.type)}`) + } if (input.type === 'checkbox' || input.type === 'radio') { parts.push(input.indeterminate ? 'mixed' : input.checked ? 'checked' : 'unchecked') } @@ -489,8 +492,9 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn const textarea = el as HTMLTextAreaElement if (textarea.readOnly) parts.push('readonly') if (textarea.required) parts.push('required') - } else if (tag === 'SELECT' && (el as HTMLSelectElement).required) { - parts.push('required') + } else if (tag === 'SELECT') { + if ((el as HTMLSelectElement).required) parts.push('required') + if ((el as HTMLSelectElement).multiple) parts.push('multiple') } for (const attribute of ['aria-checked', 'aria-expanded', 'aria-pressed', 'aria-selected']) { const value = el.getAttribute(attribute) @@ -506,7 +510,7 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn } const emitTextLeaf = (el: Element, indent: string, renderedLabel?: string): void => { - if (refCount >= refCap || textRefCount >= textRefCap || lines.length >= lineCap) { + if (refCount >= refCap || textLineCount >= textLineCap || lines.length >= lineCap) { truncated = true return } @@ -518,7 +522,7 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn ) if (!text) return const id = registerElement(el, roleFor(el), text) - textRefCount++ + textLineCount++ const lineIndex = lines.length if (push(`${indent}- text ${quote(text)} [ref=${id}]`)) refLineIndexes[id] = lineIndex } @@ -562,24 +566,47 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn ) } - const walk = (elements: Iterable, depth: number, suppressTextCoveredBy = ''): void => { + const walk = (nodes: Iterable, depth: number, suppressTextCoveredBy = ''): void => { if (refCount >= refCap || depth > depthCap) { truncated = true return } - for (const el of elements) { + for (const node of nodes) { visitedNodes++ if (refCount >= refCap || visitedNodes > nodeCap) { truncated = true return } + const indent = ' '.repeat(depth) + if (node.nodeType === Node.TEXT_NODE) { + const root = node.getRootNode() + const parent = node.parentElement ?? ('host' in root ? (root.host as Element) : null) + if (parent?.tagName.toUpperCase() === 'TEXTAREA') continue + const text = cut((node.textContent || '').replace(/\s+/g, ' ').trim(), 160) + if ( + text && + parent && + isVisible(parent) && + (!suppressTextCoveredBy || !suppressTextCoveredBy.includes(text)) + ) { + if (textLineCount >= textLineCap) { + truncated = true + continue + } + if (!push(`${indent}- text ${quote(text)}`)) return + textLineCount++ + } + continue + } + if (node.nodeType !== Node.ELEMENT_NODE) continue + const el = node as Element const tag = String(el.tagName || '').toUpperCase() if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue - const indent = ' '.repeat(depth) let childDepth = depth let emittedInteractive = false let interactiveName = '' + let emittedText = '' const visible = isVisible(el) if (el.matches(landmarkSelector) && visible) { @@ -587,15 +614,15 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn childDepth = depth + 1 } else { const level = headingLevel(el) - if (level !== null && visible) { - const text = cut(((el as HTMLElement).innerText || '').replace(/\s+/g, ' ').trim(), 160) - if (text) push(`${indent}- heading ${quote(text)} (h${level})`) - } else if (visible && (el.matches(interactiveSelector) || pointerBoundary(el))) { + if (visible && (el.matches(interactiveSelector) || pointerBoundary(el))) { emitInteractive(el, indent) emittedInteractive = true interactiveName = nameFor(el) // Interactive containers rarely nest other interactives; still // recurse so e.g. a clickable card exposes its inner links. + } else if (level !== null && visible) { + emittedText = cut(((el as HTMLElement).innerText || '').replace(/\s+/g, ' ').trim(), 160) + if (emittedText) push(`${indent}- heading ${quote(emittedText)} (h${level})`) } else if (visible) { const visibleElementChild = Array.from(el.children).some(isVisible) const leafLabel = visibleElementChild @@ -609,18 +636,21 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn (!suppressTextCoveredBy || !suppressTextCoveredBy.includes(leafLabel)) ) { emitTextLeaf(el, indent, leafLabel) + emittedText = leafLabel } } } - const coveredText = emittedInteractive ? interactiveName : suppressTextCoveredBy + const coveredText = emittedInteractive + ? interactiveName + : emittedText || suppressTextCoveredBy if (tag === 'IFRAME' || tag === 'FRAME') { try { const innerDoc = (el as HTMLIFrameElement).contentDocument if (innerDoc?.body && isVisible(el)) { if (!push(`${indent}- iframe:`)) return - walk(innerDoc.body.children, childDepth + 1, coveredText) + walk(innerDoc.body.childNodes, childDepth + 1, coveredText) } else if (scopedRoot && !innerDoc && visible) { truncated = true } @@ -631,13 +661,13 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn } const shadow = (el as HTMLElement).shadowRoot - if (shadow) walk(shadow.children, childDepth, coveredText) - walk(el.children, childDepth, coveredText) + if (shadow) walk(shadow.childNodes, childDepth, coveredText) + walk(el.childNodes, childDepth, coveredText) } } if (scopedRoot) walk([scopedRoot], 0) - else if (document.body) walk(document.body.children, 0) + else if (document.body) walk(document.body.childNodes, 0) /** * React commonly replaces a control's DOM node while preserving its @@ -929,7 +959,8 @@ export function clickElement( id: number, dispatchSynthetic = true, focusForKeyboard = false, - allowDisabled = false + allowDisabled = false, + scrollToTarget = false ): unknown { const isSecretField = (node: Element | null): boolean => { if (!node || String(node.tagName || '').toUpperCase() !== 'INPUT') return false @@ -974,7 +1005,10 @@ export function clickElement( return { error: 'file-input' } } } - el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }) + if (scrollToTarget) { + el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }) + if (!el.isConnected) return { error: 'stale', reason: window.__simAgentStaleReason } + } const view = el.ownerDocument.defaultView if (!view) return { error: 'stale', reason: window.__simAgentStaleReason } @@ -1021,7 +1055,11 @@ export function clickElement( rect.right - rect.left > 1 && rect.bottom - rect.top > 1 ) - if (rects.length === 0) return { error: 'not-visible' } + if (rects.length === 0) { + return scrollToTarget + ? { error: 'not-visible' } + : clickElement(id, dispatchSynthetic, focusForKeyboard, allowDisabled, true) + } const composedParent = (node: Element): Element | null => { if (node.parentElement) return node.parentElement @@ -1202,6 +1240,9 @@ export function clickElement( if (suggestionsCoverFocusedEditable()) { return { error: 'suggestions-open', blocker: blockerLabel(blocker) } } + if (!scrollToTarget) { + return clickElement(id, dispatchSynthetic, focusForKeyboard, allowDisabled, true) + } // A hit INSIDE the requested element is not an overlay — it is the ref // wrapping its own control (a row containing a button, a card containing a // link). hitBelongsToTarget rejects both cases identically, so this was @@ -1247,6 +1288,9 @@ export function clickElement( if (parentElementAt) { const parentHit: Element | null = parentElementAt(pageX, pageY) if (parentHit !== frame) { + if (!scrollToTarget) { + return clickElement(id, dispatchSynthetic, focusForKeyboard, allowDisabled, true) + } return { error: 'obstructed', blocker: blockerLabel(parentHit) } } } @@ -1340,6 +1384,7 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { .some((token) => token === 'current-password' || token === 'new-password') } + const valueInputTypes = ['date', 'time', 'datetime-local', 'month', 'week', 'color', 'range'] const resolver = window.__simAgentResolveElement const resolved = resolver?.(id) const el = resolver ? resolved?.element : (window.__simAgentElements || [])[id] @@ -1352,7 +1397,7 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { if (field.readOnly || field.getAttribute('aria-readonly') === 'true') return 'readonly' if (String(field.tagName || '').toUpperCase() === 'TEXTAREA') return 'writable' const type = String((field as HTMLInputElement).type || 'text').toLowerCase() - return ['text', 'search', 'email', 'url', 'tel', 'number'].includes(type) + return ['text', 'search', 'email', 'url', 'tel', 'number', ...valueInputTypes].includes(type) ? 'writable' : 'not-editable' } @@ -1366,7 +1411,16 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { if ( tag === 'TEXTAREA' || (tag === 'INPUT' && - ['text', 'search', 'email', 'url', 'tel', 'number', 'password'].includes(inputType)) || + [ + 'text', + 'search', + 'email', + 'url', + 'tel', + 'number', + 'password', + ...valueInputTypes, + ].includes(inputType)) || (node as HTMLElement).isContentEditable || // An ARIA-only textbox. The snapshot already advertises these as // `[textbox]` with a ref, and browser_insert_text accepts them, so @@ -1628,10 +1682,67 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { x: chosenPoint.x, y: chosenPoint.y, coveredByRelatedPopup, + valueInput: + editableTag === 'INPUT' && valueInputTypes.includes((editable as HTMLInputElement).type), refRecovered: resolved?.recovered === true, } } +/** Sets structured native inputs after the driver's ordinary typing actionability checks. */ +export function setFocusedInputValue(id: number, text: string): unknown { + const resolver = window.__simAgentResolveElement + const resolved = resolver?.(id) + const registered = resolver ? resolved?.element : (window.__simAgentElements || [])[id] + if (!registered?.isConnected) return { error: 'stale', reason: window.__simAgentStaleReason } + let active = registered.ownerDocument.activeElement + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement + if (String(registered.tagName || '').toUpperCase() !== 'INPUT') { + return { + error: + 'Structured inputs require the field reference itself, not a container. Take a fresh browser_snapshot.', + } + } + if (active !== registered) return { error: 'different' } + const input = active as HTMLInputElement + const type = input.type.toLowerCase() + const hints = (input.getAttribute('autocomplete') || '').toLowerCase().split(/\s+/) + if ( + type === 'password' || + hints.some((hint) => hint === 'current-password' || hint === 'new-password') + ) { + return { error: 'password' } + } + if (!['date', 'time', 'datetime-local', 'month', 'week', 'color', 'range'].includes(type)) { + return { + error: + 'The focused field no longer accepts a structured input value. Take a fresh browser_snapshot.', + } + } + if (input.matches(':disabled') || input.getAttribute('aria-disabled') === 'true') + return { error: 'disabled' } + if (input.readOnly || input.getAttribute('aria-readonly') === 'true') return { error: 'readonly' } + const value = type === 'color' ? text.trim().toLowerCase() : text.trim() + const probe = input.cloneNode(false) as HTMLInputElement + probe.value = value + if ( + (value !== '' && probe.value === '') || + (['color', 'range'].includes(type) && probe.value !== value) + ) { + return { + error: `Invalid value for input[type=${type}]. Use the native format; the field was not changed.`, + } + } + const view = input.ownerDocument.defaultView + if (!view) return { error: 'stale' } + const setter = Object.getOwnPropertyDescriptor(view.HTMLInputElement.prototype, 'value')?.set + if (!setter) + return { error: 'The native input value setter is unavailable; the field was not changed.' } + setter.call(input, probe.value) + input.dispatchEvent(new view.Event('input', { bubbles: true, composed: true })) + input.dispatchEvent(new view.Event('change', { bubbles: true })) + return { dispatched: true } +} + /** * Reads back the focused element's state after a native key/type action so * the driver can report what actually happened instead of assuming success. @@ -2657,42 +2768,74 @@ export function scrollPage(direction: string, amount?: number, elementId?: numbe } } -export function selectOptionInElement(id: number, value: string): unknown { +export function selectOptionInElement(id: number, value: string | string[]): unknown { const resolver = window.__simAgentResolveElement const resolved = resolver?.(id) const el = resolver ? resolved?.element : (window.__simAgentElements || [])[id] if (!el || !el.isConnected) return { error: 'stale', reason: window.__simAgentStaleReason } if (String(el.tagName || '').toUpperCase() !== 'SELECT') return { error: 'not-select' } const select = el as HTMLSelectElement - if (select.disabled || select.getAttribute('aria-disabled') === 'true') { + if (select.matches(':disabled') || select.getAttribute('aria-disabled') === 'true') { return { error: 'disabled' } } - const wanted = value.trim().toLowerCase() - const option = Array.from(select.options).find( - (o) => o.value.trim().toLowerCase() === wanted || o.label.trim().toLowerCase() === wanted - ) - if (!option) { + if (Array.isArray(value) && !select.multiple) { return { - error: 'no-option', - options: Array.from(select.options) - .slice(0, 50) - .map((o) => - o.label + error: + 'Use value for a single-selection dropdown; values requires a multiple-selection control.', + } + } + const requested = Array.isArray(value) ? value : [value] + if (requested.length > 100 || requested.some((entry) => typeof entry !== 'string')) { + return { error: 'A selection requires at most 100 string values.' } + } + const options = Array.from(select.options) + const chosen = new Set() + for (const entry of requested) { + const wanted = entry.trim().toLowerCase() + const option = options.find( + (candidate) => + candidate.value.trim().toLowerCase() === wanted || + candidate.label.trim().toLowerCase() === wanted + ) + if (!option) { + return { + error: 'no-option', + options: options.slice(0, 50).map((candidate) => + candidate.label .trim() .slice(0, 200) .replace(/[\uD800-\uDBFF]$/, '') ), + } } + if ( + option.disabled || + (option.parentElement as HTMLOptGroupElement | null)?.disabled === true + ) { + return { error: 'disabled' } + } + chosen.add(option) } - if (option.disabled || (option.parentElement as HTMLOptGroupElement | null)?.disabled === true) { - return { error: 'disabled' } + const selected = options.filter((option) => chosen.has(option)) + const selection = { + selected: selected[0]?.label.trim() || '', + value: selected[0]?.value || '', + ...(select.multiple + ? { + values: selected.map((option) => option.value), + labels: selected.map((option) => option.label.trim()), + } + : {}), + } + if (select.multiple) { + for (const option of options) option.selected = chosen.has(option) + } else { + select.value = selected[0].value } - select.value = option.value select.dispatchEvent(new Event('input', { bubbles: true })) select.dispatchEvent(new Event('change', { bubbles: true })) return { - selected: option.label.trim(), - value: option.value, + ...selection, refRecovered: resolved?.recovered === true, } } @@ -2804,9 +2947,19 @@ export function readSelectElementState(id: number): unknown { if (!el || !el.isConnected) return { error: 'stale', reason: window.__simAgentStaleReason } if (String(el.tagName || '').toUpperCase() !== 'SELECT') return { error: 'not-select' } const select = el as HTMLSelectElement + const values: string[] = [] + const labels: string[] = [] + if (select.multiple) { + for (const option of select.selectedOptions) { + values.push(option.value) + labels.push(option.label.trim()) + if (values.length > 100) break + } + } return { selected: select.selectedOptions[0]?.label.trim() || '', value: select.value, + ...(select.multiple ? { values, labels } : {}), } } diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index fea9849d085..bd5dbc9918f 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -173,6 +173,8 @@ function createWebContentsMock() { print: vi.fn(), focus: vi.fn(), invalidate: vi.fn(), + beginFrameSubscription: vi.fn(), + endFrameSubscription: vi.fn(), isFocused: vi.fn(() => false), close: vi.fn(), isDestroyed: vi.fn(() => false), diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 867a81af5c2..165cd584113 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2326,6 +2326,17 @@ export function AtlassianIcon(props: SVGProps) { ) } +export function CodaIcon(props: SVGProps) { + return ( + + + + ) +} + export function ConfluenceIcon(props: SVGProps) { const id = useId() const topGradientId = `confluence_top_${id}` diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 6178ca24e12..b01ec77b994 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -61,6 +61,7 @@ import { CloudflareIcon, CloudTrailIcon, CloudWatchIcon, + CodaIcon, CodeIcon, CodePipelineIcon, ConditionalIcon, @@ -358,6 +359,7 @@ export const blockTypeToIconMap: Record = { cloudformation: CloudFormationIcon, cloudtrail: CloudTrailIcon, cloudwatch: CloudWatchIcon, + coda: CodaIcon, codepipeline: CodePipelineIcon, condition: ConditionalIcon, confluence: ConfluenceIcon, diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index d793c3cae88..0a92390a38c 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -3052,7 +3052,7 @@ sim selectors get [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | | `--id ` | Yes | Resource identifier. | @@ -3072,7 +3072,7 @@ sim selectors list [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | | `--search ` | No | Provider option search text. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | diff --git a/apps/docs/content/docs/cli/selectors.mdx b/apps/docs/content/docs/cli/selectors.mdx index e728cb93a0e..4ff6c71849b 100644 --- a/apps/docs/content/docs/cli/selectors.mdx +++ b/apps/docs/content/docs/cli/selectors.mdx @@ -21,7 +21,7 @@ Get Selector Option (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | | `--id ` | Yes | Resource identifier. | @@ -41,7 +41,7 @@ List Selector Options (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | | `--search ` | No | Provider option search text. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | diff --git a/apps/docs/content/docs/integrations/coda.mdx b/apps/docs/content/docs/integrations/coda.mdx new file mode 100644 index 00000000000..74ca3e30686 --- /dev/null +++ b/apps/docs/content/docs/integrations/coda.mdx @@ -0,0 +1,1534 @@ +--- +title: Coda +description: Read and write Coda docs, pages, tables, and rows +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Coda](https://coda.io/) (now Superhuman Docs) combines documents, tables, and automations in one doc. The Coda block lets your agents read and write that doc: create and publish docs, write pages in Markdown or HTML, read and update table rows, push row buttons, read formulas and controls, trigger automations, and manage sharing, folders, and analytics. + +## Authentication + +This integration uses a reusable Coda **API token** connection, not OAuth. In Coda, open **Account settings → API settings** and generate a token. A token can be unrestricted, or limited to specific docs or tables with read or read-and-write access; a restricted token can only reach what it was granted. Create a Coda connection from the block's **Coda Account** field. Sim checks the token once against Coda, stores it encrypted, and reuses the same connection across Coda blocks. You can replace or revoke it from credential settings. + +## Working with Coda data + +- **Pickers:** After you pick an account, the **Doc**, **Page**, **Table**, **Row**, **Column**, **Formula**, **Control**, **Folder**, and **Permission** fields list what that token can reach. Switch a field to advanced mode to enter an ID, or pass one from an earlier block. IDs are safer than names, because users can rename things in the doc. +- **Links to IDs:** **Resolve Browser Link** turns a Coda URL someone pasted into the resource type and ID the other operations need. +- **Changes apply in the background:** Row, page, publishing, and automation writes return a `requestId` right away and are applied a few seconds later. Check that a change finished with **Get Mutation Status**. Data you read can also lag a few seconds behind edits made in the browser. +- **Rows:** Pass rows as objects that map column IDs or names to values, for example `[{"Name": "Apple", "Price": 1.25}]`. Set **Upsert Key Columns** to update matching rows instead of adding duplicates. Inserts only work on base tables, not views. Turn on **Use Column Names** when reading rows to get values keyed by column name. +- **Incremental reads:** **List Rows** returns a `nextSyncToken`. Pass it back later as **Sync Token** to get only rows that changed since then. +- **Pagination:** List operations return a `nextPageToken`. Pass it as **Page Token** to get the next page. When a Page Token is set, the original filters and limit are reused automatically. Coda can change page sizes at any time, so keep paging until no token comes back. +- **Page content:** **Get Page Content** reads a page as plain-text lines, with element IDs you can target in **Update Page** or **Delete Page Content**. To delete content, enter element IDs, or turn on **Delete All Page Content** to clear the whole page. For the full page as Markdown or HTML, run **Export Page**, then call **Get Page Export Status** until `downloadLink` is present. The link expires shortly after it is issued. +- **Permissions and plans:** Creating docs and pages or renaming a doc requires Doc Maker access in the workspace. Publishing needs a Coda maker profile. Hiding pages and custom domains need a paid Coda plan. Workspace members, role activity, and role changes need a workspace that belongs to an organization, and role changes need Admin access. Page analytics are only available for docs in Enterprise workspaces. +- **Rate limits:** Coda limits requests per user, with tighter limits on writes and on listing docs. Sim automatically retries reads, updates, and deletes when Coda returns HTTP 429 or a transient server error. Inserts and creates are not retried, so space out bulk writes. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Coda (Superhuman Docs) into your workflow with a reusable API-token credential. Create, copy, publish, and share docs; create, update, read, export, and clear pages; read table schemas; list, insert, upsert, update, and delete rows and push row buttons; read formulas and controls; trigger webhook automations; manage folders, custom domains, and workspace roles; and read doc and page analytics. Pick docs, pages, tables, rows, and more from dropdowns populated by your account. + + + +## Actions + +### Coda Add Custom Domain + +Connect a custom domain to a published Coda doc. Requires a Coda plan with custom domains. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `customDocDomain` | string | Yes | The custom domain \(e.g., "docs.example.com"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docId` | string | ID of the doc | +| `customDocDomain` | string | The custom domain that was added | + +### Coda Share Doc + +Share a Coda doc with a user, group, domain, workspace, or anyone with the link. Sharing with an email sends a notification unless suppressed. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `access` | string | Yes | Access level to grant: "readonly", "comment", or "write" | +| `principalType` | string | Yes | Who to share with: "email", "group", "domain", "workspace", or "anyone" | +| `principal` | string | No | Email address, group ID, domain, or workspace ID matching principalType. Not used for "anyone". | +| `suppressEmail` | boolean | No | Do not send a sharing notification email | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docId` | string | ID of the shared doc | +| `access` | string | Access level granted | +| `principalType` | string | Type of principal the doc was shared with | + +### Coda Change User Role + +Change the workspace role of a Coda user. Requires Admin access in a workspace that belongs to an organization. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(e.g., "ws-1Ab234"\) | +| `email` | string | Yes | Email address of the workspace member | +| `newRole` | string | Yes | New role: "Admin", "DocMaker", or "Editor" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `email` | string | Email address of the member | +| `newRole` | string | Role assigned | +| `roleChangedAt` | string | When the role change took effect | + +### Coda Create Doc + +Create a Coda doc, optionally copying an existing doc and setting up its first page with Markdown, HTML, an embed, or a sync page. Requires Doc Maker access in the workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `title` | string | No | Title of the new doc \(defaults to "Untitled"\) | +| `sourceDoc` | string | No | ID of an existing doc to copy | +| `timezone` | string | No | Timezone for the new doc \(e.g., "America/Los_Angeles"\) | +| `folderId` | string | No | ID of the folder to create the doc in \(defaults to "My docs"\) | +| `pageName` | string | No | Name of the initial page | +| `pageSubtitle` | string | No | Subtitle of the initial page | +| `iconName` | string | No | Icon name for the initial page \(e.g., "rocket"\) | +| `imageUrl` | string | No | Cover image URL for the initial page | +| `pageType` | string | No | Initial page content type: "canvas" \(default\), "embed", or "syncPage" | +| `contentFormat` | string | No | Canvas content format: "markdown" \(default\) or "html" | +| `content` | string | No | Canvas content for the initial page in the chosen format | +| `embedUrl` | string | No | URL to embed as a full page \(pageType "embed"\) | +| `renderMethod` | string | No | Embed render method: "standard" or "compatibility" | +| `sourceDocId` | string | No | Doc to sync from \(pageType "syncPage"\) | +| `sourcePageId` | string | No | Page to sync \(pageType "syncPage" with syncMode "page"\) | +| `syncMode` | string | No | Sync page mode: "page" \(default\) or "document" | +| `includeSubpages` | boolean | No | Include subpages in a single-page sync page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `doc` | object | The created doc | +| `requestId` | string | Coda request ID for the doc creation | + +### Coda Create Folder + +Create a folder in a Coda workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | Yes | Name of the folder | +| `workspaceId` | string | Yes | ID of the workspace \(e.g., "ws-1Ab234"\) | +| `description` | string | No | Description of the folder | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folder` | object | The created folder | + +### Coda Create Page + +Create a page in a Coda doc, optionally as a subpage, with Markdown or HTML content, a full-page embed, or a sync page from another doc. The page is created asynchronously. Requires Doc Maker access. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `name` | string | No | Name of the page | +| `subtitle` | string | No | Subtitle of the page | +| `iconName` | string | No | Name of the page icon \(e.g., "rocket"\) | +| `imageUrl` | string | No | URL of a cover image for the page | +| `parentPageId` | string | No | ID of the parent page, to create this page as a subpage | +| `pageType` | string | No | Page content type: "canvas" \(default\), "embed", or "syncPage" | +| `contentFormat` | string | No | Canvas content format: "markdown" \(default\) or "html" | +| `content` | string | No | Canvas page content in the chosen format | +| `embedUrl` | string | No | URL to embed as a full page \(pageType "embed"\) | +| `renderMethod` | string | No | Embed render method: "standard" or "compatibility" | +| `sourceDocId` | string | No | Doc to sync from \(pageType "syncPage"\) | +| `sourcePageId` | string | No | Page to sync \(pageType "syncPage" with syncMode "page"\) | +| `syncMode` | string | No | Sync page mode: "page" \(default\) or "document" | +| `includeSubpages` | boolean | No | Include subpages in a single-page sync page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pageId` | string | ID of the created page | + +### Coda Delete Custom Domain + +Remove a custom domain from a published Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `customDocDomain` | string | Yes | The custom domain \(e.g., "docs.example.com"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docId` | string | ID of the doc | +| `customDocDomain` | string | The custom domain that was removed | + +### Coda Delete Doc + +Delete a Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docId` | string | ID of the deleted doc | + +### Coda Delete Folder + +Delete an empty Coda folder (it must contain no docs) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `folderId` | string | Yes | ID of the folder \(e.g., "fl-1Ab234"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folderId` | string | ID of the deleted folder | + +### Coda Delete Page + +Delete a page from a Coda doc. Applied asynchronously. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `pageId` | string | Yes | ID or name of the page \(IDs are recommended, e.g., "canvas-IjkLmnO"; names containing "/" are not supported\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pageId` | string | ID of the deleted page | + +### Coda Delete Page Content + +Delete specific content elements from a Coda page, or all of its content when no element IDs are given. Applied asynchronously. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `pageId` | string | Yes | ID or name of the page \(IDs are recommended, e.g., "canvas-IjkLmnO"; names containing "/" are not supported\) | +| `elementIds` | json | No | Element IDs to delete \(from Get Page Content\), as an array or comma-separated list | +| `deleteAll` | boolean | No | Set to true, with no element IDs, to delete all content from the page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pageId` | string | ID of the page whose content was deleted | + +### Coda Remove Permission + +Revoke a sharing permission on a Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `permissionId` | string | Yes | ID of the permission to remove \(from List Permissions\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docId` | string | ID of the doc | +| `permissionId` | string | ID of the removed permission | + +### Coda Delete Row + +Delete a row from a Coda table or view. Applied asynchronously. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableId` | string | Yes | ID or name of the table or view \(IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported\) | +| `rowId` | string | Yes | ID or name of the row \(IDs are recommended, e.g., "i-tuVwxYz"; names containing "/" are not supported\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rowId` | string | ID of the deleted row | + +### Coda Delete Rows + +Delete multiple rows from a Coda table or view by ID. Applied asynchronously. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableId` | string | Yes | ID or name of the table or view \(IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported\) | +| `rowIds` | json | Yes | Row IDs to delete, as an array or comma-separated list \(e.g., \["i-bCdeFgh", "i-CdEfgHi"\]\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rowIds` | array | IDs of the rows queued for deletion | + +### Coda Export Page + +Start exporting a Coda page as HTML or Markdown. Poll Get Page Export Status with the returned export ID for the download link. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `pageId` | string | Yes | ID or name of the page \(IDs are recommended, e.g., "canvas-IjkLmnO"; names containing "/" are not supported\) | +| `outputFormat` | string | Yes | Export format: "markdown" or "html" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `exportId` | string | ID of the export request | +| `status` | string | Export status \(inProgress, failed, complete\) | +| `href` | string | API link that reports the export status | + +### Coda Get Sharing Settings + +Get the sharing settings of a Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docs` | json | Docs \(id, name, href, browserLink, icon, owner, ownerName, createdAt, updatedAt, workspace, folder, sourceDoc, docSize, published\) | +| `doc` | json | Doc \(id, name, href, browserLink, icon, owner, ownerName, createdAt, updatedAt, workspace \{id, name, organizationId\}, folder \{id, name\}, sourceDoc, docSize \{totalRowCount, tableAndViewCount, baseTableCount, pageCount, overApiSizeLimit\}, published \{description, browserLink, discoverable, mode, categories\}\) | +| `docId` | string | ID of the affected doc | +| `categories` | json | Doc category names | +| `requestId` | string | Request ID of a queued change, for Get Mutation Status | +| `customDomains` | json | Custom domains \(customDocDomain, hasCertificate, hasDnsDocId, setupStatus, domainStatus, lastVerifiedTimestamp\) | +| `customDocDomain` | string | Custom domain | +| `provider` | string | DNS provider of a custom domain | +| `permissions` | json | Permissions \(id, access, principal \{type, email, groupId, groupName, domain, workspaceId\}\) | +| `access` | string | Access level granted | +| `principalType` | string | Type of principal the doc was shared with | +| `permissionId` | string | ID of the removed permission | +| `users` | json | Matching users \(name, loginId, pictureLink\) | +| `groups` | json | Matching groups \(groupId, groupName\) | +| `canShare` | boolean | Whether the user can share the doc | +| `canShareWithWorkspace` | boolean | Whether the user can share with the workspace | +| `canShareWithOrg` | boolean | Whether the user can share with the org | +| `canCopy` | boolean | Whether the user can copy the doc | +| `allowEditorsToChangePermissions` | boolean | Whether editors can change permissions | +| `allowCopying` | boolean | Whether viewers can copy the doc | +| `allowViewersToRequestEditing` | boolean | Whether viewers can request editing | +| `pages` | json | Pages \(id, name, subtitle, browserLink, contentType, isHidden, icon, image, parent, children, authors, createdAt, updatedAt\) | +| `page` | json | Page \(id, name, subtitle, href, browserLink, contentType, isHidden, isEffectivelyHidden, icon, image, parent, children, authors, createdAt, createdBy, updatedAt, updatedBy\) | +| `pageId` | string | ID of the created, updated, or deleted page | +| `items` | json | Page content lines \(id, type, style, format, content, lineLevel\), or analytics items \(doc or page plus daily metrics\) | +| `exportId` | string | Page export ID | +| `status` | string | Page export status \(inProgress, failed, complete\) | +| `href` | string | API link reporting the page export status | +| `downloadLink` | string | Download link of a completed page export | +| `exportError` | string | Error message of a failed page export | +| `tables` | json | Tables and views \(id, name, tableType, href, browserLink, parent\) | +| `table` | json | Table \(id, name, tableType, browserLink, parent, parentTable, displayColumnId, rowCount, sorts, layout, filter, createdAt, updatedAt\) | +| `columns` | json | Columns \(id, name, display, calculated, formula, defaultValue, format\) | +| `column` | json | Column \(id, name, display, calculated, formula, defaultValue, format, parentTable\) | +| `rows` | json | Rows \(id, name, index, browserLink, createdAt, updatedAt, values\) | +| `row` | json | Row \(id, name, index, browserLink, createdAt, updatedAt, values, parentTable\) | +| `nextSyncToken` | string | Token for reading only rows changed later | +| `addedRowIds` | json | IDs of rows that will be added | +| `rowId` | string | ID of the affected row | +| `rowIds` | json | IDs of rows queued for deletion | +| `columnId` | string | ID of the pushed button column | +| `formulas` | json | Named formulas \(id, name, href, parent\) | +| `formula` | json | Formula \(id, name, href, parent, value\) | +| `controls` | json | Controls \(id, name, href, parent\) | +| `control` | json | Control \(id, name, href, parent, controlType, value\) | +| `folders` | json | Folders \(id, name, browserLink, description, icon, iconColor, createdAt, canEdit, workspace\) | +| `folder` | json | Folder \(id, name, browserLink, description, icon, iconColor, createdAt, canEdit, workspace\) | +| `folderId` | string | ID of the deleted folder | +| `children` | json | Subfolders \(id, name, browserLink, visibility, workspace, ...\) | +| `members` | json | Workspace members \(email, name, role, registeredAt, roleChangedAt, lastActiveAt, ownedDocs, totalDocs, ...\) | +| `email` | string | Email of the member whose role changed | +| `newRole` | string | Role assigned | +| `roleChangedAt` | string | When the role change took effect | +| `roleActivity` | json | Monthly role counts \(month, activeAdminCount, activeDocMakerCount, activeEditorCount, inactive counts\) | +| `totalSessions` | number | Total sessions across matching docs | +| `docAnalyticsLastUpdated` | string | Date doc analytics last updated | +| `packAnalyticsLastUpdated` | string | Date Pack analytics last updated | +| `packFormulaAnalyticsLastUpdated` | string | Date Pack formula analytics last updated | +| `browserLink` | string | Canonical browser link of a resolved resource | +| `resource` | json | Resolved resource \(type, id, name, href\) | +| `completed` | boolean | Whether a queued change was applied | +| `warning` | string | Warning for a change that completed with caveats | +| `name` | string | Name of the token owner | +| `loginId` | string | Email of the token owner | +| `pictureLink` | string | Avatar link of the token owner | +| `scoped` | boolean | Whether the token is restricted | +| `tokenName` | string | Name of the API token | +| `workspace` | json | Default workspace of the token owner \(id, name, organizationId, browserLink\) | +| `nextPageToken` | string | Token for fetching the next page of results | + +### Coda Get Analytics Last Updated + +Get the dates (Pacific time) Coda analytics were last refreshed, to know how current analytics data is + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docAnalyticsLastUpdated` | string | Date doc analytics last updated | +| `packAnalyticsLastUpdated` | string | Date Pack analytics last updated | +| `packFormulaAnalyticsLastUpdated` | string | Date Pack formula analytics last updated | + +### Coda Get Column + +Get details about a column in a Coda table, including its full format settings + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableId` | string | Yes | ID or name of the table or view \(IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported\) | +| `columnId` | string | Yes | ID or name of the column \(IDs are recommended, e.g., "c-tuVwxYz"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `column` | object | Column details | + +### Coda Get Control + +Get the type and current value of a control in a Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `controlId` | string | Yes | ID or name of the control \(IDs are recommended, e.g., "ctrl-cDefGhij"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `control` | object | Control details | +| ↳ `controlType` | string | Control type \(aiBlock, button, checkbox, datePicker, dateRangePicker, dateTimePicker, lookup, multiselect, select, scale, slider, reaction, textbox, timePicker\) | +| ↳ `value` | json | Current value \(string, number, boolean, or array of these\) | + +### Coda Get Custom Domain Provider + +Look up the DNS provider (GoDaddy, Namecheap, Hover, Network Solutions, Google Domains, or Other) of a custom domain + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `customDocDomain` | string | Yes | The custom domain \(e.g., "docs.example.com"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `customDocDomain` | string | The custom domain | +| `provider` | string | DNS provider of the domain | + +### Coda Get Doc + +Get metadata for a Coda doc, including its owner, workspace, folder, size, and publishing settings + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `doc` | object | Doc metadata | + +### Coda Get Doc Analytics Summary + +Get the total number of sessions across the Coda docs the user can access + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `isPublished` | boolean | No | Only include published docs | +| `sinceDate` | string | No | Only include activity on or after this date \(YYYY-MM-DD\) | +| `untilDate` | string | No | Only include activity on or before this date \(YYYY-MM-DD\) | +| `workspaceId` | string | No | Only include docs in this workspace | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalSessions` | number | Total sessions across all matching docs | + +### Coda Get Folder + +Get details about a Coda folder + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `folderId` | string | Yes | ID of the folder \(e.g., "fl-1Ab234"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folder` | object | Folder details | + +### Coda Get Formula + +Get the current computed value of a named formula in a Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `formulaId` | string | Yes | ID or name of the formula \(IDs are recommended, e.g., "f-fgHijkLm"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `formula` | object | Formula details | +| ↳ `value` | json | Computed value \(string, number, boolean, or array of these\) | + +### Coda Get Mutation Status + +Check whether a queued Coda change (row, page, publish, or automation request) has been applied. Status is kept for about a day. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `requestId` | string | Yes | Request ID returned by a Coda write operation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `completed` | boolean | Whether the change has been applied | +| `warning` | string | Warning if the change completed with caveats | + +### Coda Get Page + +Get metadata for a page in a Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `pageId` | string | Yes | ID or name of the page \(IDs are recommended, e.g., "canvas-IjkLmnO"; names containing "/" are not supported\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `page` | object | Page metadata | + +### Coda Get Page Content + +Read the content of a Coda canvas page as plain-text lines with their styles (headings, paragraphs, lists, quotes, code) and element IDs + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `pageId` | string | Yes | ID or name of the page \(IDs are recommended, e.g., "canvas-IjkLmnO"; names containing "/" are not supported\) | +| `limit` | number | No | Maximum number of content items to return \(1-500, default 50\) | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Content elements on the page, in order | +| ↳ `id` | string | Element ID, usable with Update Page and Delete Page Content | +| ↳ `type` | string | Element type \(line\) | +| ↳ `style` | string | Line style \(paragraph, h1, h2, h3, bulletedList, numberedList, checkboxList, collapsibleList, blockQuote, pullQuote, code\) | +| ↳ `format` | string | Content format \(plainText\) | +| ↳ `content` | string | Element text | +| ↳ `lineLevel` | number | Indentation level for paragraphs, quotes, and list items | + +### Coda Get Page Export Status + +Check a Coda page export and get its download link once complete. Download links expire shortly after they are issued. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `pageId` | string | Yes | ID or name of the page \(IDs are recommended, e.g., "canvas-IjkLmnO"; names containing "/" are not supported\) | +| `exportId` | string | Yes | Export ID returned by Export Page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `exportId` | string | ID of the export request | +| `status` | string | Export status \(inProgress, failed, complete\) | +| `href` | string | API link that reports the export status | +| `downloadLink` | string | Short-lived download link for the exported file, once complete | +| `exportError` | string | Error message if the export failed | + +### Coda Get Row + +Get a single row from a Coda table, including all of its cell values + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableId` | string | Yes | ID or name of the table or view \(IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported\) | +| `rowId` | string | Yes | ID or name of the row \(IDs are recommended, e.g., "i-tuVwxYz"; names containing "/" are not supported\) | +| `useColumnNames` | boolean | No | Key cell values by column name instead of column ID | +| `valueFormat` | string | No | Cell value format: "simple" \(default\), "simpleWithArrays", or "rich" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `row` | object | Row details and values | + +### Coda Get Sharing Metadata + +Check whether the connected user can share or copy a Coda doc, and whether they can share it with the workspace or organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `canShare` | boolean | Whether the user can share the doc | +| `canShareWithWorkspace` | boolean | Whether the user can share the doc with the workspace | +| `canShareWithOrg` | boolean | Whether the user can share the doc with the organization | +| `canCopy` | boolean | Whether the user can copy the doc | + +### Coda Get Table + +Get details about a table or view in a Coda doc, including its row count, sorts, layout, and filter + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableId` | string | Yes | ID or name of the table or view \(IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported\) | +| `useUpdatedTableLayouts` | boolean | No | Report detail and form layouts as "detail" and "form" instead of "masterDetail" for both | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `table` | object | Table details | + +### Coda List Doc Categories + +List the categories that can be applied to a published Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `categories` | array | Category names usable when publishing a doc | + +### Coda List Columns + +List the columns of a Coda table with their IDs, formats, and formulas. Use column IDs when reading and writing rows. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableId` | string | Yes | ID or name of the table or view \(IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported\) | +| `visibleOnly` | boolean | No | Only return visible columns \(applies to base tables, not views\) | +| `limit` | number | No | Maximum number of columns to return \(1-100, default 25\) | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `columns` | array | Columns in the table | + +### Coda List Controls + +List the controls (sliders, selects, checkboxes, date pickers, buttons, etc.) in a Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `sortBy` | string | No | Sort order; "name" sorts alphabetically | +| `limit` | number | No | Maximum number of results to return per page | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `controls` | array | Controls in the doc | + +### Coda List Custom Domains + +List the custom domains connected to a published Coda doc and their setup status + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `customDomains` | array | Custom domains for the published doc | +| ↳ `customDocDomain` | string | The custom domain | +| ↳ `hasCertificate` | boolean | Whether the domain has a certificate | +| ↳ `hasDnsDocId` | boolean | Whether the domain DNS points back to this doc | +| ↳ `setupStatus` | string | Setup status \(pending, succeeded, failed\) | +| ↳ `domainStatus` | string | connected or notConnected | +| ↳ `lastVerifiedTimestamp` | string | When the DNS settings were last checked | + +### Coda List Doc Analytics + +Get per-day or cumulative analytics (views, copies, likes, sessions by device, AI credits) for Coda docs + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docIds` | json | No | Doc IDs to fetch analytics for, as an array or comma-separated list | +| `workspaceId` | string | No | Only include docs in this workspace | +| `query` | string | No | Search term used to filter docs | +| `isPublished` | boolean | No | Only include published docs | +| `sinceDate` | string | No | Only include activity on or after this date \(YYYY-MM-DD\) | +| `untilDate` | string | No | Only include activity on or before this date \(YYYY-MM-DD\) | +| `scale` | string | No | Aggregation: "daily" \(default\) or "cumulative" | +| `orderBy` | string | No | Sort field: date, docId, title, createdAt, publishedAt, likes, copies, views, sessionsDesktop, sessionsMobile, sessionsOther, totalSessions, or an aiCredits field | +| `direction` | string | No | Sort direction: "ascending" or "descending" | +| `limit` | number | No | Maximum number of results to return \(1-5000, default 1000\) | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Analytics per doc | +| ↳ `doc` | object | Doc the metrics belong to | +| ↳ `metrics` | array | Metrics per date | +| ↳ `date` | string | Date of the data \(YYYY-MM-DD\) | +| ↳ `views` | number | Doc views | +| ↳ `copies` | number | Doc copies | +| ↳ `likes` | number | Doc likes | +| ↳ `sessionsMobile` | number | Unique mobile visitors | +| ↳ `sessionsDesktop` | number | Unique desktop visitors | +| ↳ `sessionsOther` | number | Unique visitors on other devices | +| ↳ `totalSessions` | number | Sessions across all devices | +| ↳ `aiCreditsChat` | number | AI credits used by chat | +| ↳ `aiCreditsBlock` | number | AI credits used by AI blocks | +| ↳ `aiCreditsColumn` | number | AI credits used by AI columns | +| ↳ `aiCreditsAssistant` | number | AI credits used by the assistant | +| ↳ `aiCreditsReviewer` | number | AI credits used by the reviewer | +| ↳ `aiCredits` | number | Total AI credits used | + +### Coda List Docs + +List Coda docs the user has opened, most recently used first, filtered by search, owner, publishing, stars, workspace, folder, or source doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` | string | No | Search term used to filter docs | +| `isOwner` | boolean | No | Only return docs owned by the user | +| `isPublished` | boolean | No | Only return published docs | +| `isStarred` | boolean | No | true returns only starred docs; false returns only unstarred docs | +| `inGallery` | boolean | No | Only return docs visible in the gallery | +| `sourceDoc` | string | No | Only return docs copied from this doc ID | +| `workspaceId` | string | No | Only return docs in this workspace \(e.g., "ws-1Ab234"\) | +| `folderId` | string | No | Only return docs in this folder \(e.g., "fl-1Ab234"\) | +| `limit` | number | No | Maximum number of results to return per page | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docs` | array | Docs matching the filters | + +### Coda List Subfolders + +List the direct subfolders of a Coda folder. Subfolders you cannot access but manage the parent of are returned with only an ID and restricted visibility. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `folderId` | string | Yes | ID of the folder \(e.g., "fl-1Ab234"\) | +| `limit` | number | No | Maximum number of results to return per page | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `children` | array | Direct subfolders | +| ↳ `visibility` | string | visible, or restricted when only the ID is returned because you cannot access the subfolder | + +### Coda List Folders + +List the Coda folders the user can access, optionally within one workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | No | Only return folders in this workspace \(e.g., "ws-1Ab234"\) | +| `isStarred` | boolean | No | true returns only starred folders; false returns only unstarred folders | +| `limit` | number | No | Maximum number of results to return per page | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folders` | array | Folders the user can access | + +### Coda List Formulas + +List the named formulas in a Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `sortBy` | string | No | Sort order; "name" sorts alphabetically | +| `limit` | number | No | Maximum number of results to return per page | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `formulas` | array | Named formulas in the doc | + +### Coda List Page Analytics + +Get daily analytics (views, sessions, users, time viewed) for each page of a Coda doc. Only available for docs in Enterprise workspaces. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `sinceDate` | string | No | Only include activity on or after this date \(YYYY-MM-DD\) | +| `untilDate` | string | No | Only include activity on or before this date \(YYYY-MM-DD\) | +| `limit` | number | No | Maximum number of results to return \(1-5000, default 1000\) | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Analytics per page | +| ↳ `page` | object | Page the metrics belong to | +| ↳ `metrics` | array | Metrics per date | +| ↳ `date` | string | Date of the data \(YYYY-MM-DD\) | +| ↳ `views` | number | Page views that day | +| ↳ `sessions` | number | Unique browsers that viewed the page | +| ↳ `users` | number | Unique Coda users that viewed the page | +| ↳ `averageSecondsViewed` | number | Average seconds the page was viewed | +| ↳ `medianSecondsViewed` | number | Median seconds the page was viewed | +| ↳ `tabs` | number | Unique tabs that opened the doc | + +### Coda List Pages + +List the pages in a Coda doc, including their hierarchy + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `limit` | number | No | Maximum number of results to return per page | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pages` | array | Pages in the doc | + +### Coda List Permissions + +List who a Coda doc is shared with and their access levels + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `limit` | number | No | Maximum number of results to return per page | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `permissions` | array | Permissions granted on the doc | + +### Coda List Rows + +List rows in a Coda table or view, optionally filtered by a column value, sorted, or limited to rows changed since a sync token + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableId` | string | Yes | ID or name of the table or view \(IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported\) | +| `query` | string | No | Filter as <column_id_or_name>:<JSON value>. Quote column names and string values, e.g., c-tuVwxYz:"Apple" or "Status":"Done" | +| `sortBy` | string | No | Sort order: "createdAt" \(default\), "updatedAt", or "natural" \(view order; implies visibleOnly\) | +| `useColumnNames` | boolean | No | Key cell values by column name instead of column ID | +| `valueFormat` | string | No | Cell value format: "simple" \(default\), "simpleWithArrays", or "rich" | +| `visibleOnly` | boolean | No | Only return visible rows and columns | +| `syncToken` | string | No | nextSyncToken from a previous call, to return only rows changed since then | +| `limit` | number | No | Maximum number of results to return per page | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Rows in the table | +| `nextSyncToken` | string | Token to pass as syncToken later to fetch only rows changed after this call | + +### Coda List Tables + +List the tables and views in a Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableTypes` | json | No | Table types to include, as an array or comma-separated list of "table", "view", "database" \(defaults to all\) | +| `sortBy` | string | No | Sort order; "name" sorts alphabetically | +| `limit` | number | No | Maximum number of results to return per page | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tables` | array | Tables and views in the doc | + +### Coda List Workspace Members + +List the members of a Coda workspace with their roles and doc activity, requesting user first. The workspace must belong to an organization. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(e.g., "ws-1Ab234"\) | +| `includedRoles` | json | No | Only return members with these roles, as an array or comma-separated list of "Admin", "DocMaker", "Editor" | +| `pageToken` | string | No | Page token from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | Workspace members | +| ↳ `email` | string | Email address | +| ↳ `name` | string | Name | +| ↳ `role` | string | Workspace role \(Admin, DocMaker, Editor\) | +| ↳ `pictureUrl` | string | Avatar link | +| ↳ `registeredAt` | string | When the user joined the workspace | +| ↳ `roleChangedAt` | string | When the role last changed | +| ↳ `lastActiveAt` | string | Date the user last acted in any workspace | +| ↳ `ownedDocs` | number | Docs the user owns in this workspace | +| ↳ `docsLastActiveAt` | string | Date anyone last accessed a doc the user owns | +| ↳ `docCollaboratorCount` | number | Collaborators on docs the user owns in the last 90 days | +| ↳ `totalDocs` | number | Docs the user owns, manages, or added pages to in the last 90 days | +| ↳ `totalDocsLastActiveAt` | string | Date anyone last accessed a doc the user owns or contributed to | +| ↳ `totalDocCollaboratorsLast90Days` | number | Unique viewers of docs the user owns, manages, or added pages to | + +### Coda List Workspace Role Activity + +Get monthly counts of active and inactive Admins, Doc Makers, and Editors in a workspace. The workspace must belong to an organization. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(e.g., "ws-1Ab234"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `roleActivity` | array | Role counts per month | +| ↳ `month` | string | Month of the data \(YYYY-MM-DD\) | +| ↳ `activeAdminCount` | number | Active Admins | +| ↳ `activeDocMakerCount` | number | Active Doc Makers | +| ↳ `activeEditorCount` | number | Active Editors | +| ↳ `inactiveAdminCount` | number | Inactive Admins | +| ↳ `inactiveDocMakerCount` | number | Inactive Doc Makers | +| ↳ `inactiveEditorCount` | number | Inactive Editors | + +### Coda Publish Doc + +Publish a Coda doc or update its publishing settings: URL slug, discoverability, categories, and interaction mode. The doc owner needs a Coda maker profile. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `slug` | string | No | URL slug for the published doc \(e.g., "my-doc"\) | +| `discoverable` | boolean | No | Whether the published doc is discoverable in the gallery | +| `categoryNames` | json | No | Category names to apply, as an array or comma-separated list \(see List Doc Categories\) | +| `mode` | string | No | Interaction mode for viewers: "view", "play", or "edit" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docs` | json | Docs \(id, name, href, browserLink, icon, owner, ownerName, createdAt, updatedAt, workspace, folder, sourceDoc, docSize, published\) | +| `doc` | json | Doc \(id, name, href, browserLink, icon, owner, ownerName, createdAt, updatedAt, workspace \{id, name, organizationId\}, folder \{id, name\}, sourceDoc, docSize \{totalRowCount, tableAndViewCount, baseTableCount, pageCount, overApiSizeLimit\}, published \{description, browserLink, discoverable, mode, categories\}\) | +| `docId` | string | ID of the affected doc | +| `categories` | json | Doc category names | +| `requestId` | string | Request ID of a queued change, for Get Mutation Status | +| `customDomains` | json | Custom domains \(customDocDomain, hasCertificate, hasDnsDocId, setupStatus, domainStatus, lastVerifiedTimestamp\) | +| `customDocDomain` | string | Custom domain | +| `provider` | string | DNS provider of a custom domain | +| `permissions` | json | Permissions \(id, access, principal \{type, email, groupId, groupName, domain, workspaceId\}\) | +| `access` | string | Access level granted | +| `principalType` | string | Type of principal the doc was shared with | +| `permissionId` | string | ID of the removed permission | +| `users` | json | Matching users \(name, loginId, pictureLink\) | +| `groups` | json | Matching groups \(groupId, groupName\) | +| `canShare` | boolean | Whether the user can share the doc | +| `canShareWithWorkspace` | boolean | Whether the user can share with the workspace | +| `canShareWithOrg` | boolean | Whether the user can share with the org | +| `canCopy` | boolean | Whether the user can copy the doc | +| `allowEditorsToChangePermissions` | boolean | Whether editors can change permissions | +| `allowCopying` | boolean | Whether viewers can copy the doc | +| `allowViewersToRequestEditing` | boolean | Whether viewers can request editing | +| `pages` | json | Pages \(id, name, subtitle, browserLink, contentType, isHidden, icon, image, parent, children, authors, createdAt, updatedAt\) | +| `page` | json | Page \(id, name, subtitle, href, browserLink, contentType, isHidden, isEffectivelyHidden, icon, image, parent, children, authors, createdAt, createdBy, updatedAt, updatedBy\) | +| `pageId` | string | ID of the created, updated, or deleted page | +| `items` | json | Page content lines \(id, type, style, format, content, lineLevel\), or analytics items \(doc or page plus daily metrics\) | +| `exportId` | string | Page export ID | +| `status` | string | Page export status \(inProgress, failed, complete\) | +| `href` | string | API link reporting the page export status | +| `downloadLink` | string | Download link of a completed page export | +| `exportError` | string | Error message of a failed page export | +| `tables` | json | Tables and views \(id, name, tableType, href, browserLink, parent\) | +| `table` | json | Table \(id, name, tableType, browserLink, parent, parentTable, displayColumnId, rowCount, sorts, layout, filter, createdAt, updatedAt\) | +| `columns` | json | Columns \(id, name, display, calculated, formula, defaultValue, format\) | +| `column` | json | Column \(id, name, display, calculated, formula, defaultValue, format, parentTable\) | +| `rows` | json | Rows \(id, name, index, browserLink, createdAt, updatedAt, values\) | +| `row` | json | Row \(id, name, index, browserLink, createdAt, updatedAt, values, parentTable\) | +| `nextSyncToken` | string | Token for reading only rows changed later | +| `addedRowIds` | json | IDs of rows that will be added | +| `rowId` | string | ID of the affected row | +| `rowIds` | json | IDs of rows queued for deletion | +| `columnId` | string | ID of the pushed button column | +| `formulas` | json | Named formulas \(id, name, href, parent\) | +| `formula` | json | Formula \(id, name, href, parent, value\) | +| `controls` | json | Controls \(id, name, href, parent\) | +| `control` | json | Control \(id, name, href, parent, controlType, value\) | +| `folders` | json | Folders \(id, name, browserLink, description, icon, iconColor, createdAt, canEdit, workspace\) | +| `folder` | json | Folder \(id, name, browserLink, description, icon, iconColor, createdAt, canEdit, workspace\) | +| `folderId` | string | ID of the deleted folder | +| `children` | json | Subfolders \(id, name, browserLink, visibility, workspace, ...\) | +| `members` | json | Workspace members \(email, name, role, registeredAt, roleChangedAt, lastActiveAt, ownedDocs, totalDocs, ...\) | +| `email` | string | Email of the member whose role changed | +| `newRole` | string | Role assigned | +| `roleChangedAt` | string | When the role change took effect | +| `roleActivity` | json | Monthly role counts \(month, activeAdminCount, activeDocMakerCount, activeEditorCount, inactive counts\) | +| `totalSessions` | number | Total sessions across matching docs | +| `docAnalyticsLastUpdated` | string | Date doc analytics last updated | +| `packAnalyticsLastUpdated` | string | Date Pack analytics last updated | +| `packFormulaAnalyticsLastUpdated` | string | Date Pack formula analytics last updated | +| `browserLink` | string | Canonical browser link of a resolved resource | +| `resource` | json | Resolved resource \(type, id, name, href\) | +| `completed` | boolean | Whether a queued change was applied | +| `warning` | string | Warning for a change that completed with caveats | +| `name` | string | Name of the token owner | +| `loginId` | string | Email of the token owner | +| `pictureLink` | string | Avatar link of the token owner | +| `scoped` | boolean | Whether the token is restricted | +| `tokenName` | string | Name of the API token | +| `workspace` | json | Default workspace of the token owner \(id, name, organizationId, browserLink\) | +| `nextPageToken` | string | Token for fetching the next page of results | + +### Coda Push Button + +Push a button column on a row of a Coda table, running its action. The button can perform any action in the doc. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableId` | string | Yes | ID or name of the table or view \(IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported\) | +| `rowId` | string | Yes | ID or name of the row \(IDs are recommended, e.g., "i-tuVwxYz"; names containing "/" are not supported\) | +| `columnId` | string | Yes | ID or name of the button column \(e.g., "c-tuVwxYz"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rowId` | string | ID of the row containing the button | +| `columnId` | string | ID of the button column | + +### Coda Resolve Browser Link + +Resolve a Coda browser URL (doc, page, table, row, etc.) into its resource type and ID for use in other Coda operations + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `url` | string | Yes | Coda browser link, e.g., https://coda.io/d/_dAbCDeFGH/Launch-Status_sumnO | +| `degradeGracefully` | boolean | No | If the linked object was deleted, resolve the nearest existing parent \(up to the doc\) instead of failing | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `browserLink` | string | Canonical browser link to the resource | +| `resource` | object | The resolved resource | +| ↳ `type` | string | Resource type \(doc, page, table, row, column, formula, control, etc.\) | +| ↳ `id` | string | Resource ID | +| ↳ `name` | string | Resource name | +| ↳ `href` | string | API link to the resource | + +### Coda Search Principals + +Search for users and groups a Coda doc can be shared with (up to 20 of each). Returns nothing without a query. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `query` | string | No | Name or email to search for | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | Matching users | +| ↳ `name` | string | User name | +| ↳ `loginId` | string | User email address | +| ↳ `pictureLink` | string | Avatar link | +| `groups` | array | Matching groups | +| ↳ `groupId` | string | Group ID | +| ↳ `groupName` | string | Group name | + +### Coda Trigger Automation + +Trigger a webhook-invoked automation in a Coda doc, optionally passing a JSON payload the automation can read + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `ruleId` | string | Yes | ID of the automation rule \(e.g., "grid-auto-b3Jmey6jBS"\) | +| `payload` | json | No | JSON object passed to the automation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docs` | json | Docs \(id, name, href, browserLink, icon, owner, ownerName, createdAt, updatedAt, workspace, folder, sourceDoc, docSize, published\) | +| `doc` | json | Doc \(id, name, href, browserLink, icon, owner, ownerName, createdAt, updatedAt, workspace \{id, name, organizationId\}, folder \{id, name\}, sourceDoc, docSize \{totalRowCount, tableAndViewCount, baseTableCount, pageCount, overApiSizeLimit\}, published \{description, browserLink, discoverable, mode, categories\}\) | +| `docId` | string | ID of the affected doc | +| `categories` | json | Doc category names | +| `requestId` | string | Request ID of a queued change, for Get Mutation Status | +| `customDomains` | json | Custom domains \(customDocDomain, hasCertificate, hasDnsDocId, setupStatus, domainStatus, lastVerifiedTimestamp\) | +| `customDocDomain` | string | Custom domain | +| `provider` | string | DNS provider of a custom domain | +| `permissions` | json | Permissions \(id, access, principal \{type, email, groupId, groupName, domain, workspaceId\}\) | +| `access` | string | Access level granted | +| `principalType` | string | Type of principal the doc was shared with | +| `permissionId` | string | ID of the removed permission | +| `users` | json | Matching users \(name, loginId, pictureLink\) | +| `groups` | json | Matching groups \(groupId, groupName\) | +| `canShare` | boolean | Whether the user can share the doc | +| `canShareWithWorkspace` | boolean | Whether the user can share with the workspace | +| `canShareWithOrg` | boolean | Whether the user can share with the org | +| `canCopy` | boolean | Whether the user can copy the doc | +| `allowEditorsToChangePermissions` | boolean | Whether editors can change permissions | +| `allowCopying` | boolean | Whether viewers can copy the doc | +| `allowViewersToRequestEditing` | boolean | Whether viewers can request editing | +| `pages` | json | Pages \(id, name, subtitle, browserLink, contentType, isHidden, icon, image, parent, children, authors, createdAt, updatedAt\) | +| `page` | json | Page \(id, name, subtitle, href, browserLink, contentType, isHidden, isEffectivelyHidden, icon, image, parent, children, authors, createdAt, createdBy, updatedAt, updatedBy\) | +| `pageId` | string | ID of the created, updated, or deleted page | +| `items` | json | Page content lines \(id, type, style, format, content, lineLevel\), or analytics items \(doc or page plus daily metrics\) | +| `exportId` | string | Page export ID | +| `status` | string | Page export status \(inProgress, failed, complete\) | +| `href` | string | API link reporting the page export status | +| `downloadLink` | string | Download link of a completed page export | +| `exportError` | string | Error message of a failed page export | +| `tables` | json | Tables and views \(id, name, tableType, href, browserLink, parent\) | +| `table` | json | Table \(id, name, tableType, browserLink, parent, parentTable, displayColumnId, rowCount, sorts, layout, filter, createdAt, updatedAt\) | +| `columns` | json | Columns \(id, name, display, calculated, formula, defaultValue, format\) | +| `column` | json | Column \(id, name, display, calculated, formula, defaultValue, format, parentTable\) | +| `rows` | json | Rows \(id, name, index, browserLink, createdAt, updatedAt, values\) | +| `row` | json | Row \(id, name, index, browserLink, createdAt, updatedAt, values, parentTable\) | +| `nextSyncToken` | string | Token for reading only rows changed later | +| `addedRowIds` | json | IDs of rows that will be added | +| `rowId` | string | ID of the affected row | +| `rowIds` | json | IDs of rows queued for deletion | +| `columnId` | string | ID of the pushed button column | +| `formulas` | json | Named formulas \(id, name, href, parent\) | +| `formula` | json | Formula \(id, name, href, parent, value\) | +| `controls` | json | Controls \(id, name, href, parent\) | +| `control` | json | Control \(id, name, href, parent, controlType, value\) | +| `folders` | json | Folders \(id, name, browserLink, description, icon, iconColor, createdAt, canEdit, workspace\) | +| `folder` | json | Folder \(id, name, browserLink, description, icon, iconColor, createdAt, canEdit, workspace\) | +| `folderId` | string | ID of the deleted folder | +| `children` | json | Subfolders \(id, name, browserLink, visibility, workspace, ...\) | +| `members` | json | Workspace members \(email, name, role, registeredAt, roleChangedAt, lastActiveAt, ownedDocs, totalDocs, ...\) | +| `email` | string | Email of the member whose role changed | +| `newRole` | string | Role assigned | +| `roleChangedAt` | string | When the role change took effect | +| `roleActivity` | json | Monthly role counts \(month, activeAdminCount, activeDocMakerCount, activeEditorCount, inactive counts\) | +| `totalSessions` | number | Total sessions across matching docs | +| `docAnalyticsLastUpdated` | string | Date doc analytics last updated | +| `packAnalyticsLastUpdated` | string | Date Pack analytics last updated | +| `packFormulaAnalyticsLastUpdated` | string | Date Pack formula analytics last updated | +| `browserLink` | string | Canonical browser link of a resolved resource | +| `resource` | json | Resolved resource \(type, id, name, href\) | +| `completed` | boolean | Whether a queued change was applied | +| `warning` | string | Warning for a change that completed with caveats | +| `name` | string | Name of the token owner | +| `loginId` | string | Email of the token owner | +| `pictureLink` | string | Avatar link of the token owner | +| `scoped` | boolean | Whether the token is restricted | +| `tokenName` | string | Name of the API token | +| `workspace` | json | Default workspace of the token owner \(id, name, organizationId, browserLink\) | +| `nextPageToken` | string | Token for fetching the next page of results | + +### Coda Unpublish Doc + +Unpublish a Coda doc + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docId` | string | ID of the unpublished doc | + +### Coda Update Sharing Settings + +Update who can change permissions, copy, or request edit access on a Coda doc; unset settings are left unchanged + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `allowEditorsToChangePermissions` | boolean | No | Allow editors to change doc permissions | +| `allowCopying` | boolean | No | Allow viewers to copy the doc | +| `allowViewersToRequestEditing` | boolean | No | Allow viewers to request edit access | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docs` | json | Docs \(id, name, href, browserLink, icon, owner, ownerName, createdAt, updatedAt, workspace, folder, sourceDoc, docSize, published\) | +| `doc` | json | Doc \(id, name, href, browserLink, icon, owner, ownerName, createdAt, updatedAt, workspace \{id, name, organizationId\}, folder \{id, name\}, sourceDoc, docSize \{totalRowCount, tableAndViewCount, baseTableCount, pageCount, overApiSizeLimit\}, published \{description, browserLink, discoverable, mode, categories\}\) | +| `docId` | string | ID of the affected doc | +| `categories` | json | Doc category names | +| `requestId` | string | Request ID of a queued change, for Get Mutation Status | +| `customDomains` | json | Custom domains \(customDocDomain, hasCertificate, hasDnsDocId, setupStatus, domainStatus, lastVerifiedTimestamp\) | +| `customDocDomain` | string | Custom domain | +| `provider` | string | DNS provider of a custom domain | +| `permissions` | json | Permissions \(id, access, principal \{type, email, groupId, groupName, domain, workspaceId\}\) | +| `access` | string | Access level granted | +| `principalType` | string | Type of principal the doc was shared with | +| `permissionId` | string | ID of the removed permission | +| `users` | json | Matching users \(name, loginId, pictureLink\) | +| `groups` | json | Matching groups \(groupId, groupName\) | +| `canShare` | boolean | Whether the user can share the doc | +| `canShareWithWorkspace` | boolean | Whether the user can share with the workspace | +| `canShareWithOrg` | boolean | Whether the user can share with the org | +| `canCopy` | boolean | Whether the user can copy the doc | +| `allowEditorsToChangePermissions` | boolean | Whether editors can change permissions | +| `allowCopying` | boolean | Whether viewers can copy the doc | +| `allowViewersToRequestEditing` | boolean | Whether viewers can request editing | +| `pages` | json | Pages \(id, name, subtitle, browserLink, contentType, isHidden, icon, image, parent, children, authors, createdAt, updatedAt\) | +| `page` | json | Page \(id, name, subtitle, href, browserLink, contentType, isHidden, isEffectivelyHidden, icon, image, parent, children, authors, createdAt, createdBy, updatedAt, updatedBy\) | +| `pageId` | string | ID of the created, updated, or deleted page | +| `items` | json | Page content lines \(id, type, style, format, content, lineLevel\), or analytics items \(doc or page plus daily metrics\) | +| `exportId` | string | Page export ID | +| `status` | string | Page export status \(inProgress, failed, complete\) | +| `href` | string | API link reporting the page export status | +| `downloadLink` | string | Download link of a completed page export | +| `exportError` | string | Error message of a failed page export | +| `tables` | json | Tables and views \(id, name, tableType, href, browserLink, parent\) | +| `table` | json | Table \(id, name, tableType, browserLink, parent, parentTable, displayColumnId, rowCount, sorts, layout, filter, createdAt, updatedAt\) | +| `columns` | json | Columns \(id, name, display, calculated, formula, defaultValue, format\) | +| `column` | json | Column \(id, name, display, calculated, formula, defaultValue, format, parentTable\) | +| `rows` | json | Rows \(id, name, index, browserLink, createdAt, updatedAt, values\) | +| `row` | json | Row \(id, name, index, browserLink, createdAt, updatedAt, values, parentTable\) | +| `nextSyncToken` | string | Token for reading only rows changed later | +| `addedRowIds` | json | IDs of rows that will be added | +| `rowId` | string | ID of the affected row | +| `rowIds` | json | IDs of rows queued for deletion | +| `columnId` | string | ID of the pushed button column | +| `formulas` | json | Named formulas \(id, name, href, parent\) | +| `formula` | json | Formula \(id, name, href, parent, value\) | +| `controls` | json | Controls \(id, name, href, parent\) | +| `control` | json | Control \(id, name, href, parent, controlType, value\) | +| `folders` | json | Folders \(id, name, browserLink, description, icon, iconColor, createdAt, canEdit, workspace\) | +| `folder` | json | Folder \(id, name, browserLink, description, icon, iconColor, createdAt, canEdit, workspace\) | +| `folderId` | string | ID of the deleted folder | +| `children` | json | Subfolders \(id, name, browserLink, visibility, workspace, ...\) | +| `members` | json | Workspace members \(email, name, role, registeredAt, roleChangedAt, lastActiveAt, ownedDocs, totalDocs, ...\) | +| `email` | string | Email of the member whose role changed | +| `newRole` | string | Role assigned | +| `roleChangedAt` | string | When the role change took effect | +| `roleActivity` | json | Monthly role counts \(month, activeAdminCount, activeDocMakerCount, activeEditorCount, inactive counts\) | +| `totalSessions` | number | Total sessions across matching docs | +| `docAnalyticsLastUpdated` | string | Date doc analytics last updated | +| `packAnalyticsLastUpdated` | string | Date Pack analytics last updated | +| `packFormulaAnalyticsLastUpdated` | string | Date Pack formula analytics last updated | +| `browserLink` | string | Canonical browser link of a resolved resource | +| `resource` | json | Resolved resource \(type, id, name, href\) | +| `completed` | boolean | Whether a queued change was applied | +| `warning` | string | Warning for a change that completed with caveats | +| `name` | string | Name of the token owner | +| `loginId` | string | Email of the token owner | +| `pictureLink` | string | Avatar link of the token owner | +| `scoped` | boolean | Whether the token is restricted | +| `tokenName` | string | Name of the API token | +| `workspace` | json | Default workspace of the token owner \(id, name, organizationId, browserLink\) | +| `nextPageToken` | string | Token for fetching the next page of results | + +### Coda Update Doc + +Rename a Coda doc or change its icon. Renaming requires Doc Maker access in the workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `title` | string | No | New title of the doc | +| `iconName` | string | No | Name of the icon to use \(e.g., "rocket"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `docId` | string | ID of the updated doc | + +### Coda Update Folder + +Rename a Coda folder or change its description. Coda can return the folder as it was before the change; read it again to confirm. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `folderId` | string | Yes | ID of the folder \(e.g., "fl-1Ab234"\) | +| `name` | string | No | New name of the folder | +| `description` | string | No | New description of the folder | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folder` | object | The updated folder | + +### Coda Update Page + +Update a Coda page: rename it, change its subtitle, icon, cover, or visibility, and append, prepend, or replace content with Markdown or HTML. Applied asynchronously. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `pageId` | string | Yes | ID or name of the page \(IDs are recommended, e.g., "canvas-IjkLmnO"; names containing "/" are not supported\) | +| `name` | string | No | New name of the page | +| `subtitle` | string | No | New subtitle of the page \(an empty value leaves the subtitle unchanged\) | +| `iconName` | string | No | Name of the page icon \(e.g., "rocket"\) | +| `imageUrl` | string | No | URL of a cover image for the page | +| `isHidden` | boolean | No | Whether the page is hidden \(requires a paid Coda plan; ignored for pages that cannot be hidden\) | +| `insertionMode` | string | No | How to apply content: "append", "prepend", or "replace". Required when content is provided. | +| `elementId` | string | No | Page element to insert relative to or replace \(e.g., "cl-lzqh0Q0poT"\); omit to apply to the whole page | +| `contentFormat` | string | No | Content format: "markdown" \(default\) or "html" | +| `content` | string | No | Content to add to the page in the chosen format | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pageId` | string | ID of the updated page | + +### Coda Update Row + +Update cell values in a row of a Coda table. Applied asynchronously. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableId` | string | Yes | ID or name of the table or view \(IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported\) | +| `rowId` | string | Yes | ID or name of the row \(IDs are recommended, e.g., "i-tuVwxYz"; names containing "/" are not supported\) | +| `cells` | json | Yes | Object mapping column IDs \(or names\) to new values, e.g., \{"c-tuVwxYz": "Done"\}, or Coda cells \[\{"column": "c-tuVwxYz", "value": "Done"\}\] | +| `disableParsing` | boolean | No | Store values exactly as given without parsing them | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rowId` | string | ID of the updated row | + +### Coda Insert or Upsert Rows + +Insert rows into a Coda base table, or update matching rows when key columns are given. Only works on base tables, not views. Applied asynchronously. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `docId` | string | Yes | ID of the doc \(e.g., "AbCDeFGH"\) | +| `tableId` | string | Yes | ID or name of the table or view \(IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported\) | +| `rows` | json | Yes | Array of rows. Each row maps column IDs \(or names\) to values, e.g., \[\{"c-tuVwxYz": "Apple", "c-bCdeFgh": 12\}\], or uses Coda cells \[\{"cells": \[\{"column": "c-tuVwxYz", "value": "Apple"\}\]\}\] | +| `keyColumns` | json | No | Column IDs \(or names\) to match existing rows on, as an array or comma-separated list. Matching rows are updated instead of inserted. | +| `disableParsing` | boolean | No | Store values exactly as given without parsing them | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `addedRowIds` | array | IDs of rows that will be added \(only returned when no key columns are set\) | + +### Coda Get Current User + +Get the user and default workspace behind the connected Coda API token + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the user | +| `loginId` | string | Email address of the user | +| `pictureLink` | string | Link to the user avatar | +| `scoped` | boolean | Whether the token is restricted to specific docs or tables | +| `tokenName` | string | Name of the API token | +| `workspace` | object | Default workspace of the user | + + diff --git a/apps/docs/content/docs/integrations/meta.json b/apps/docs/content/docs/integrations/meta.json index b97450113b3..ad8e80316f1 100644 --- a/apps/docs/content/docs/integrations/meta.json +++ b/apps/docs/content/docs/integrations/meta.json @@ -46,6 +46,7 @@ "cloudformation", "cloudtrail", "cloudwatch", + "coda", "codepipeline", "confluence", "context_dev", diff --git a/apps/docs/content/docs/platform/enterprise/index.mdx b/apps/docs/content/docs/platform/enterprise/index.mdx index be354a1a18e..52acd3e5216 100644 --- a/apps/docs/content/docs/platform/enterprise/index.mdx +++ b/apps/docs/content/docs/platform/enterprise/index.mdx @@ -14,7 +14,7 @@ Sim Enterprise adds organization controls for access, provisioning, operations, | [Security](/platform/enterprise/security) | Manage session policies and view configured outbound IP addresses | | [Session policies](/platform/enterprise/session-policies) | Set session lifetimes and revoke member sessions | | [Audit logs](/platform/enterprise/audit-logs) | Investigate configuration and security events | -| [Usage tracking](/platform/enterprise/usage-tracking) | Review usage by member, workspace, model, and source | +| [Insights](/platform/enterprise/usage-tracking) | Explore credit usage, workflow performance, and recorded chat activity | | [Data retention](/platform/enterprise/data-retention) | Set retention windows and configure PII redaction | | [Data drains](/platform/enterprise/data-drains) | Export logs and Chat records to your own destination | | [Custom blocks](/platform/enterprise/custom-blocks) | Share a workflow as a block across the organization | diff --git a/apps/docs/content/docs/platform/enterprise/self-hosted.mdx b/apps/docs/content/docs/platform/enterprise/self-hosted.mdx index f256a162b28..c623ecd169c 100644 --- a/apps/docs/content/docs/platform/enterprise/self-hosted.mdx +++ b/apps/docs/content/docs/platform/enterprise/self-hosted.mdx @@ -51,7 +51,7 @@ Three features do not need a flag at all: **custom branding**, **session policie | Directory provisioning (SCIM) | `SCIM_ENABLED` | `NEXT_PUBLIC_SCIM_ENABLED` | | Custom branding — on by default | `WHITELABELING_ENABLED` | `NEXT_PUBLIC_WHITELABELING_ENABLED` | | Audit logs | `AUDIT_LOGS_ENABLED` | `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` | -| Usage tracking | `USAGE_MONITORING_ENABLED` | `NEXT_PUBLIC_USAGE_MONITORING_ENABLED` | +| Insights | `USAGE_MONITORING_ENABLED` | `NEXT_PUBLIC_USAGE_MONITORING_ENABLED` | | Custom blocks | `CUSTOM_BLOCKS_ENABLED` | `NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED` | | Session policies — on by default | `SESSION_POLICIES_ENABLED` | `NEXT_PUBLIC_SESSION_POLICIES_ENABLED` | | Data retention deletion | `DATA_RETENTION_ENABLED` | `NEXT_PUBLIC_DATA_RETENTION_ENABLED` | diff --git a/apps/docs/content/docs/platform/enterprise/sso.mdx b/apps/docs/content/docs/platform/enterprise/sso.mdx index 5ed4d7a7fc1..ff7a6fb4002 100644 --- a/apps/docs/content/docs/platform/enterprise/sso.mdx +++ b/apps/docs/content/docs/platform/enterprise/sso.mdx @@ -319,8 +319,18 @@ With **Automatic** provisioning, no invitation is required for organization memb SSO provisioning creates internal organization members but does not grant workspace access. To grant workspace access from your identity provider, use [directory provisioning](/platform/enterprise/scim) and map a pushed group to a workspace. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats. Existing invitations and external access take precedence over automatic provisioning so their intended role and workspace grants are preserved. +## Require single sign-on + +By default members can sign in with a password, an email code, or your identity provider. To make single sign-on the only way in, open **Settings → Organization → Single sign-on → Sign-in** and set **Allowed sign-in methods** to **Single sign-on**. It becomes available once the organization has an identity provider on a verified domain. + +- The requirement is checked when a session is created, so turning it on signs nobody out. Members keep working and meet the requirement at their next sign-in. To end current sessions too, use **Sign out all members** under **Settings → Organization → Security**. +- Organization owners keep every sign-in method. If the identity provider breaks, an owner can still sign in with a password — setting one through **Forgot password** if they only ever signed in through the identity provider — and turn the requirement back off. +- If the last identity provider is deleted or its domain verification lapses, the requirement stops being enforced instead of locking the organization out, and you can switch back to any method at any time. +- Desktop app handoff from an already signed-in browser keeps working, because that session derives from one the requirement already admitted. +- A member who tries a password, an email code, or a social sign-in such as Google or GitHub sees a message telling them to sign in through their identity provider. + - Password-based login remains available. Forcing all organization members to use SSO exclusively is not yet supported. + Turning the requirement off restores password and email sign-in immediately for everyone. --- @@ -352,7 +362,7 @@ SSO provisioning creates internal organization members but does not grant worksp }, { question: "Can I still use email/password login after enabling SSO?", - answer: "Yes. Enabling SSO does not disable password-based login. Users can still sign in with their email and password if they have one. Forced SSO (requiring all users on the domain to use SSO) is not yet supported." + answer: "Yes, unless you require single sign-on. Enabling SSO does not disable password login on its own; set Allowed sign-in methods to Single sign-on to refuse password, email-code, and social sign-in for members. Organization owners keep password sign-in as a way back in if the identity provider breaks, and can set a password through Forgot password if they never had one." }, { question: "A user already has an account with the same email — what happens when they sign in with SSO?", diff --git a/apps/docs/content/docs/platform/enterprise/usage-tracking.mdx b/apps/docs/content/docs/platform/enterprise/usage-tracking.mdx index 09e62e9b3af..21a1343a2c2 100644 --- a/apps/docs/content/docs/platform/enterprise/usage-tracking.mdx +++ b/apps/docs/content/docs/platform/enterprise/usage-tracking.mdx @@ -1,23 +1,21 @@ --- -title: Usage Tracking -description: See where your organization's credits go, by member, workspace, and model +title: Insights +description: Explore organization credit usage, workflow performance, and recorded chat activity --- import { Callout } from 'fumadocs-ui/components/callout' import { FAQ } from '@/components/ui/faq' import { Image } from '@/components/ui/image' -Usage tracking shows how your organization consumes credits across every part of the platform — which members, which workspaces, which models, and which product features. Use it to monitor spend against your commitment, find what is driving it, and export the underlying events for chargeback. +Insights shows how your organization consumes credits across every part of the platform — which members, which workspaces, which models, and which product features. Use it to monitor spend against your commitment, find what is driving it, and export the underlying events for chargeback. -All figures are in **credits** (1 credit = $0.005). See [cost calculation](/platform/costs) for how credits are derived. +Spend figures are in **credits** (1 credit = $0.005). See [cost calculation](/platform/costs) for how credits are derived. --- ## Viewing usage -Go to **Settings → Organization → Usage tracking** in your workspace. - -Usage tracking Overview tab showing the period selector, credits used against the organization limit, a daily usage chart, and a Sources section pairing a ranked list of sources with a radar chart of the same mix +Go to **Settings → Organization → Insights** in your workspace. The period selector applies to every tab: @@ -36,7 +34,8 @@ The period selector applies to every tab: | Tab | Answers | |-----|---------| -| **Overview** | How much have we used, against what limit, and what kind of work was it | +| **Overview** | Credit consumption, recorded activity, workflow failure rate, and usage by source | +| **Activity** | Run trends and performance, grouped by workspace, workflow, chat member, or trigger | | **Members** | Which people are driving usage | | **Workspaces** | Which workspaces are driving usage — select one to drill in | | **Models** | Which models we are paying for | @@ -49,6 +48,22 @@ Selecting a workspace opens its detail view, which splits that workspace's usage --- +## Activity and performance + +The **Activity** tab shows workflow runs, recorded chat runs, members with chat activity, and workflow failure rate. Group the breakdown by **Workspaces**, **Workflows**, **Members**, or **Triggers**, and sort by run volume, failure count, or average workflow duration. Member breakdowns rank chat runs only. Results are paginated; totals and sorting include the whole selected period, not just the visible page. Select a workspace to narrow the activity view, then use **All workspaces** to clear that filter. + +- **Workflow runs** count retained execution records whose start time falls in the selected period, including runs that are still running, paused, or cancelled. +- **Recorded chat runs** count distinct recorded execution IDs across workspace and organization chats. Continuations count once, in the period containing their first retained start. Calls without a persisted chat run are not included. +- **Members with chat activity** count distinct users attributed to those chat runs. This measures recorded chat activity, not logins, all active users, or people billed for automated workflows. +- **Workflow failure rate** is failed runs divided by completed plus failed runs. Paused, cancelled, and unfinished runs do not enter the denominator. A dash means there are no completed or failed runs to measure. +- **Average duration** uses the recorded duration of completed or failed workflows. Missing durations are excluded; a dash means no duration is available. + +Activity follows retained history and current organization/workspace ownership. Deleting history or moving a workspace can change historical activity totals. Status and duration reflect the latest recorded outcome, even for a run that started in an earlier period. The activity view does not read chat transcripts or workflow trace payloads. + +Credit totals continue to come from the billing ledger. Activity counts need not match billing-event counts, and the credit export does not export activity. Exact per-chat cost, connector/skill adoption, and generated-artifact attribution are not available in this view. + +--- + ## What each source means A **source** is the part of the platform that consumed the credits. Every charge belongs to exactly one source, so the Sources breakdown always adds up to your total. @@ -102,9 +117,9 @@ When a workspace or organization supplies its own provider key, Sim does not cha ## Exporting -**Export** downloads the events behind the current period and filters as a CSV with columns `Date, Source, Description, Workflow, Credits`. Credits are exported as plain numbers so the column can be summed, and carry decimals — an individual event often costs a fraction of a credit. +**Export credits** downloads the events behind the current period and filters as a CSV with columns `Date, Source, Description, Workflow, Credits`. Credits are exported as plain numbers so the column can be summed, and carry decimals — an individual event often costs a fraction of a credit. -**All events** opens the full ledger — every credit-consuming event, newest first, with its own filters and export. +**Credit events** opens the full ledger — every credit-consuming event, newest first, with its own filters and export. Very large exports are capped. When that happens the download still succeeds and Sim tells you it was truncated; narrow the date range to capture everything. @@ -112,7 +127,7 @@ Very large exports are capped. When that happens the download still succeeds and
+ {policyError && ( + +

{policyError}

+
+ )} + {resetSuccessMessage && (

{resetSuccessMessage}

diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index b2009abdbbc..4d1274412b7 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { normalizeEmail } from '@sim/utils/string' import { useSearchParams } from 'next/navigation' import { client, useSession } from '@/lib/auth/auth-client' +import { SSO_REQUIRED_ERROR_CODE } from '@/lib/auth/constants' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { DEFAULT_POST_AUTH_ROUTE, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' @@ -122,7 +123,14 @@ export function useVerification({ }, 1000) } else { logger.info('Setting invalid OTP state - API error response') - const message = 'Invalid verification code. Please check and try again.' + /** + * A refusal by policy — an organization requiring single sign-on — is not a bad code, and + * telling the person to re-check their code sends them round a loop they cannot exit. + */ + const message = + response?.error?.code === SSO_REQUIRED_ERROR_CODE && response.error.message + ? response.error.message + : 'Invalid verification code. Please check and try again.' setStatus('error') setErrorMessage(message) logger.info('Error state after API error:', { errorMessage: message }) diff --git a/apps/sim/app/access-requests/layout.tsx b/apps/sim/app/access-requests/layout.tsx new file mode 100644 index 00000000000..f4b12888e24 --- /dev/null +++ b/apps/sim/app/access-requests/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' + +interface AccessRequestsLayoutProps { + children: ReactNode +} + +export default function AccessRequestsLayout({ children }: AccessRequestsLayoutProps) { + return ( +
+ + {children} +
+ ) +} diff --git a/apps/sim/app/access-requests/loading.tsx b/apps/sim/app/access-requests/loading.tsx new file mode 100644 index 00000000000..4eb54fb68e8 --- /dev/null +++ b/apps/sim/app/access-requests/loading.tsx @@ -0,0 +1,5 @@ +import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading' + +export default function Loading() { + return +} diff --git a/apps/sim/app/access-requests/page.test.tsx b/apps/sim/app/access-requests/page.test.tsx new file mode 100644 index 00000000000..f1f50b8cae5 --- /dev/null +++ b/apps/sim/app/access-requests/page.test.tsx @@ -0,0 +1,68 @@ +/** @vitest-environment node */ +import { authMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { redirect } = vi.hoisted(() => ({ redirect: vi.fn() })) +vi.mock('next/navigation', () => ({ redirect })) +vi.mock('@/components/access-requests/my-access-requests', () => ({ MyAccessRequests: () => null })) +vi.mock('@/components/access-requests/organization-access-requests', () => ({ + OrganizationAccessRequests: () => null, +})) + +import AccessRequestsPage from '@/app/access-requests/page' + +describe('access request sign-in redirect', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue(null) + redirect.mockImplementation(() => { + throw new Error('Redirect') + }) + }) + + it.each([ + { + organizationId: 'organization', + view: 'catalog', + requestId: 'request', + search: 'Slack & Notion', + page: '3', + }, + { + organizationId: 'organization', + view: 'admin', + requestId: 'request', + 'request-status': 'declined', + 'request-page': '2', + }, + ])('preserves the supported $view state through sign-in', async (params) => { + await expect(AccessRequestsPage({ searchParams: Promise.resolve(params) })).rejects.toThrow( + 'Redirect' + ) + const loginUrl = new URL(redirect.mock.calls[0][0], 'https://example.com') + expect(loginUrl.pathname).toBe('/login') + const callback = new URL(loginUrl.searchParams.get('callbackUrl')!, loginUrl.origin) + expect(callback.pathname).toBe('/access-requests') + expect(Object.fromEntries(callback.searchParams)).toEqual(params) + }) + + it('drops invalid and unsupported state instead of forwarding raw query parameters', async () => { + await expect( + AccessRequestsPage({ + searchParams: Promise.resolve({ + organizationId: 'organization', + view: 'invalid', + page: '40001', + search: 'x'.repeat(201), + requestId: 'x'.repeat(129), + 'request-page': '-1', + 'request-status': 'invalid', + callbackUrl: 'https://example.com/untrusted', + }), + }) + ).rejects.toThrow('Redirect') + expect(redirect).toHaveBeenCalledWith( + `/login?callbackUrl=${encodeURIComponent('/access-requests?organizationId=organization')}` + ) + }) +}) diff --git a/apps/sim/app/access-requests/page.tsx b/apps/sim/app/access-requests/page.tsx new file mode 100644 index 00000000000..a3c90a5ec65 --- /dev/null +++ b/apps/sim/app/access-requests/page.tsx @@ -0,0 +1,67 @@ +import { Suspense } from 'react' +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { createSearchParamsCache, createSerializer } from 'nuqs/server' +import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading' +import { MyAccessRequests } from '@/components/access-requests/my-access-requests' +import { OrganizationAccessRequests } from '@/components/access-requests/organization-access-requests' +import { accessRequestEntrySearchParams } from '@/components/access-requests/search-params' +import { EmptyState } from '@/components/empty-state/empty-state' +import { getSession } from '@/lib/auth' +import { WORKSPACES_PATH } from '@/lib/navigation/paths' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' + +export const metadata: Metadata = { + title: 'Access requests', + robots: { index: false, follow: false }, +} + +interface AccessRequestsPageProps { + searchParams: Promise> +} + +const entrySearchParams = createSearchParamsCache(accessRequestEntrySearchParams) +const serializeEntrySearchParams = createSerializer(accessRequestEntrySearchParams) + +/** Session-only entry so access requests remain reachable outside the organization Search rollout. */ +export default async function AccessRequestsPage({ searchParams }: AccessRequestsPageProps) { + const [rawParams, session] = await Promise.all([searchParams, getSession()]) + const params = entrySearchParams.parse(rawParams) + if (!session?.user) { + redirect( + buildAuthCrossLink('/login', { + callbackUrl: serializeEntrySearchParams('/access-requests', params), + isInviteFlow: false, + }) + ) + } + + if (!params.organizationId) { + return ( + Your workspaces} + /> + ) + } + + return ( + }> + {params.view === 'admin' ? ( +
+
+
+

Access requests

+ Your workspaces +
+ +
+
+ ) : ( + + )} +
+ ) +} diff --git a/apps/sim/app/api/access-requests/[requestId]/cancel/route.ts b/apps/sim/app/api/access-requests/[requestId]/cancel/route.ts new file mode 100644 index 00000000000..c887c12d619 --- /dev/null +++ b/apps/sim/app/api/access-requests/[requestId]/cancel/route.ts @@ -0,0 +1,23 @@ +import { cancelAccessRequestContract } from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { cancelAccessRequest } from '@/lib/permission-access-requests/application/requests' + +export const POST = defineInternalJsonRoute({ + contract: cancelAccessRequestContract, + auth: internalSessionAuth, + operation: accessRequestOperations.cancel, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:write', + config: { maxTokens: 10, refillRate: 5, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ requestId: params.requestId, scope: body.scope }), + useCase: cancelAccessRequest, + present: ({ request }) => ({ request }), +}) diff --git a/apps/sim/app/api/access-requests/discovery/route.ts b/apps/sim/app/api/access-requests/discovery/route.ts new file mode 100644 index 00000000000..4e0e645fc41 --- /dev/null +++ b/apps/sim/app/api/access-requests/discovery/route.ts @@ -0,0 +1,22 @@ +import { discoverAccessRequestsContract } from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { discoverAccessRequests } from '@/lib/permission-access-requests/application/requests' + +export const GET = defineInternalJsonRoute({ + contract: discoverAccessRequestsContract, + auth: internalSessionAuth, + operation: accessRequestOperations.discover, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:read', + config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: discoverAccessRequests, +}) diff --git a/apps/sim/app/api/access-requests/route.ts b/apps/sim/app/api/access-requests/route.ts new file mode 100644 index 00000000000..74e2bd961c8 --- /dev/null +++ b/apps/sim/app/api/access-requests/route.ts @@ -0,0 +1,47 @@ +import { + createAccessRequestContract, + listMyAccessRequestsContract, +} from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { + createAccessRequest, + listMyAccessRequests, +} from '@/lib/permission-access-requests/application/requests' + +export const GET = defineInternalJsonRoute({ + contract: listMyAccessRequestsContract, + auth: internalSessionAuth, + operation: accessRequestOperations.listMine, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:read', + config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + scope: query, + limit: query.limit, + offset: query.offset, + requestId: query.requestId, + }), + useCase: listMyAccessRequests, +}) + +export const POST = defineInternalJsonRoute({ + contract: createAccessRequestContract, + auth: internalSessionAuth, + operation: accessRequestOperations.create, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:write', + config: { maxTokens: 10, refillRate: 5, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: createAccessRequest, + present: ({ request }) => ({ request }), +}) diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts index 6257b7f4519..40c8dd45f32 100644 --- a/apps/sim/app/api/auth/[...all]/route.test.ts +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -190,6 +190,76 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => { }) }) +describe('auth catch-all route password-reset mail', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + 'request-password-reset', + 'email-otp/request-password-reset', + 'forget-password/email-otp', + /** Matched by shape, so a plugin version that renames or adds an alias cannot reopen it. */ + 'request-password-reset/v2', + 'some-plugin/forget-password', + ])('blocks %s, which reaches the mailer without the per-recipient budget', async (path) => { + const req = createMockRequest('POST', undefined, {}, `http://localhost:3000/api/auth/${path}`) + + const res = await POST(req) + + expect(res.status).toBe(404) + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + await expect(res.json()).resolves.toEqual({ + error: 'Password reset is handled by application API routes.', + }) + }) + + /** The resend button on /verify calls this directly, so blocking it would break verification. */ + it('leaves the verification-code sender reachable for the purpose the product sends', async () => { + const req = createMockRequest( + 'POST', + { email: 'someone@example.com', type: 'email-verification' }, + {}, + 'http://localhost:3000/api/auth/email-otp/send-verification-otp' + ) + + await POST(req) + + expect(handlerMocks.betterAuthPOST).toHaveBeenCalled() + }) + + /** + * The same endpoint takes the OTP purpose from the body, and `forget-password` there sends reset + * mail to any address named — blocking the reset paths while leaving this open renames the hole. + */ + it.each(['forget-password', 'sign-in', 'change-email'])( + 'refuses the verification sender asked for %s', + async (type) => { + const req = createMockRequest( + 'POST', + { email: 'victim@example.com', type }, + {}, + 'http://localhost:3000/api/auth/email-otp/send-verification-otp' + ) + + expect((await POST(req)).status).toBe(404) + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + } + ) + + it('refuses the verification sender when the body cannot be read', async () => { + const req = createMockRequest( + 'POST', + undefined, + {}, + 'http://localhost:3000/api/auth/email-otp/send-verification-otp' + ) + + expect((await POST(req)).status).toBe(404) + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + }) +}) + describe('auth catch-all route organization mutations', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/auth/[...all]/route.ts b/apps/sim/app/api/auth/[...all]/route.ts index 32227242238..b0a80d114d5 100644 --- a/apps/sim/app/api/auth/[...all]/route.ts +++ b/apps/sim/app/api/auth/[...all]/route.ts @@ -15,6 +15,42 @@ export const dynamic = 'force-dynamic' const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler) const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active']) +/** + * Password-reset mail the plugin would send under a name Sim does not own. + * + * `/api/auth/forget-password` is an application route that owns the per-recipient budget (5 per 15 + * minutes, keyed on the address) and writes the `PASSWORD_RESET_REQUESTED` audit record. Every + * plugin alias reaches the same mailer with only a per-IP default in front of it, which a caller + * spread across addresses walks straight past, at one victim's mailbox. Matched rather than listed, + * like the SSO and OAuth guards below, so a plugin version that renames or adds an alias cannot + * quietly reopen the path. + */ +function isBlockedPasswordResetPath(path: string): boolean { + return /(^|\/)(request-password-reset|forget-password)(\/|$)/.test(path) +} + +/** The one OTP purpose a Sim surface sends: the resend button on `/verify`. */ +const ALLOWED_VERIFICATION_OTP_TYPE = 'email-verification' +const VERIFICATION_OTP_SENDER_PATH = 'email-otp/send-verification-otp' + +/** + * The same mailer again, reached by asking the verification sender for a different purpose. + * + * `email-otp/send-verification-otp` takes the OTP `type` from the request body, and + * `forget-password` there sends reset mail to any address named — so blocking the reset paths + * above while leaving this one open would only rename the hole. The endpoint stays reachable for + * the purpose the product actually sends, and an unreadable body is refused rather than forwarded. + */ +async function isBlockedVerificationOtpSend(request: NextRequest, path: string): Promise { + if (path !== VERIFICATION_OTP_SENDER_PATH) return false + // boundary-raw-json: the plugin owns this endpoint's schema; the guard reads one field to decide whether to forward the request at all + const body = await request + .clone() + .json() + .catch(() => null) + return (body as { type?: unknown } | null)?.type !== ALLOWED_VERIFICATION_OTP_TYPE +} + const OAUTH_CALLBACK_PATH_PREFIX = 'oauth2/callback/' const UNSUPPORTED_OIDC_PATHS = new Set([ '.well-known/openid-configuration', @@ -179,6 +215,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + if (isBlockedPasswordResetPath(path) || (await isBlockedVerificationOtpSend(request, path))) { + return NextResponse.json( + { error: 'Password reset is handled by application API routes.' }, + { status: 404 } + ) + } + if (isBlockedOAuthProviderMutationPath(path)) { return NextResponse.json( { error: 'OAuth client registration is not available.' }, diff --git a/apps/sim/app/api/auth/forget-password/route.test.ts b/apps/sim/app/api/auth/forget-password/route.test.ts index d246c5c1543..8c4085b8bb9 100644 --- a/apps/sim/app/api/auth/forget-password/route.test.ts +++ b/apps/sim/app/api/auth/forget-password/route.test.ts @@ -64,6 +64,7 @@ vi.mock('@sim/logger', () => ({ setRequestAuth: vi.fn(), })) +import { APIError } from 'better-auth/api' import { POST } from '@/app/api/auth/forget-password/route' describe('Forget Password API Route', () => { @@ -210,6 +211,24 @@ describe('Forget Password API Route', () => { expect(mockRequestPasswordReset).not.toHaveBeenCalled() }) + /** + * The route answers identically whether or not an account exists, so a refusal must not become a + * status the success path never produces — that alone would tell a caller which addresses are + * registered. It is logged rather than surfaced. + */ + it('answers a refusal Better Auth raises the way it answers a success', async () => { + mockRequestPasswordReset.mockRejectedValue( + new APIError('BAD_REQUEST', { message: 'invalid email' }) + ) + + const response = await POST(createMockRequest('POST', { email: 'someone@example.com' })) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ success: true }) + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalled() + }) + it('should handle auth service error with message', async () => { const errorMessage = 'User not found' @@ -223,7 +242,9 @@ describe('Forget Password API Route', () => { const data = await response.json() expect(response.status).toBe(500) - expect(data.message).toBe(errorMessage) + /** An unrecognized failure is ours, and its wording is not for an unauthenticated caller. */ + expect(data.message).toBe('Failed to send password reset email. Please try again later.') + expect(data.message).not.toContain(errorMessage) expect(mockLogger.error).toHaveBeenCalledWith('Error requesting password reset:', { error: expect.any(Error), diff --git a/apps/sim/app/api/auth/forget-password/route.ts b/apps/sim/app/api/auth/forget-password/route.ts index 9f0c7c1ce1f..5e022144431 100644 --- a/apps/sim/app/api/auth/forget-password/route.ts +++ b/apps/sim/app/api/auth/forget-password/route.ts @@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { forgetPasswordContract } from '@/lib/api/contracts' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { auth } from '@/lib/auth' +import { getBetterAuthClientErrorStatus } from '@/lib/auth/better-auth-error' import { enforceIpRateLimitWithIndependentBackstop, enforceRecipientRateLimit, @@ -92,18 +93,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true }) } catch (error) { + /** + * A refusal Better Auth raises is not a server fault, but it must not become a distinguishable + * answer either: this route replies identically whether or not an account exists, and a status + * the success path never produces would tell a caller which addresses are registered. So it is + * logged and answered like a success — only the reset half, where the caller already holds the + * token and has nothing left to enumerate, surfaces the refusal. + */ + const clientStatus = getBetterAuthClientErrorStatus(error) + if (clientStatus !== undefined) { + logger.warn('Rejected a password reset request', { status: clientStatus }) + return NextResponse.json({ success: true }) + } + logger.error('Error requesting password reset:', { error }) return NextResponse.json( - { - message: - // utils-lint-allow: returned to an unauthenticated caller, so a non-Error throw - // must surface the fixed copy rather than its own text — getErrorMessage would - // pass a thrown string straight through. - error instanceof Error - ? error.message - : 'Failed to send password reset email. Please try again later.', - }, + { message: 'Failed to send password reset email. Please try again later.' }, { status: 500 } ) } diff --git a/apps/sim/app/api/auth/reset-password/route.test.ts b/apps/sim/app/api/auth/reset-password/route.test.ts index b7038f796fb..67724acd539 100644 --- a/apps/sim/app/api/auth/reset-password/route.test.ts +++ b/apps/sim/app/api/auth/reset-password/route.test.ts @@ -46,6 +46,7 @@ vi.mock('@sim/logger', () => ({ setRequestAuth: vi.fn(), })) +import { APIError } from 'better-auth/api' import { POST } from '@/app/api/auth/reset-password/route' describe('Reset Password API Route', () => { @@ -160,6 +161,22 @@ describe('Reset Password API Route', () => { expect(mockResetPassword).not.toHaveBeenCalled() }) + it('refuses an invalid or expired token with a 400, not a server error', async () => { + // Better Auth reports a consumed, expired, or fabricated token as a 400-class APIError. + // Re-emitting that as a 500 paged on a routine click of a stale reset link. + mockResetPassword.mockRejectedValue(new APIError('BAD_REQUEST', { message: 'invalid token' })) + + const response = await POST( + createMockRequest('POST', { token: 'expired-token', newPassword: 'newSecurePassword123!' }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + message: 'This reset link is invalid or has expired. Please request a new one.', + }) + expect(mockLogger.error).not.toHaveBeenCalled() + }) + it('should handle auth service error with message', async () => { const errorMessage = 'Invalid or expired token' @@ -174,7 +191,11 @@ describe('Reset Password API Route', () => { const data = await response.json() expect(response.status).toBe(500) - expect(data.message).toBe(errorMessage) + /** An unrecognized failure is ours, and its wording is not for an unauthenticated caller. */ + expect(data.message).toBe( + 'Failed to reset password. Please try again or request a new reset link.' + ) + expect(data.message).not.toContain(errorMessage) expect(mockLogger.error).toHaveBeenCalledWith('Error during password reset:', { error: expect.any(Error), diff --git a/apps/sim/app/api/auth/reset-password/route.ts b/apps/sim/app/api/auth/reset-password/route.ts index 469738fd04f..4bd54347573 100644 --- a/apps/sim/app/api/auth/reset-password/route.ts +++ b/apps/sim/app/api/auth/reset-password/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { resetPasswordContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { auth } from '@/lib/auth' +import { getBetterAuthClientErrorStatus } from '@/lib/auth/better-auth-error' import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -55,18 +56,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true }) } catch (error) { + /** An expired or reused token is the caller's to fix; the fixed copy names the recovery. */ + const clientStatus = getBetterAuthClientErrorStatus(error) + if (clientStatus !== undefined) { + logger.warn('Rejected a password reset', { status: clientStatus }) + return NextResponse.json( + { message: 'This reset link is invalid or has expired. Please request a new one.' }, + { status: 400 } + ) + } + logger.error('Error during password reset:', { error }) return NextResponse.json( - { - message: - // utils-lint-allow: returned to an unauthenticated caller, so a non-Error throw - // must surface the fixed copy rather than its own text — getErrorMessage would - // pass a thrown string straight through. - error instanceof Error - ? error.message - : 'Failed to reset password. Please try again or request a new reset link.', - }, + { message: 'Failed to reset password. Please try again or request a new reset link.' }, { status: 500 } ) } diff --git a/apps/sim/app/api/auth/socket-token/route.ts b/apps/sim/app/api/auth/socket-token/route.ts index 5140d0d62f7..a5afc157e03 100644 --- a/apps/sim/app/api/auth/socket-token/route.ts +++ b/apps/sim/app/api/auth/socket-token/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' import { headers } from 'next/headers' import { type NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' +import { getBetterAuthClientErrorStatus } from '@/lib/auth/better-auth-error' import { isAuthDisabled } from '@/lib/core/config/env-flags' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -40,14 +41,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ token: response.token }) } catch (error) { - // better-auth's sessionMiddleware throws APIError("UNAUTHORIZED") with no message - // when the session is missing/expired — surface this as a 401, not a 500. - if ( - error instanceof Error && - ('statusCode' in error || 'status' in error) && - ((error as Record).statusCode === 401 || - (error as Record).status === 'UNAUTHORIZED') - ) { + /** + * better-auth's sessionMiddleware throws `APIError("UNAUTHORIZED")` with no message when the + * session is missing or expired — surface that as a 401, not a 500. + */ + if (getBetterAuthClientErrorStatus(error) === 401) { logger.warn('Socket token request with invalid/expired session') return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } diff --git a/apps/sim/app/api/auth/sso/providers/[providerId]/route.ts b/apps/sim/app/api/auth/sso/providers/[providerId]/route.ts index 6f08b425e41..cf8739ddb2d 100644 --- a/apps/sim/app/api/auth/sso/providers/[providerId]/route.ts +++ b/apps/sim/app/api/auth/sso/providers/[providerId]/route.ts @@ -16,6 +16,7 @@ import { setPrimarySsoProvider, setPrimarySsoProviderOperation, } from '@/lib/auth/sso/application/set-primary-provider' +import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' @@ -105,6 +106,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou return NextResponse.json({ error: 'Provider not found' }, { status: 404 }) } + /** The organization may have just lost the provider its sign-in requirement depends on. */ + if (organizationId) invalidateSsoPolicyCache(organizationId) + logger.info('Deleted SSO provider', { providerId, organizationId, diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index 1c273ddd046..04c3c94c800 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -8,6 +8,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { ssoRegistrationContract } from '@/lib/api/contracts/auth' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth' +import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' import { hasSSOAccess } from '@/lib/billing' import { isSsoEnabled } from '@/lib/core/config/env-flags' import { runWithOutboundOrganization } from '@/lib/core/network/context.server' @@ -756,6 +757,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return domainNotVerifiedResponse() } + /** The edit may have changed whether this provider can satisfy the sign-in requirement. */ + invalidateSsoPolicyCache(orgId) + logger.info('SSO provider updated successfully', { providerId, providerType, domain }) return NextResponse.json({ success: true, @@ -801,6 +805,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return domainNotVerifiedResponse() } + /** A new provider can make an organization able to require single sign-on again. */ + invalidateSsoPolicyCache(orgId) + logger.info('SSO provider registered successfully', { providerId, providerType, diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts index 42208e91a8d..64ced142086 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts @@ -28,6 +28,8 @@ const { mockGetUserEntityPermissions, mockGetWorkspaceBillingSettings, mockAuthorizeOrganizationChat, + mockAuthorizeCallback, + mockCheckContinuationBilling, } = vi.hoisted(() => ({ mockCheckInternalApiKey: vi.fn(), mockCheckAttributedUsageLimits: vi.fn(), @@ -44,6 +46,8 @@ const { mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceBillingSettings: vi.fn(), mockAuthorizeOrganizationChat: vi.fn(), + mockAuthorizeCallback: vi.fn(), + mockCheckContinuationBilling: vi.fn(), })) const ATTRIBUTION = { @@ -85,7 +89,8 @@ const SELF_HOSTED_OPAQUE_WORKSPACE_VALIDATE_BODY = { workspaceId: 'local-self-hosted-workspace', } as const -vi.mock('@/lib/billing/core/billing-attribution', () => ({ +vi.mock('@/lib/billing/core/billing-attribution', async (importOriginal) => ({ + ...(await importOriginal()), BILLING_ACCOUNT_DECISION_HEADER: 'x-sim-billing-account-decision', BILLING_ATTRIBUTION_HEADER: 'x-sim-billing-attribution', BILLING_REQUEST_ID_HEADER: 'x-sim-billing-request-id', @@ -120,6 +125,11 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ deriveBillingContext: mockDeriveBillingContext, })) +vi.mock('@/lib/copilot/application/authorize-chat-callback', () => ({ + authorizeCopilotChatCallback: mockAuthorizeCallback, + checkCopilotContinuationBilling: mockCheckContinuationBilling, +})) + vi.mock('@/lib/copilot/chat/organization-chats', () => ({ authorizeOrganizationChatDelegation: { execute: mockAuthorizeOrganizationChat }, })) @@ -158,7 +168,9 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - setEnvFlags({ isHosted: false }) + setEnvFlags({ isHosted: false, isBillingEnabled: false }) + mockAuthorizeCallback.mockResolvedValue(undefined) + mockCheckContinuationBilling.mockResolvedValue({ blocked: false }) mockCheckInternalApiKey.mockReturnValue({ success: true }) queueTableRows(schemaMock.user, [{ id: 'user-1' }]) mockResolveBillingAttribution.mockResolvedValue(ATTRIBUTION) @@ -558,3 +570,327 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { expect(mockResolveBillingAttribution).not.toHaveBeenCalled() }) }) + +describe('validation lifecycle purposes', () => { + const requestId = '0190c03f-9f7d-4b79-8b58-e7f779fd29e1' + const encode = (value: object) => encodeURIComponent(JSON.stringify(value)) + const attributedHeaders = { + 'x-sim-billing-protocol': 'attribution-v1', + 'x-sim-billing-request-id': requestId, + 'x-sim-billing-attribution': encode(ATTRIBUTION), + } + const directHeaders = { + 'x-sim-billing-protocol': 'direct-v1', + 'x-sim-billing-request-id': requestId, + 'x-sim-billing-account-decision': encode(ACCOUNT_BILLING_DECISION), + } + const body = { userId: 'user-1', workspaceId: 'ws-1', chatId: 'chat-1', purpose: 'continuation' } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isHosted: true, isBillingEnabled: true }) + for (let call = 0; call < 3; call++) queueTableRows(schemaMock.user, [{ id: 'user-1' }]) + mockCheckInternalApiKey.mockReturnValue({ success: true }) + mockAuthorizeCallback.mockReset().mockResolvedValue(undefined) + mockCheckContinuationBilling.mockReset().mockResolvedValue({ blocked: false }) + mockIsEnterprisePlan.mockResolvedValue(false) + }) + + it('defaults older callers to full admission and rejects unknown purposes', () => { + expect(validateCopilotApiKeyBodySchema.parse({ userId: 'user-1' }).purpose).toBe('new-turn') + expect( + validateCopilotApiKeyBodySchema.safeParse({ userId: 'user-1', purpose: 'skip' }).success + ).toBe(false) + }) + + it.each(['continuation', 'cancellation'])( + 'authenticates before processing %s', + async (purpose) => { + mockCheckInternalApiKey.mockReturnValueOnce({ success: false }) + expect((await POST(request({ ...body, purpose }, attributedHeaders))).status).toBe(401) + expect(mockAuthorizeCallback).not.toHaveBeenCalled() + expect(mockCheckContinuationBilling).not.toHaveBeenCalled() + } + ) + + it('checks original payer and current scope without repeating spend admission', async () => { + const response = await POST(request(body, attributedHeaders)) + expect(response.status).toBe(200) + expect(mockAuthorizeCallback).toHaveBeenCalledWith({ ...body, delegationId: requestId }) + expect(mockCheckContinuationBilling).toHaveBeenCalledWith({ + kind: 'attributed', + attribution: ATTRIBUTION, + }) + expect(mockAuthorizeCallback.mock.invocationCallOrder[0]).toBeLessThan( + mockCheckContinuationBilling.mock.invocationCallOrder[0] + ) + expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled() + expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled() + expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled() + expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() + expect(response.headers.get('x-sim-billing-attribution')).toBeNull() + expect(response.headers.get('x-sim-billing-account-decision')).toBeNull() + }) + + it('refreshes entitlement with the stored account payer and opaque direct scope', async () => { + mockIsEnterprisePlan.mockResolvedValueOnce(true) + const response = await POST( + request({ ...body, workspaceId: 'opaque-local-workspace' }, directHeaders) + ) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ isEnterprise: true }) + expect(mockCheckContinuationBilling).toHaveBeenCalledWith({ + kind: 'account', + decision: ACCOUNT_BILLING_DECISION, + }) + expect(mockAuthorizeCallback).not.toHaveBeenCalled() + expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() + expect(mockDeriveBillingContext).not.toHaveBeenCalled() + expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled() + expect(response.headers.get('x-sim-billing-account-decision')).toBeNull() + }) + + it.each([ + ['missing attribution', { ...attributedHeaders, 'x-sim-billing-attribution': '' }], + [ + 'malformed attribution', + { ...attributedHeaders, 'x-sim-billing-attribution': 'invalid-json' }, + ], + ['missing id', { ...attributedHeaders, 'x-sim-billing-request-id': '' }], + [ + 'conflicting material', + { ...attributedHeaders, 'x-sim-billing-account-decision': encode(ACCOUNT_BILLING_DECISION) }, + ], + [ + 'another actor', + { + ...attributedHeaders, + 'x-sim-billing-attribution': encode({ ...ATTRIBUTION, actorUserId: 'other-user' }), + }, + ], + [ + 'another workspace', + { + ...attributedHeaders, + 'x-sim-billing-attribution': encode({ ...ATTRIBUTION, workspaceId: 'other-workspace' }), + }, + ], + ['missing account decision', { ...directHeaders, 'x-sim-billing-account-decision': '' }], + [ + 'malformed account decision', + { ...directHeaders, 'x-sim-billing-account-decision': 'invalid-json' }, + ], + [ + 'different account actor', + { + ...directHeaders, + 'x-sim-billing-account-decision': encode({ + ...ACCOUNT_BILLING_DECISION, + userId: 'other-user', + }), + }, + ], + [ + 'direct conflicting material', + { ...directHeaders, 'x-sim-billing-attribution': encode(ATTRIBUTION) }, + ], + ])('rejects %s before authorization or billing', async (_label, headers) => { + expect((await POST(request(body, headers))).status).toBe(400) + expect(mockAuthorizeCallback).not.toHaveBeenCalled() + expect(mockCheckContinuationBilling).not.toHaveBeenCalled() + expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled() + expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled() + }) + + it('binds organization continuation to original actor, scope and private chat', async () => { + const attribution = { ...ATTRIBUTION, workspaceId: null } + const orgBody = { + userId: 'user-1', + organizationId: 'org-1', + chatId: 'chat-1', + purpose: 'continuation', + } + const headers = { ...attributedHeaders, 'x-sim-billing-attribution': encode(attribution) } + expect((await POST(request(orgBody, headers))).status).toBe(200) + expect(mockAuthorizeCallback).toHaveBeenCalledWith({ ...orgBody, delegationId: requestId }) + expect(mockCheckContinuationBilling).toHaveBeenCalledWith({ kind: 'attributed', attribution }) + expect((await POST(request({ ...orgBody, organizationId: 'other-org' }, headers))).status).toBe( + 400 + ) + }) + + it.each(['continuation', 'cancellation'])('rejects a deleted actor on %s', async (purpose) => { + resetDbChainMock() + queueTableRows(schemaMock.user, []) + expect((await POST(request({ ...body, purpose }, attributedHeaders))).status).toBe(403) + expect(mockAuthorizeCallback).not.toHaveBeenCalled() + expect(mockCheckContinuationBilling).not.toHaveBeenCalled() + }) + + it.each(['continuation', 'cancellation'])( + 'rejects revoked scope on %s before billing', + async (purpose) => { + mockAuthorizeCallback.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Access revoked') + ) + expect((await POST(request({ ...body, purpose }, attributedHeaders))).status).toBe(403) + expect(mockCheckContinuationBilling).not.toHaveBeenCalled() + } + ) + + it('fails closed on scope or account-standing infrastructure errors', async () => { + mockAuthorizeCallback.mockRejectedValueOnce(new Error('database unavailable')) + expect((await POST(request(body, attributedHeaders))).status).toBe(500) + mockCheckContinuationBilling.mockRejectedValueOnce(new Error('database unavailable')) + expect((await POST(request(body, attributedHeaders))).status).toBe(500) + }) + + it.each(['actor', 'payer'])('refuses a newly blocked %s on continuation', async (scope) => { + mockCheckContinuationBilling.mockResolvedValueOnce({ blocked: true, scope }) + expect((await POST(request(body, attributedHeaders))).status).toBe(402) + expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled() + }) + + it('allows cancellation without billing material or spending/standing/plan checks', async () => { + const response = await POST( + request({ ...body, purpose: 'cancellation' }, { 'x-sim-billing-protocol': 'attribution-v1' }) + ) + expect(response.status).toBe(200) + expect(mockAuthorizeCallback).toHaveBeenCalledWith( + expect.objectContaining({ purpose: 'cancellation', workspaceId: 'ws-1' }) + ) + expect(mockCheckContinuationBilling).not.toHaveBeenCalled() + expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled() + expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled() + expect(mockIsEnterprisePlan).not.toHaveBeenCalled() + await expect(response.json()).resolves.toEqual({ isEnterprise: false }) + }) + + it('reuses a legacy checkpoint snapshot without selecting a new payer', async () => { + const response = await POST( + request(body, { + 'x-sim-billing-protocol': 'legacy-v0', + 'x-sim-billing-attribution': encode(ATTRIBUTION), + }) + ) + expect(response.status).toBe(200) + expect(mockCheckContinuationBilling).toHaveBeenCalledWith({ + kind: 'attributed', + attribution: ATTRIBUTION, + }) + expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled() + }) + + it.each([true, false])( + 'rejects a legacy organization snapshot with omitted scope even when hosted=%s', + async (isHosted) => { + setEnvFlags({ isHosted, isBillingEnabled: isHosted }) + const response = await POST( + request( + { userId: 'user-1', purpose: 'continuation' }, + { + 'x-sim-billing-protocol': 'legacy-v0', + 'x-sim-billing-attribution': encode({ ...ATTRIBUTION, workspaceId: null }), + } + ) + ) + expect(response.status).toBe(400) + expect(mockAuthorizeCallback).not.toHaveBeenCalled() + expect(mockCheckContinuationBilling).not.toHaveBeenCalled() + } + ) + + it.each([ + [true, true, 400], + [false, true, 400], + [false, false, 200], + ])( + 'allows missing legacy material only for unbilled self-hosting (%s, %s)', + async (isHosted, isBillingEnabled, status) => { + setEnvFlags({ isHosted, isBillingEnabled }) + expect((await POST(request(body, { 'x-sim-billing-protocol': 'legacy-v0' }))).status).toBe( + status + ) + expect(mockCheckContinuationBilling).not.toHaveBeenCalled() + expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled() + } + ) + + it.each(['continuation', 'cancellation'])( + 'preserves markerless unbilled local %s with an opaque workspace', + async (purpose) => { + setEnvFlags({ isHosted: false, isBillingEnabled: false }) + expect( + (await POST(request({ ...body, workspaceId: 'opaque-local-workspace', purpose }))).status + ).toBe(200) + expect(mockAuthorizeCallback).not.toHaveBeenCalled() + expect(mockCheckContinuationBilling).not.toHaveBeenCalled() + expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled() + } + ) + + it.each(['continuation', 'cancellation'])( + 'still checks snapshot scope on unbilled local %s', + async (purpose) => { + setEnvFlags({ isHosted: false, isBillingEnabled: false }) + const headers = { + 'x-sim-billing-protocol': 'legacy-v0', + 'x-sim-billing-attribution': encode(ATTRIBUTION), + } + expect((await POST(request({ ...body, purpose }, headers))).status).toBe(200) + expect(mockAuthorizeCallback).toHaveBeenCalledWith( + expect.objectContaining({ purpose, workspaceId: 'ws-1' }) + ) + } + ) + + it.each([ + ['attribution-v1', false, false], + ['legacy-v0', true, true], + ['legacy-v0', true, false], + ['legacy-v0', false, true], + ])( + 'checks cancellation scope for %s (hosted=%s, billing=%s)', + async (protocol, isHosted, isBillingEnabled) => { + setEnvFlags({ isHosted, isBillingEnabled }) + expect( + ( + await POST( + request({ ...body, purpose: 'cancellation' }, { 'x-sim-billing-protocol': protocol }) + ) + ).status + ).toBe(200) + expect(mockAuthorizeCallback).toHaveBeenCalled() + } + ) + + it('refuses hosted cancellation without a protocol or resource scope', async () => { + expect((await POST(request({ ...body, purpose: 'cancellation' }))).status).toBe(400) + expect( + ( + await POST( + request( + { userId: 'user-1', purpose: 'cancellation' }, + { 'x-sim-billing-protocol': 'attribution-v1' } + ) + ) + ).status + ).toBe(400) + expect(mockAuthorizeCallback).not.toHaveBeenCalled() + }) + + it('checks fresh spending on the next new turn and refuses supplied account decisions', async () => { + expect((await POST(request(body, attributedHeaders))).status).toBe(200) + mockCheckAttributedUsageLimits.mockResolvedValueOnce({ + isExceeded: true, + payerUsage: { currentUsage: 120, limit: 100 }, + }) + expect((await POST(request({ ...body, purpose: 'new-turn' }, attributedHeaders))).status).toBe( + 402 + ) + expect(mockCheckAttributedUsageLimits).toHaveBeenCalledTimes(1) + expect((await POST(request({ ...body, purpose: 'new-turn' }, directHeaders))).status).toBe(400) + expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index 970220f9099..174a22250af 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -11,7 +11,9 @@ import { type AccountBillingDecision, type BillingAttributionSnapshot, checkAttributedUsageLimits, + requireAccountBillingDecisionHeader, requireBillingAttributionHeader, + requireBillingCallbackAttribution, requireBillingRequestIdHeader, resolveLegacyV0BillingAttribution, resolveOrganizationBillingAttribution, @@ -21,6 +23,11 @@ import { import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { isEnterprisePlan } from '@/lib/billing/core/subscription' import { deriveBillingContext } from '@/lib/billing/core/usage-log' +import { + authorizeCopilotChatCallback, + type CopilotContinuationBilling, + checkCopilotContinuationBilling, +} from '@/lib/copilot/application/authorize-chat-callback' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, createTrustedOrganizationCopilotPrincipal, @@ -32,6 +39,7 @@ import { BILLING_REQUEST_ID_HEADER, COPILOT_BILLING_PROTOCOL, COPILOT_BILLING_PROTOCOL_HEADER, + COPILOT_VALIDATION_PURPOSE, type CopilotBillingProtocol, } from '@/lib/copilot/generated/billing-protocol-v1' import { CopilotValidateOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' @@ -39,7 +47,7 @@ import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' -import { isHosted } from '@/lib/core/config/env-flags' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -69,12 +77,12 @@ type AdmissionBillingDecision = } /** - * Resolves admission against the versioned Go callback protocol. + * Resolves new-turn admission against the versioned Go callback protocol. * * Markerless self-hosted admission is legacy-v0. A locally resolvable * workspace selects its current payer; an absent or opaque workspace preserves - * account billing. This mutable resolution is repeated at callback time for - * local self-hosted compatibility. Direct-v1 remains scoped only to the + * account billing. Only new-turn admission resolves a mutable payer; continuation + * restores the original checkpoint decision. Direct-v1 remains scoped only to the * authenticated Chat/Copilot key owner's hosted account, and attributed-v1 * never falls back from its immutable envelope. */ @@ -178,6 +186,52 @@ async function resolveAdmissionBillingDecision( return { kind: 'legacy-account', userId: actorUserId } } +/** Restores only the checkpoint's admitted payer; continuation never re-resolves billing. */ +function resolveContinuationBilling( + req: NextRequest, + protocol: CopilotBillingProtocol | undefined, + scope: { userId: string; workspaceId?: string; organizationId?: string } +): CopilotContinuationBilling | null | NextResponse { + const hasAttribution = Boolean(req.headers.get(BILLING_ATTRIBUTION_HEADER)) + const hasDecision = Boolean(req.headers.get(BILLING_ACCOUNT_DECISION_HEADER)) + try { + if (protocol === COPILOT_BILLING_PROTOCOL.direct) { + if (hasAttribution) return invalidBillingProtocolResponse() + requireBillingRequestIdHeader(req.headers) + const decision = requireAccountBillingDecisionHeader(req.headers) + if (decision.userId !== scope.userId) return invalidBillingProtocolResponse() + return { kind: 'account', decision } + } + if (hasDecision) return invalidBillingProtocolResponse() + if (protocol === COPILOT_BILLING_PROTOCOL.attributed) { + if (!scope.workspaceId && !scope.organizationId) return invalidBillingProtocolResponse() + requireBillingRequestIdHeader(req.headers) + } else { + if (protocol !== undefined && protocol !== COPILOT_BILLING_PROTOCOL.legacy) { + return invalidBillingProtocolResponse() + } + if (req.headers.has(BILLING_REQUEST_ID_HEADER)) return invalidBillingProtocolResponse() + if (protocol === undefined && (isHosted || hasAttribution)) { + return invalidBillingProtocolResponse() + } + if (!hasAttribution) { + return !isHosted && !isBillingEnabled ? null : invalidBillingProtocolResponse() + } + } + if (!scope.workspaceId && !scope.organizationId) return invalidBillingProtocolResponse() + return { + kind: 'attributed', + attribution: requireBillingCallbackAttribution(req.headers, { + actorUserId: scope.userId, + workspaceId: scope.workspaceId, + organizationId: scope.organizationId, + }), + } + } catch { + return invalidBillingProtocolResponse() + } +} + async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise<{ isExceeded: boolean currentUsage: number @@ -287,7 +341,8 @@ export const POST = withRouteHandler((req: NextRequest) => ) if (!parsed.success) return parsed.response - const { userId, workspaceId, organizationId, chatId } = parsed.data.body + const { userId, workspaceId, organizationId, chatId, purpose } = parsed.data.body + const startedAt = performance.now() const protocol = parsed.data.headers?.[COPILOT_BILLING_PROTOCOL_HEADER] span.setAttribute(TraceAttr.UserId, userId) @@ -299,7 +354,72 @@ export const POST = withRouteHandler((req: NextRequest) => return NextResponse.json({ error: 'User not found' }, { status: 403 }) } - logger.info('[API VALIDATION] Validating usage limit', { userId }) + if (purpose !== COPILOT_VALIDATION_PURPOSE.newTurn) { + const billing = + purpose === COPILOT_VALIDATION_PURPOSE.continuation + ? resolveContinuationBilling(req, protocol, { userId, workspaceId, organizationId }) + : null + if (billing instanceof NextResponse || (protocol === undefined && isHosted)) { + span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InvalidBody) + span.setAttribute(TraceAttr.HttpStatusCode, 400) + return billing instanceof NextResponse ? billing : invalidBillingProtocolResponse() + } + + /** Unbilled local legacy turns have no admitted Sim resource scope to restore. */ + const localUnbilledCallback = + (protocol === undefined || protocol === COPILOT_BILLING_PROTOCOL.legacy) && + !req.headers.has(BILLING_ATTRIBUTION_HEADER) && + !req.headers.has(BILLING_ACCOUNT_DECISION_HEADER) && + !isHosted && + !isBillingEnabled + if ( + purpose === COPILOT_VALIDATION_PURPOSE.cancellation && + protocol !== COPILOT_BILLING_PROTOCOL.direct && + !localUnbilledCallback && + !workspaceId && + !organizationId + ) { + span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InvalidBody) + span.setAttribute(TraceAttr.HttpStatusCode, 400) + return invalidBillingProtocolResponse() + } + /** Direct keys carry self-hosted scope IDs that do not name hosted Sim resources. */ + if (protocol !== COPILOT_BILLING_PROTOCOL.direct && !localUnbilledCallback) { + await authorizeCopilotChatCallback({ + userId, + workspaceId, + organizationId, + chatId, + purpose, + delegationId: req.headers.get(BILLING_REQUEST_ID_HEADER) ?? generateId(), + }) + } + const blocked = billing ? await checkCopilotContinuationBilling(billing) : null + logger.info('[API VALIDATION] Lifecycle authorization validated', { + userId, + purpose, + billingProtocol: protocol ?? COPILOT_BILLING_PROTOCOL.legacy, + blocked: blocked?.blocked ?? false, + elapsedMs: Math.round(performance.now() - startedAt), + }) + if (blocked?.blocked) { + span.setAttribute( + TraceAttr.CopilotValidateOutcome, + CopilotValidateOutcome.UsageExceeded + ) + span.setAttribute(TraceAttr.HttpStatusCode, 402) + return new NextResponse(null, { status: 402 }) + } + const isEnterprise = + purpose === COPILOT_VALIDATION_PURPOSE.cancellation + ? false + : await isEnterprisePlan(userId) + span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.Ok) + span.setAttribute(TraceAttr.HttpStatusCode, 200) + return NextResponse.json({ isEnterprise }) + } + + logger.info('[API VALIDATION] Validating usage limit', { userId, purpose }) const admission = await resolveAdmissionBillingDecision( req, protocol, @@ -323,6 +443,8 @@ export const POST = withRouteHandler((req: NextRequest) => logger.info('[API VALIDATION] Usage limit validated', { userId, + purpose, + elapsedMs: Math.round(performance.now() - startedAt), currentUsage, limit, isExceeded: usage.isExceeded, diff --git a/apps/sim/app/api/copilot/chat/abort/route.test.ts b/apps/sim/app/api/copilot/chat/abort/route.test.ts index dcd1c74dd33..ace30ab46d8 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.test.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.test.ts @@ -33,7 +33,7 @@ const { }) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ - getAccessibleCopilotChatAuth: mockGetAccessibleChat, + getAccessibleCopilotChatForCancellation: mockGetAccessibleChat, })) vi.mock('@/lib/copilot/request/http', () => ({ @@ -100,6 +100,24 @@ describe('POST /api/copilot/chat/abort', () => { expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'stream-1') }) + it('authorizes org Stop using the cancellation lookup and forwards the canonical scope', async () => { + const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-1', isAuthenticated: true, principal }) + mockGetLatestRunForStream.mockResolvedValueOnce({ chatId: 'chat-1', workspaceId: null }) + mockGetAccessibleChat.mockResolvedValueOnce({ id: 'chat-1', organizationId: 'org-1' }) + const response = await POST(abortRequest()) + expect(response.status).toBe(200) + expect(mockGetAccessibleChat).toHaveBeenCalledWith('chat-1', 'user-1', { principal }) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'chat-1', + userId: 'user-1', + organizationId: 'org-1', + workspaceId: undefined, + }) + ) + }) + it('refuses an inaccessible organization chat before changing stream state', async () => { mockGetAccessibleChat.mockResolvedValueOnce(null) const response = await POST(abortRequest()) diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts index 8aea11d6439..420e5b6eeda 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { copilotChatAbortBodySchema } from '@/lib/api/contracts/copilot' import { validationErrorResponse } from '@/lib/api/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' -import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' +import { getAccessibleCopilotChatForCancellation } from '@/lib/copilot/chat/lifecycle' import { CopilotAbortOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' @@ -75,7 +75,9 @@ export const POST = withRouteHandler((request: NextRequest) => return NextResponse.json({ error: 'Stream not found' }, { status: 404 }) } const chat = run.chatId - ? await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal }) + ? await getAccessibleCopilotChatForCancellation(run.chatId, authenticatedUserId, { + principal, + }) : null if (run.chatId && !chat) { return NextResponse.json({ error: 'Stream not found' }, { status: 404 }) diff --git a/apps/sim/app/api/credential-groups/oauth-callback.test.ts b/apps/sim/app/api/credential-groups/oauth-callback.test.ts index 4a15fcadf1a..c132e570485 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.test.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.test.ts @@ -1,4 +1,5 @@ /** @vitest-environment node */ +import { sha256Hex } from '@sim/security/hash' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -11,6 +12,7 @@ const mocks = vi.hoisted(() => ({ consumeAttempt: vi.fn(), logError: vi.fn(), completeSetupOAuth: vi.fn(), + authenticateSession: vi.fn(), })) vi.mock('@sim/logger', () => ({ @@ -21,7 +23,7 @@ vi.mock('@/lib/knowledge/application/github-setup', () => ({ })) vi.mock('@/lib/api/server/routes', () => ({ internalSessionAuth: { - authenticate: async () => ({ kind: 'session', userId: 'admin', sessionId: 'browser' }), + authenticate: mocks.authenticateSession, }, })) vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) @@ -65,8 +67,8 @@ describe('GitHub managed OAuth failure presentation', () => { describe.each([false, true])('completion redirect: %s', (completionRedirect) => { it.each([ { - failure: new OAuthIdentityVerificationError('email_mismatch', 'emails'), - status: 'github_email_mismatch', + failure: new OAuthIdentityVerificationError('email_unverified', 'emails'), + status: 'github_email_unverified', }, { failure: new OAuthIdentityVerificationError('email_access_denied', 'emails', 403), @@ -127,6 +129,55 @@ describe('GitHub managed OAuth failure presentation', () => { provider: 'github-repositories', failure: 'failed', errorClass: 'unexpected', + stage: 'enrollment_completion', + errorType: 'Error', + fingerprint: sha256Hex('member@example.com ghu_token').slice(0, 12), + }) + }) + + it('identifies a wrapped database failure without logging SQL, parameters, or provider data', async () => { + const cause = Object.assign(new Error('duplicate key for member@example.com'), { + name: 'PostgresError', + code: '23505', + detail: 'ghu_private_token', + }) + mocks.consumeAttempt.mockResolvedValue(attempt) + mocks.completeOAuth.mockRejectedValueOnce( + new Error('Failed query: INSERT INTO credential\nparams: ghu_private_token', { cause }) + ) + const response = await completeCallback() + expect(response.headers.get('location')).toContain('oauth=failed') + expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', { + provider: 'github-repositories', + failure: 'failed', + errorClass: 'unexpected', + stage: 'enrollment_completion', + errorType: 'PostgresError', + databaseCode: '23505', + fingerprint: sha256Hex(cause.message).slice(0, 12), + }) + const logged = JSON.stringify(mocks.logError.mock.calls) + expect(logged).not.toContain('member@example.com') + expect(logged).not.toContain('ghu_private_token') + expect(logged).not.toContain('INSERT') + }) + + it('does not log arbitrary error names or codes as diagnostic metadata', async () => { + mocks.consumeAttempt.mockResolvedValue(attempt) + mocks.completeOAuth.mockRejectedValueOnce( + Object.assign(new Error('private provider response'), { + name: 'ghu_private_token', + code: 'client_secret=private', + }) + ) + await completeCallback() + expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', { + provider: 'github-repositories', + failure: 'failed', + errorClass: 'unexpected', + stage: 'enrollment_completion', + errorType: 'UnknownError', + fingerprint: sha256Hex('private provider response').slice(0, 12), }) }) @@ -149,7 +200,40 @@ describe('GitHub managed OAuth failure presentation', () => { describe('GitHub installation setup OAuth return target', () => { beforeEach(() => { vi.clearAllMocks() + mocks.authenticateSession.mockResolvedValue({ + kind: 'session', + userId: 'admin', + sessionId: 'browser', + }) }) + + it.each(['session_authentication', 'setup_completion'])( + 'identifies an unexpected failure during %s without exposing its message', + async (stage) => { + mocks.consumeAttempt.mockResolvedValue({ + ...attempt, + returnTo: 'github-installation', + organizationId: 'organization', + completionId, + }) + const error = new TypeError('private callback data') + if (stage === 'session_authentication') { + mocks.authenticateSession.mockRejectedValueOnce(error) + } else { + mocks.completeSetupOAuth.mockRejectedValueOnce(error) + } + const response = await completeCallback() + expect(response.headers.get('location')).toContain('oauth=failed') + expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', { + provider: 'github-repositories', + failure: 'failed', + errorClass: 'unexpected', + stage, + errorType: 'TypeError', + fingerprint: sha256Hex(error.message).slice(0, 12), + }) + } + ) it('resumes only the server-owned setup after the guarded OAuth completion', async () => { mocks.consumeAttempt.mockResolvedValue({ ...attempt, diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts index 1883ecdcb21..0d8b0240ac2 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' +import { sha256Hex } from '@sim/security/hash' +import { describeError, getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/credential-groups' import { internalSessionAuth } from '@/lib/api/server/routes' @@ -23,6 +24,18 @@ import { } from '@/app/api/credential-groups/enrollment-redirect' const logger = createLogger('CredentialGroupOAuthCallbackAPI') +const DIAGNOSTIC_ERROR_TYPES = new Set([ + 'Error', + 'TypeError', + 'ReferenceError', + 'SyntaxError', + 'RangeError', + 'ZodError', + 'PostgresError', + 'DrizzleQueryError', + 'InternalUnauthenticatedError', + 'ManagedOAuthCredentialError', +]) interface HandleCredentialGroupOAuthCallbackParams { request: NextRequest @@ -87,13 +100,17 @@ export async function handleCredentialGroupOAuthCallback({ return failureRedirect('failed') } + let stage = 'session_authentication' try { if (installationSetup) { const principal = await internalSessionAuth.authenticate() + stage = 'setup_completion' await completeGitHubSetupReaderOAuth.execute({ principal, input: { attempt, code }, request }) return setupRedirect() } + stage = 'enrollment_authentication' const principal = await credentialGroupOAuthAttemptPrincipal(attempt) + stage = 'enrollment_completion' await completePublicCredentialGroupOAuth.execute({ principal, input: { attempt, code }, @@ -112,16 +129,14 @@ export async function handleCredentialGroupOAuthCallback({ error instanceof CredentialGroupInvitationUnavailableError ? 'unavailable' : error instanceof CredentialGroupOAuthError && error.statusCode === 403 - ? error.message.startsWith('Sign in with') - ? 'account_mismatch' - : 'permissions_required' + ? 'permissions_required' : error instanceof CredentialGroupOAuthError && error.statusCode === 409 ? 'configuration_changed' : 'failed' if (identityFailure) { switch (identityFailure.reason) { - case 'email_mismatch': - status = provider === 'github-repositories' ? 'github_email_mismatch' : 'account_mismatch' + case 'email_unverified': + status = provider === 'github-repositories' ? 'github_email_unverified' : 'failed' break case 'email_access_denied': status = @@ -139,19 +154,32 @@ export async function handleCredentialGroupOAuthCallback({ } } const applicationError = asOrchestrationError(error) + const errorClass = + error instanceof CredentialGroupInvitationUnavailableError + ? 'invitation_unavailable' + : error instanceof CredentialGroupOAuthError + ? 'credential_group_oauth' + : error instanceof CredentialGroupProviderConfigurationError + ? 'provider_configuration' + : applicationError + ? 'application' + : 'unexpected' + const unexpectedError = errorClass === 'unexpected' ? describeError(error) : undefined logger.error('Managed OAuth authorization failed', { provider, failure: status, - errorClass: - error instanceof CredentialGroupInvitationUnavailableError - ? 'invitation_unavailable' - : error instanceof CredentialGroupOAuthError - ? 'credential_group_oauth' - : error instanceof CredentialGroupProviderConfigurationError - ? 'provider_configuration' - : applicationError - ? 'application' - : 'unexpected', + errorClass, + /** Provider errors and SQL parameters may contain credentials; retain only bounded diagnostics. */ + ...(unexpectedError && { + stage, + errorType: DIAGNOSTIC_ERROR_TYPES.has(unexpectedError.name) + ? unexpectedError.name + : 'UnknownError', + fingerprint: sha256Hex(unexpectedError.message).slice(0, 12), + ...(unexpectedError.code && /^[0-9A-Z]{5}$/.test(unexpectedError.code) + ? { databaseCode: unexpectedError.code } + : {}), + }), ...(error instanceof CredentialGroupOAuthError && { statusCode: error.statusCode }), ...(error instanceof CredentialGroupProviderConfigurationError && { statusCode: 503 }), ...(applicationError && { diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts index 508dc05267a..98a20503ac6 100644 --- a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -115,7 +115,6 @@ describe('credential group OAuth callback', () => { it.each([ [new CredentialGroupInvitationUnavailableError(), 'unavailable'], - [new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'], [new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'], [new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'], [new Error('Provider failed'), 'failed'], @@ -250,7 +249,6 @@ describe('credential group OAuth callback', () => { it.each([ [new CredentialGroupInvitationUnavailableError(), 'unavailable'], - [new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'], [new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'], [new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'], [new Error('Provider failed'), 'failed'], diff --git a/apps/sim/app/api/desktop/auth/handoff/route.ts b/apps/sim/app/api/desktop/auth/handoff/route.ts index 401da183f6c..f336c54c89f 100644 --- a/apps/sim/app/api/desktop/auth/handoff/route.ts +++ b/apps/sim/app/api/desktop/auth/handoff/route.ts @@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { headers } from 'next/headers' import { type NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' +import { getBetterAuthClientErrorStatus } from '@/lib/auth/better-auth-error' import { createDesktopHandoffToken } from '@/lib/auth/desktop-handoff' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -43,15 +44,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const token = await createDesktopHandoffToken(session.user.id) return NextResponse.json({ token }) } catch (error) { - // Session creation runs the app's own `session.create.before` hook, which - // rejects access-controlled accounts with a Better Auth APIError. That is a - // permanent refusal, not a server fault — a 500 would tell the user to try - // again forever. - if ( - error instanceof Error && - 'statusCode' in error && - (error as Record).statusCode === 403 - ) { + /** + * Session creation runs the app's own `session.create.before` hook, which rejects + * access-controlled accounts with a Better Auth `APIError`. That is a permanent refusal, not a + * server fault — a 500 would tell the user to try again forever. + */ + if (getBetterAuthClientErrorStatus(error) === 403) { logger.warn('Desktop handoff refused for this account', { userId: session.user.id }) return NextResponse.json( { error: getErrorMessage(error, 'Access restricted') }, diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 96aa61c9b87..10034a9e495 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -60,12 +60,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { * persists a map derived from the pre-replace state, discarding this one * entirely. * - * The reconcile below stays outside because it opens its own transaction. - * That leaves a known gap: it prunes mirrors against this request's key - * list, so a secret added after the commit loses its mirror while its - * value survives. Closing it means having the reconcile read the map - * itself rather than trust a caller's list, across all four of its - * callers. + * The reconcile below opens its own transaction and re-reads the map + * under this same lock so a later save cannot be undone by stale keys. */ await db.transaction(async (tx) => { await lockPersonalEnvMap(tx, session.user.id) @@ -89,7 +85,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { await syncPersonalEnvCredentialsForUser({ userId: session.user.id, - envKeys: Object.keys(variables), }) recordAudit({ diff --git a/apps/sim/app/api/files/uploads/finalizers.ts b/apps/sim/app/api/files/uploads/finalizers.ts index 963b421720c..4007f4802a5 100644 --- a/apps/sim/app/api/files/uploads/finalizers.ts +++ b/apps/sim/app/api/files/uploads/finalizers.ts @@ -1,8 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' -import { type WorkspaceFileRow, workspaceFileColumns, workspaceFiles } from '@sim/db/schema' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' import type { V2File } from '@/lib/api/contracts/v2/files' @@ -367,7 +366,7 @@ async function insertOrLoadFileMetadata( const now = new Date() const [inserted] = await db - .insert(withInsertColumns(workspaceFiles, workspaceFileColumns)) + .insert(workspaceFiles) .values({ id: generateId(), key: input.key, @@ -384,7 +383,7 @@ async function insertOrLoadFileMetadata( contentUpdatedAt: now, }) .onConflictDoNothing() - .returning(workspaceFileColumns) + .returning() if (inserted) return { file: inserted, created: true } @@ -399,7 +398,7 @@ async function insertOrLoadFileMetadata( async function findFileMetadataByKey(key: string): Promise { const [file] = await db - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where(eq(workspaceFiles.key, key)) .orderBy(sql`${workspaceFiles.deletedAt} IS NULL DESC`) diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 2c0a4abaf59..fc19016b170 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -212,6 +212,10 @@ describe('Knowledge Search Utils', () => { describe('handleTagAndVectorSearch', () => { it('returns only bounded ranked rows without first materializing every matching tag ID', async () => { resetDbChainMock() + queueTableRows( + schemaMock.embedding, + Array.from({ length: 201 }, (_, index) => ({ id: `candidate-${index}` })) + ) queueTableRows(schemaMock.embedding, [makeResult('second', 0.2), makeResult('first', 0.1)]) const results = await handleTagAndVectorSearch({ @@ -226,9 +230,11 @@ describe('Knowledge Search Utils', () => { }) expect(results.map((row) => row.id)).toEqual(['first', 'second']) - expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) expect(dbChainMockFns.as).toHaveBeenCalledWith('ranked_embeddings') - expect(dbChainMockFns.select.mock.calls[0][0]).toHaveProperty('distance') + expect(Object.keys(dbChainMockFns.select.mock.calls[0][0])).toEqual(['id']) + expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 201) + expect(dbChainMockFns.select.mock.calls[1][0]).toHaveProperty('distance') expect(dbChainMockFns.limit).toHaveBeenCalledWith(2) }) @@ -536,6 +542,7 @@ describe('Knowledge Search Utils', () => { }) it('runs a single retrieval leg in vector mode', async () => { + queueTableRows(schemaMock.embedding, [{ id: 'vector-hit' }]) queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) const results = await executeKnowledgeSearch({ @@ -548,20 +555,19 @@ describe('Knowledge Search Utils', () => { }) expect(results.map((r) => r.id)).toEqual(['vector-hit']) - expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) expect(dbChainMockFns.as).toHaveBeenCalledWith('ranked_embeddings') }) it('runs both legs and fuses them in hybrid mode', async () => { /** - * Chains dequeue in creation order. Hybrid legs over-fetch past the - * plain scan's candidate pool, so the vector leg opens its transaction - * and applies the scan settings before selecting: the keyword ranking - * pass is built first, then the vector select, then hydration. + * Chains dequeue in creation order: keyword ranking, the budgeted vector + * probe, keyword hydration, then vector ranking and hydration in one query. */ queueTableRows(schemaMock.embedding, [{ id: 'keyword-hit', keywordRank: 0.9 }]) - queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) + queueTableRows(schemaMock.embedding, [{ id: 'vector-hit' }]) queueTableRows(schemaMock.embedding, [makeResult('keyword-hit')]) + queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) const results = await executeKnowledgeSearch({ knowledgeBaseIds: ['kb-123'], @@ -573,39 +579,31 @@ describe('Knowledge Search Utils', () => { }) expect(results.map((r) => r.id).sort()).toEqual(['keyword-hit', 'vector-hit']) - expect(dbChainMockFns.select).toHaveBeenCalledTimes(4) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(5) }) - it('falls back to vector results when the keyword leg fails', async () => { + it('propagates unexpected keyword errors after the vector leg finishes', async () => { /** The failing ranking chain is still built first and takes the first queued set. */ queueTableRows(schemaMock.embedding, [{ id: 'never-ranked', keywordRank: 0 }]) + queueTableRows(schemaMock.embedding, [{ id: 'vector-hit' }]) queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) - /** - * Both legs share one `orderBy` spy, so target the keyword leg by its - * ranking expression. Calling the untouched spy first captures the - * sentinel that tells the mock to build its normal chain, which the - * vector leg still needs. - */ - const chainDefault = dbChainMockFns.orderBy() - dbChainMockFns.orderBy.mockImplementation((fragment: unknown) => { - const text = (fragment as { strings?: string[] })?.strings?.join('') ?? '' - if (text.includes('ts_rank_cd')) { - throw new Error('tsquery blew up') - } - return chainDefault - }) - - const results = await executeKnowledgeSearch({ - knowledgeBaseIds: ['kb-123'], - access: WORKSPACE_ACCESS_SCOPE, - topK: 10, - searchMode: 'hybrid', - query: 'PROJ-1234', - queryVector: JSON.stringify([0.1, 0.2, 0.3]), + const failure = new Error('tsquery failed') + dbChainMockFns.orderBy.mockImplementationOnce(() => { + throw failure }) - expect(results.map((r) => r.id)).toEqual(['vector-hit']) + await expect( + executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + access: WORKSPACE_ACCESS_SCOPE, + topK: 10, + searchMode: 'hybrid', + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + ).rejects.toBe(failure) + expect(dbChainMockFns.as).toHaveBeenCalledWith('ranked_embeddings') }) it('skips both query legs when only tag filters are provided', async () => { diff --git a/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts b/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts new file mode 100644 index 00000000000..59b74751968 --- /dev/null +++ b/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts @@ -0,0 +1,104 @@ +/** @vitest-environment node */ +import { authMockFns, dbChainMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const m = vi.hoisted(() => ({ + authenticate: vi.fn(), + complete: vi.fn(), + rate: vi.fn(), +})) +vi.mock('@/lib/slack-search/public-install-auth', () => ({ + authenticateSlackPublicInstallation: m.authenticate, +})) +vi.mock('@/lib/knowledge/application/slack-search/setup', () => ({ + completeSlackSearchSetup: { execute: m.complete }, +})) +vi.mock('@/lib/core/rate-limiter', async (importOriginal) => ({ + ...(await importOriginal()), + enforceIpRateLimit: m.rate, + enforceUserRateLimit: m.rate, +})) +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://www.sim.ai', + SITE_URL: 'https://www.sim.ai', +})) + +import { GET, HEAD } from '@/app/api/knowledge/slack/oauth/callback/route' + +const request = (query: string) => + new NextRequest(`https://www.sim.ai/api/knowledge/slack/oauth/callback?${query}`) +beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'admin' }, + session: { id: 'session' }, + }) + m.rate.mockResolvedValue(null) + m.authenticate.mockResolvedValue({ teamId: 'T1' }) + m.complete.mockResolvedValue({ organizationId: 'org1' }) +}) +describe('Slack OAuth callback', () => { + it.each(['code=code', 'state=&code=code'])( + 'accepts Slack-initiated install without Sim login: %s', + async (query) => { + authMockFns.mockGetSession.mockResolvedValue(null) + const response = await GET(request(query)) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe('https://www.sim.ai/slack-search/install/T1') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(authMockFns.mockGetSession).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(m.complete).not.toHaveBeenCalled() + } + ) + it('does not attach a public grant to an existing browser session', async () => { + await GET(request('code=code&organizationId=attacker')) + expect(authMockFns.mockGetSession).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(m.complete).not.toHaveBeenCalled() + }) + it('keeps org-initiated installs on the existing session/state path', async () => { + const response = await GET(request('state=state&code=code')) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe( + 'https://www.sim.ai/o/org1/settings/search-slack?slackSetup=complete' + ) + expect(m.complete).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'admin', sessionId: 'session' }, + input: { state: 'state', code: 'code', error: undefined }, + }) + ) + expect(m.authenticate).not.toHaveBeenCalled() + }) + it('never falls back to public install on an invalid nonempty state', async () => { + m.complete.mockRejectedValueOnce(new OrchestrationError('validation', 'Expired state')) + expect((await GET(request('state=expired&code=code'))).status).toBe(400) + expect(m.authenticate).not.toHaveBeenCalled() + }) + it('still requires a Sim session for an org-bound state', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + expect((await GET(request('state=state&code=code'))).status).toBe(401) + expect(m.authenticate).not.toHaveBeenCalled() + expect(m.complete).not.toHaveBeenCalled() + }) + it.each(['error=access_denied', '', 'state=&state=other&code=code', 'code=a&code=b'])( + 'rejects ambiguous or denied callbacks: %s', + async (query) => { + expect((await GET(request(query))).status).toBe(400) + expect(m.authenticate).not.toHaveBeenCalled() + expect(m.complete).not.toHaveBeenCalled() + } + ) + it('does not consume codes on HEAD requests or after rate limiting', async () => { + expect((await HEAD(request('code=code'))).status).toBe(405) + m.rate.mockResolvedValue(new Response(null, { status: 429 })) + expect((await GET(request('code=code'))).status).toBe(429) + expect(m.authenticate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts b/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts index c9f507625e2..b6155d6f5c3 100644 --- a/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts +++ b/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts @@ -7,24 +7,50 @@ import { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { completeSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup' import { organizationRoutes } from '@/lib/navigation/paths' +import { slackSearchInstallPath } from '@/lib/slack-search/install-link' +import { authenticateSlackPublicInstallation } from '@/lib/slack-search/public-install-auth' /** OAuth is a redirect protocol; protected configuration remains in the application use case. */ export const GET = withRouteHandler(async (request) => { try { + const limited = await enforceIpRateLimit('slack-search-oauth-callback', request) + if (limited) return limited + const parsed = await parseRequest( + slackSearchOAuthCallbackContract, + request, + {}, + { + rejectDuplicateQueryValues: true, + } + ) + if (!parsed.success) return parsed.response + const { state, code, error } = parsed.data.query + if (!state) { + if (error || !code) + throw new OrchestrationError( + 'validation', + 'Slack installation was not authorized. Install the app again.' + ) + const { teamId } = await authenticateSlackPublicInstallation(code) + return NextResponse.redirect(new URL(slackSearchInstallPath(teamId), getBaseUrl()), { + status: 303, + headers: { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }, + }) + } const principal = await internalSessionAuth.authenticate() const rateResponse = await internalRateLimits .user({ bucketName: 'slack-search-settings' }) .enforce(request, principal) if (rateResponse) return rateResponse - const parsed = await parseRequest(slackSearchOAuthCallbackContract, request, {}) - if (!parsed.success) return parsed.response const result = await completeSlackSearchSetup.execute({ principal, - input: parsed.data.query, + input: { state, code, error }, request, }) const url = new URL( @@ -44,3 +70,6 @@ export const GET = withRouteHandler(async (request) => { throw error } }) + +/** Link previews must not consume a single-use OAuth code. */ +export const HEAD = withRouteHandler(async () => new NextResponse(null, { status: 405 })) diff --git a/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/preview/route.ts b/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/preview/route.ts new file mode 100644 index 00000000000..c4ba646b057 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/preview/route.ts @@ -0,0 +1,22 @@ +import { previewAccessRequestContract } from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { previewAccessRequest } from '@/lib/permission-access-requests/application/review' + +export const GET = defineInternalJsonRoute({ + contract: previewAccessRequestContract, + auth: internalSessionAuth, + operation: accessRequestOperations.preview, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:read', + config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, requestId: params.requestId }), + useCase: previewAccessRequest, +}) diff --git a/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/resolve/route.ts b/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/resolve/route.ts new file mode 100644 index 00000000000..0bc389ec7e9 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/resolve/route.ts @@ -0,0 +1,27 @@ +import { resolveAccessRequestContract } from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { resolveAccessRequest } from '@/lib/permission-access-requests/application/review' + +export const POST = defineInternalJsonRoute({ + contract: resolveAccessRequestContract, + auth: internalSessionAuth, + operation: accessRequestOperations.resolve, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:write', + config: { maxTokens: 10, refillRate: 5, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + organizationId: params.id, + requestId: params.requestId, + decision: body, + }), + useCase: resolveAccessRequest, + present: ({ request }) => ({ request }), +}) diff --git a/apps/sim/app/api/organizations/[id]/access-requests/route.ts b/apps/sim/app/api/organizations/[id]/access-requests/route.ts new file mode 100644 index 00000000000..f68a80694bb --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/access-requests/route.ts @@ -0,0 +1,22 @@ +import { listOrganizationAccessRequestsContract } from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { listOrganizationAccessRequests } from '@/lib/permission-access-requests/application/requests' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationAccessRequestsContract, + auth: internalSessionAuth, + operation: accessRequestOperations.listOrganization, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:read', + config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }), + useCase: listOrganizationAccessRequests, +}) diff --git a/apps/sim/app/api/organizations/[id]/access-requests/settings/route.ts b/apps/sim/app/api/organizations/[id]/access-requests/settings/route.ts new file mode 100644 index 00000000000..6f4ceec0c09 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/access-requests/settings/route.ts @@ -0,0 +1,41 @@ +import { + getAccessRequestSettingsContract, + updateAccessRequestSettingsContract, +} from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { + getAccessRequestSettings, + updateAccessRequestSettings, +} from '@/lib/permission-access-requests/application/requests' + +export const GET = defineInternalJsonRoute({ + contract: getAccessRequestSettingsContract, + auth: internalSessionAuth, + operation: accessRequestOperations.getSettings, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:read', + config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getAccessRequestSettings, +}) + +export const PATCH = defineInternalJsonRoute({ + contract: updateAccessRequestSettingsContract, + auth: internalSessionAuth, + operation: accessRequestOperations.updateSettings, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:write', + config: { maxTokens: 10, refillRate: 5, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: updateAccessRequestSettings, +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts index 8ae3315a131..b63fce6598f 100644 --- a/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts @@ -36,5 +36,5 @@ export const PUT = defineInternalJsonRoute({ errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), useCase: updateOrganizationAccountWorkspaceAccess, - present: ({ revision, workspaceIds }) => ({ revision, workspaceIds }), + present: ({ revision, grants }) => ({ revision, grants }), }) diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts index 8522785b516..a8cfcf94a8d 100644 --- a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts @@ -9,6 +9,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { removeOrganizationDomainContract } from '@/lib/api/contracts/organization' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -91,6 +92,9 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Domain not found' }, { status: 404 }) } + /** Providers on the removed domain no longer satisfy the sign-in requirement. */ + invalidateSsoPolicyCache(organizationId) + logger.info('Domain removed', { organizationId, domain: removed.domain }) recordAudit({ workspaceId: null, diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts index 2fa5cb23cac..2fbb23cb694 100644 --- a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts @@ -11,6 +11,7 @@ import { verifyOrganizationDomainContract } from '@/lib/api/contracts/organizati import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { checkDomainTxtRecord, toDomainResponse } from '@/lib/auth/sso/domain-verification' +import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -188,6 +189,9 @@ export const POST = withRouteHandler( ) } + /** A newly verified domain can make the organization able to require single sign-on. */ + invalidateSsoPolicyCache(organizationId) + logger.info('Domain verified', { organizationId, domain: row.domain }) recordAudit({ workspaceId: null, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts index 62c0ddb18cc..c426e067815 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts @@ -4,13 +4,15 @@ import { resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({ - mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), - mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), -})) +const { mockIsOrganizationAdminOrOwner, mockIsOrganizationPermissionRegimeActive } = vi.hoisted( + () => ({ + mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), + mockIsOrganizationPermissionRegimeActive: vi.fn<() => Promise>(), + }) +) -vi.mock('@/lib/billing', () => ({ - isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + isOrganizationPermissionRegimeActive: mockIsOrganizationPermissionRegimeActive, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -29,7 +31,7 @@ describe('authorizeOrgAccessControl', () => { it('returns a 403 when the user is not an organization admin/owner', async () => { mockIsOrganizationAdminOrOwner.mockResolvedValue(false) - mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) const response = await authorizeOrgAccessControl('user-1', 'org-1') @@ -37,12 +39,12 @@ describe('authorizeOrgAccessControl', () => { expect(response?.status).toBe(403) await expect(response?.json()).resolves.toEqual({ error: 'Admin permissions required' }) // Entitlement is only checked after the admin gate passes. - expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled() + expect(mockIsOrganizationPermissionRegimeActive).not.toHaveBeenCalled() }) it('returns a 403 when the organization is not on an enterprise plan', async () => { mockIsOrganizationAdminOrOwner.mockResolvedValue(true) - mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false) + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(false) const response = await authorizeOrgAccessControl('user-1', 'org-1') @@ -54,7 +56,7 @@ describe('authorizeOrgAccessControl', () => { it('returns null when the user is an admin and the org is entitled', async () => { mockIsOrganizationAdminOrOwner.mockResolvedValue(true) - mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) const response = await authorizeOrgAccessControl('user-1', 'org-1') diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts index f96ac8dacf7..026963a3a5a 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts @@ -2,12 +2,12 @@ import { db } from '@sim/db' import { permissionGroup, permissionGroupWorkspace, workspace } from '@sim/db/schema' import { and, asc, eq, inArray } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import type { DbOrTx } from '@/lib/db/types' import type { AllMembersConflict, ScopeConflict, } from '@/lib/permission-groups/application/group-membership' +import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server' import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' /** A workspace reference (id + display name). */ @@ -31,8 +31,13 @@ export async function authorizeOrgAccessControl( return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) } - const entitled = await isOrganizationOnEnterprisePlan(organizationId) - if (!entitled) { + /** + * The active permission regime, which is what the Access Control page now reads too: an + * organization whose restrictions still apply has to be able to see and loosen them, and a + * deployment with Access Control switched off governs nobody, so neither should manage anything. + */ + const governed = await isOrganizationPermissionRegimeActive(organizationId) + if (!governed) { return NextResponse.json({ error: 'Access Control is an Enterprise feature' }, { status: 403 }) } diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts index a84bf866f19..ba074c3ab5f 100644 --- a/apps/sim/app/api/organizations/[id]/route.ts +++ b/apps/sim/app/api/organizations/[id]/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { member, organization, organizationColumns } from '@sim/db/schema' +import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, ne } from 'drizzle-orm' @@ -64,7 +64,7 @@ export const GET = withRouteHandler( } const organizationEntry = await db - .select(organizationColumns) + .select() .from(organization) .where(eq(organization.id, organizationId)) .limit(1) @@ -156,7 +156,7 @@ export const PUT = withRouteHandler( if (name !== undefined || slug !== undefined || logo !== undefined) { if (slug !== undefined) { const existingSlug = await db - .select(organizationColumns) + .select() .from(organization) .where(and(eq(organization.slug, slug), ne(organization.id, organizationId))) .limit(1) @@ -180,7 +180,7 @@ export const PUT = withRouteHandler( .update(organization) .set(updateData) .where(eq(organization.id, organizationId)) - .returning(organizationColumns) + .returning() if (updatedOrg.length === 0) { return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) diff --git a/apps/sim/app/api/organizations/[id]/sso-policy/route.ts b/apps/sim/app/api/organizations/[id]/sso-policy/route.ts new file mode 100644 index 00000000000..8a97929003a --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/sso-policy/route.ts @@ -0,0 +1,43 @@ +import { + getOrganizationSsoPolicyContract, + updateOrganizationSsoPolicyContract, +} from '@/lib/api/contracts/organization' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + readSsoRequirement, + readSsoRequirementOperation, + type SsoRequirement, + setSsoRequirement, + setSsoRequirementOperation, +} from '@/lib/auth/sso/application/sso-requirement' + +const present = (requirement: SsoRequirement) => ({ success: true as const, data: requirement }) + +/** Whether members must sign in through the organization's identity provider. */ +export const GET = defineInternalJsonRoute({ + contract: getOrganizationSsoPolicyContract, + auth: internalSessionAuth, + operation: readSsoRequirementOperation, + rateLimit: internalRateLimits.none({ reason: 'Settings read behind organization membership' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: readSsoRequirement, + present, +}) + +/** Turns the requirement on or off. Never ends a session that already exists. */ +export const PUT = defineInternalJsonRoute({ + contract: updateOrganizationSsoPolicyContract, + auth: internalSessionAuth, + operation: setSsoRequirementOperation, + rateLimit: internalRateLimits.user({ bucketName: 'sso-set-requirement' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, requireSso: body.requireSso }), + useCase: setSsoRequirement, + present, +}) diff --git a/apps/sim/app/api/organizations/[id]/usage/activity/breakdown/route.ts b/apps/sim/app/api/organizations/[id]/usage/activity/breakdown/route.ts new file mode 100644 index 00000000000..a237f5217d0 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/usage/activity/breakdown/route.ts @@ -0,0 +1,30 @@ +import { getOrganizationActivityBreakdownContract } from '@/lib/api/contracts/organization-activity' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getOrganizationActivityBreakdown } from '@/lib/billing/application/organization-usage/get-organization-activity' +import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' +import { organizationUsageErrorPolicy } from '@/app/api/organizations/[id]/usage/error-policy' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: getOrganizationActivityBreakdownContract, + auth: internalSessionAuth, + operation: organizationUsageOperations.readActivityBreakdown, + rateLimit: internalRateLimits.user({ + bucketName: 'organization-activity', + }), + errorPolicy: organizationUsageErrorPolicy, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: ({ params, query }) => ({ + ...query, + organizationId: params.id, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + }), + useCase: getOrganizationActivityBreakdown, + present: (result) => result, +}) diff --git a/apps/sim/app/api/organizations/[id]/usage/activity/summary/route.ts b/apps/sim/app/api/organizations/[id]/usage/activity/summary/route.ts new file mode 100644 index 00000000000..f1829ebd109 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/usage/activity/summary/route.ts @@ -0,0 +1,30 @@ +import { getOrganizationActivitySummaryContract } from '@/lib/api/contracts/organization-activity' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getOrganizationActivitySummary } from '@/lib/billing/application/organization-usage/get-organization-activity' +import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' +import { organizationUsageErrorPolicy } from '@/app/api/organizations/[id]/usage/error-policy' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: getOrganizationActivitySummaryContract, + auth: internalSessionAuth, + operation: organizationUsageOperations.readActivitySummary, + rateLimit: internalRateLimits.user({ + bucketName: 'organization-activity', + }), + errorPolicy: organizationUsageErrorPolicy, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: ({ params, query }) => ({ + ...query, + organizationId: params.id, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + }), + useCase: getOrganizationActivitySummary, + present: (result) => result, +}) diff --git a/apps/sim/app/api/permission-groups/user/route.test.ts b/apps/sim/app/api/permission-groups/user/route.test.ts new file mode 100644 index 00000000000..f58c021e342 --- /dev/null +++ b/apps/sim/app/api/permission-groups/user/route.test.ts @@ -0,0 +1,191 @@ +/** @vitest-environment node */ +import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + session: vi.fn(), + context: vi.fn(), + role: vi.fn(), + admin: vi.fn(), + enterprise: vi.fn(), + group: vi.fn(), +})) +vi.mock('@/lib/auth', () => ({ getSession: mocks.session })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.context, +})) +vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({ + ...(await importOriginal()), + resolveEffectiveWorkspacePermission: mocks.role, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwner: mocks.admin })) +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mocks.enterprise, + /** Permission resolution reads the governance axis; these tests drive both from one knob. */ + isOrganizationGovernanceActive: mocks.enterprise, +})) +vi.mock('@/lib/permission-groups/resolve.server', async (importOriginal) => ({ + ...(await importOriginal()), + resolveWorkspaceGroup: mocks.group, +})) + +import { userPermissionConfigSchema } from '@/lib/api/contracts/permission-groups' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { readUserPermissionConfig } from '@/lib/permission-groups/application/read-user-config' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET } from '@/app/api/permission-groups/user/route' + +const principal = { kind: 'session', userId: 'viewer', sessionId: 'session' } as const +const context = { + workspaceId: 'workspace', + workspaceOrganizationId: 'owning-org', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner', +} +const unrestricted = { + permissionGroupId: null, + groupName: null, + config: null, + entitled: false, + organizationId: 'owning-org', + isOrgAdmin: false, +} +function get(query = '?workspaceId=workspace') { + return GET( + createMockRequest('GET', undefined, {}, `http://localhost/api/permission-groups/user${query}`) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isHosted: true, isAccessControlEnabled: true }) + mocks.session.mockResolvedValue({ + user: { id: 'viewer' }, + session: { id: 'session', activeOrganizationId: 'unrelated-org' }, + }) + mocks.context.mockResolvedValue(context) + mocks.role.mockResolvedValue('read') + mocks.admin.mockResolvedValue(false) + mocks.enterprise.mockResolvedValue(true) + mocks.group.mockResolvedValue(null) +}) + +afterEach(resetEnvFlagsMock) + +describe('user permission policy shared read', () => { + it.each([ + { hosted: false, accessControl: false, entitled: false }, + { hosted: false, accessControl: true, entitled: true }, + { hosted: true, accessControl: false, entitled: true }, + ])( + 'matches the active permission regime ($hosted, $accessControl)', + async ({ hosted, accessControl, entitled }) => { + setEnvFlags({ + isHosted: hosted, + isAccessControlEnabled: accessControl, + isBillingEnabled: false, + }) + mocks.admin.mockResolvedValue(true) + const group = { + permissionGroupId: 'group', + groupName: 'Restricted', + config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true }, + } + mocks.group.mockResolvedValue(group) + const expected = { ...unrestricted, ...(entitled ? group : {}), entitled, isOrgAdmin: true } + expect(await (await get()).json()).toEqual(expected) + expect( + await readUserPermissionConfig.execute({ principal, input: { workspaceId: 'workspace' } }) + ).toEqual(expected) + if (!entitled) { + expect(mocks.group).not.toHaveBeenCalled() + expect(mocks.enterprise).not.toHaveBeenCalled() + } + } + ) + + it('authenticates before parsing or protected lookups', async () => { + mocks.session.mockResolvedValue(null) + expect((await get('')).status).toBe(401) + expect(mocks.context).not.toHaveBeenCalled() + }) + it.each(['', '?workspaceId='])('preserves missing workspace validation for %s', async (query) => { + const response = await get(query) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: 'workspaceId is required' }) + expect(mocks.context).not.toHaveBeenCalled() + }) + it('preserves missing or archived workspace responses', async () => { + mocks.context.mockRejectedValue(new OrchestrationError('not_found', 'Workspace not found')) + const response = await get() + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ error: 'Workspace not found' }) + expect(mocks.group).not.toHaveBeenCalled() + }) + it('refuses current nonmembers before loading their policy', async () => { + mocks.role.mockResolvedValue(null) + const response = await get() + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: 'Not a member of this workspace' }) + expect(mocks.admin).not.toHaveBeenCalled() + expect(mocks.enterprise).not.toHaveBeenCalled() + expect(mocks.group).not.toHaveBeenCalled() + }) + it('leaves personal workspaces unrestricted without organization reads', async () => { + mocks.context.mockResolvedValue({ ...context, workspaceOrganizationId: null }) + const response = await get() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ...unrestricted, organizationId: null }) + expect(mocks.admin).not.toHaveBeenCalled() + expect(mocks.enterprise).not.toHaveBeenCalled() + expect(mocks.group).not.toHaveBeenCalled() + }) + it('retains organization admin status without enterprise entitlement', async () => { + mocks.enterprise.mockResolvedValue(false) + mocks.admin.mockResolvedValue(true) + expect(await (await get()).json()).toEqual({ ...unrestricted, isOrgAdmin: true }) + expect(mocks.group).not.toHaveBeenCalled() + }) + it('reads the acting member in the workspace owning organization and matches the server result', async () => { + const group = { + permissionGroupId: 'group', + groupName: 'Restricted', + config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true }, + } + mocks.group.mockResolvedValue(group) + const response = await get() + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ ...unrestricted, ...group, entitled: true }) + expect(mocks.group).toHaveBeenCalledWith('viewer', 'owning-org', 'workspace') + expect(mocks.admin).toHaveBeenCalledWith('viewer', 'owning-org') + const serverResult = await readUserPermissionConfig.execute({ + principal, + input: { workspaceId: 'workspace' }, + }) + expect(userPermissionConfigSchema.parse(serverResult)).toEqual(body) + }) + it('retains enterprise entitlement when no group applies', async () => { + expect(await (await get()).json()).toEqual({ ...unrestricted, entitled: true }) + }) + it('does not turn policy infrastructure failures into unrestricted access', async () => { + /** + * The governance reader has no lenient mode — answering `false` on a failed read would mean + * "no permission group", which denies nothing — so a failure here is simply a rejection. + */ + mocks.enterprise.mockRejectedValue(new Error('unavailable')) + expect((await get()).status).toBe(500) + await expect( + readUserPermissionConfig.execute({ principal, input: { workspaceId: 'workspace' } }) + ).rejects.toThrow('unavailable') + }) + it('rejects API keys before canonical lookup on the shared server entry point', async () => { + await expect( + readUserPermissionConfig.execute({ + principal: { kind: 'personal_api_key', userId: 'viewer', keyId: 'key' }, + input: { workspaceId: 'workspace' }, + }) + ).rejects.toThrow('cannot perform operation') + expect(mocks.context).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/permission-groups/user/route.ts b/apps/sim/app/api/permission-groups/user/route.ts index fcf78e58df9..4fb90f82005 100644 --- a/apps/sim/app/api/permission-groups/user/route.ts +++ b/apps/sim/app/api/permission-groups/user/route.ts @@ -1,77 +1,35 @@ import { NextResponse } from 'next/server' -import { userPermissionConfigQuerySchema } from '@/lib/api/contracts/permission-groups' -import { getSession } from '@/lib/auth' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserPermissionConfigContract } from '@/lib/api/contracts/permission-groups' import { - checkWorkspaceAccess, - isOrganizationAdminOrOwner, -} from '@/lib/workspaces/permissions/utils' -import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check' - -export const GET = withRouteHandler(async (req: Request) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const queryResult = userPermissionConfigQuerySchema.safeParse( - Object.fromEntries(new URL(req.url).searchParams.entries()) - ) - if (!queryResult.success) { - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) - } - const { workspaceId } = queryResult.data - - const access = await checkWorkspaceAccess(workspaceId, session.user.id) - if (!access.exists) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - if (!access.hasAccess) { - return NextResponse.json({ error: 'Not a member of this workspace' }, { status: 403 }) - } - - const organizationId = access.workspace?.organizationId ?? null - - // Workspaces without an organization have no permission groups, and the caller - // can never be an org admin in that case. - if (!organizationId) { - return NextResponse.json({ - permissionGroupId: null, - groupName: null, - config: null, - entitled: false, - organizationId: null, - isOrgAdmin: false, - }) - } - - // Resolve role + entitlement against the WORKSPACE's owning organization (not - // the caller's active org) so management gating is scoped to the org that - // actually governs this workspace. External members are not org admins here. - const isOrgAdmin = await isOrganizationAdminOrOwner(session.user.id, organizationId) - - if (!(await isOrganizationOnEnterprisePlan(organizationId))) { - return NextResponse.json({ - permissionGroupId: null, - groupName: null, - config: null, - entitled: false, - organizationId, - isOrgAdmin, - }) - } - - // Single source of truth: specific-scope group covering this workspace -> - // the user's all-workspaces group -> org default -> none. - const resolved = await resolveWorkspaceGroup(session.user.id, organizationId, workspaceId) - - return NextResponse.json({ - permissionGroupId: resolved?.permissionGroupId ?? null, - groupName: resolved?.groupName ?? null, - config: resolved?.config ?? null, - entitled: true, - organizationId, - isOrgAdmin, - }) + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization' +import { + readUserPermissionConfig, + readUserPermissionConfigOperation, +} from '@/lib/permission-groups/application/read-user-config' + +export const GET = defineInternalJsonRoute({ + contract: getUserPermissionConfigContract, + auth: internalSessionAuth, + operation: readUserPermissionConfigOperation, + rateLimit: internalRateLimits.none({ + reason: 'Preserve the existing internal policy read rate.', + }), + parseOptions: { + validationErrorResponse: () => + NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }), + }, + errorPolicy: extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) => + error instanceof NoWorkspaceAccessError + ? internalErrorResponse(403, { error: 'Not a member of this workspace' }) + : null + ), + mapInput: ({ query }) => query, + useCase: readUserPermissionConfig, }) diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index da245cdc7a4..2c41426d1f2 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -25,8 +25,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' -import { organization, subscription, user, userStats, userStatsColumns } from '@sim/db/schema' +import { organization, subscription, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' @@ -156,7 +155,7 @@ export const POST = withRouteHandler( .limit(1) if (!existingStats) { - await db.insert(withInsertColumns(userStats, userStatsColumns)).values({ + await db.insert(userStats).values({ id: generateShortId(), userId: entityId, }) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index 40263d252c4..6fca93b98d1 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -16,7 +16,7 @@ */ import { db, dbReplica } from '@sim/db' -import { member, organization, organizationColumns } from '@sim/db/schema' +import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { @@ -155,7 +155,7 @@ export const PATCH = withRouteHandler( if (!parsed.success) return parsed.response const [orgData] = await db - .select(organizationColumns) + .select() .from(organization) .where(eq(organization.id, organizationId)) .limit(1) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 15ca0aca873..d33652870ae 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -38,7 +38,7 @@ import { recordAuditBatch, } from '@sim/audit' import { db } from '@sim/db' -import { member, organization, organizationColumns, subscription } from '@sim/db/schema' +import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, inArray, isNull, not, or } from 'drizzle-orm' import { @@ -93,7 +93,7 @@ export const GET = withRouteHandler( try { const [orgData] = await db - .select(organizationColumns) + .select() .from(organization) .where(eq(organization.id, organizationId)) .limit(1) @@ -144,7 +144,7 @@ export const PATCH = withRouteHandler( try { const [existing] = await db - .select(organizationColumns) + .select() .from(organization) .where(eq(organization.id, organizationId)) .limit(1) @@ -183,7 +183,7 @@ export const PATCH = withRouteHandler( .update(organization) .set(updateData) .where(eq(organization.id, organizationId)) - .returning(organizationColumns) + .returning() const updatedFields = auditUpdatedFields(updateData) logger.info(`Admin API: Updated organization ${organizationId}`, { updatedFields }) diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index 497d0cad173..bc95ea0823d 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -27,7 +27,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, dbReplica } from '@sim/db' -import { member, organization, organizationColumns, user } from '@sim/db/schema' +import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { slugify } from '@sim/utils/string' import { count, eq } from 'drizzle-orm' @@ -155,7 +155,7 @@ export const POST = withRouteHandler( }) const [createdOrg] = await db - .select(organizationColumns) + .select() .from(organization) .where(eq(organization.id, organizationId)) .limit(1) diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts index 4ab52cb09e9..6907ddcc0c0 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts @@ -20,15 +20,7 @@ */ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' -import { - member, - organization, - subscription, - user, - userStats, - userStatsColumns, -} from '@sim/db/schema' +import { member, organization, subscription, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { eq, or } from 'drizzle-orm' @@ -86,11 +78,7 @@ export const GET = withRouteHandler( return notFoundResponse('User') } - const [stats] = await db - .select(userStatsColumns) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) + const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1) // Canonical current-period usage (attributed usage_log, refresh-adjusted) // comes from the same helper users see. @@ -180,7 +168,7 @@ export const PATCH = withRouteHandler( } const [existingStats] = await db - .select(userStatsColumns) + .select() .from(userStats) .where(eq(userStats.userId, userId)) .limit(1) @@ -248,7 +236,7 @@ export const PATCH = withRouteHandler( if (existingStats) { await db.update(userStats).set(updateData).where(eq(userStats.userId, userId)) } else { - await db.insert(withInsertColumns(userStats, userStatsColumns)).values({ + await db.insert(userStats).values({ id: generateShortId(), userId, ...updateData, diff --git a/apps/sim/app/api/webhooks/outbox/process/route.test.ts b/apps/sim/app/api/webhooks/outbox/process/route.test.ts new file mode 100644 index 00000000000..9000a9f59ec --- /dev/null +++ b/apps/sim/app/api/webhooks/outbox/process/route.test.ts @@ -0,0 +1,62 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ enqueue: vi.fn(), verifyCronAuth: vi.fn() })) +vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mocks.verifyCronAuth })) +vi.mock('@/lib/core/outbox/enqueue', () => ({ enqueueOutboxProcessor: mocks.enqueue })) + +import { GET } from '@/app/api/webhooks/outbox/process/route' + +const request = () => + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/webhooks/outbox/process') + +describe('outbox cron route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.verifyCronAuth.mockReturnValue(null) + }) + it('authenticates before accepting any background work', async () => { + mocks.verifyCronAuth.mockReturnValue(new Response(null, { status: 401 })) + expect((await GET(request())).status).toBe(401) + expect(mocks.enqueue).not.toHaveBeenCalled() + }) + it('acknowledges a durably accepted task with 202', async () => { + mocks.enqueue.mockResolvedValue({ backend: 'trigger-dev', jobId: 'run-1' }) + const response = await GET(request()) + expect(response.status).toBe(202) + await expect(response.json()).resolves.toEqual({ + success: true, + requestId: expect.any(String), + triggered: true, + backend: 'trigger-dev', + jobId: 'run-1', + }) + }) + it('preserves the existing 200 response for synchronous self-hosted runs', async () => { + const output = { + result: { processed: 1, retried: 0, deadLettered: 0, leaseLost: 0, reaped: 0 }, + reapedBackgroundWork: 0, + recoveredDocuments: 0, + } + mocks.enqueue.mockResolvedValue({ backend: 'inline', output }) + const response = await GET(request()) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + requestId: expect.any(String), + ...output, + }) + }) + it('returns an error when durable acceptance fails', async () => { + mocks.enqueue.mockRejectedValue(new Error('Trigger unavailable')) + const response = await GET(request()) + expect(response.status).toBe(500) + await expect(response.json()).resolves.toMatchObject({ + success: false, + error: 'Trigger unavailable', + }) + }) +}) diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index f79d06a4e28..6f590e0d33e 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -1,116 +1,35 @@ -import { db } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { adminInvitationOperationOutboxHandlers } from '@/lib/admin/invitation-operation' -import { adminMemberOperationOutboxHandlers } from '@/lib/admin/member-operation' import { verifyCronAuth } from '@/lib/auth/internal' -import { enterpriseOwnerClaimOutboxHandlers } from '@/lib/billing/enterprise-owner-claim' -import { enterpriseIssuanceOutboxHandlers } from '@/lib/billing/enterprise-provisioning' -import { membershipBillingOutboxHandlers } from '@/lib/billing/organizations/membership-reconciliation' -import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers' -import { processOutboxEvents } from '@/lib/core/outbox/service' -import { DeadlineExceededError } from '@/lib/core/utils/deadline' +import { enqueueOutboxProcessor } from '@/lib/core/outbox/enqueue' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { directGrantOutboxHandlers } from '@/lib/invitations/direct-grant' -import { slackSearchOutboxHandlers } from '@/lib/knowledge/application/slack-search/outbox' -import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error' -import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler' -import { recoverKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery' -import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup' -import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' -import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' -import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' -import { invitationMigrationOutboxHandlers } from '@/lib/workspaces/admin-move' -import { workspaceOperationOutboxHandlers } from '@/lib/workspaces/operations/outbox' -import { forkContentOutboxHandlers } from '@/ee/workspace-forking/application/content-outbox' -import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store' const logger = createLogger('OutboxProcessorAPI') export const dynamic = 'force-dynamic' +/** Self-hosted deployments without Trigger.dev retain the synchronous processing window. */ export const maxDuration = 800 -const handlers = { - ...slackSearchOutboxHandlers, - ...adminInvitationOperationOutboxHandlers, - ...adminMemberOperationOutboxHandlers, - ...billingOutboxHandlers, - ...membershipBillingOutboxHandlers, - ...enterpriseIssuanceOutboxHandlers, - ...enterpriseOwnerClaimOutboxHandlers, - ...invitationMigrationOutboxHandlers, - ...directGrantOutboxHandlers, - ...knowledgeDocumentProcessingOutboxHandlers, - ...organizationResourceCleanupOutboxHandlers, - ...workspaceFileLiveDocOutboxHandlers, - ...workspaceFileStorageCleanupOutboxHandlers, - ...workflowDeploymentOutboxHandlers, - ...workspaceOperationOutboxHandlers, - ...forkContentOutboxHandlers, -} as const - +/** The cron secret authorizes this lifecycle endpoint; hosted processing runs outside the HTTP request. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() + const authError = verifyCronAuth(request, 'Outbox processor') + if (authError) return authError + const requestId = generateRequestId() try { - const authError = verifyCronAuth(request, 'Outbox processor') - if (authError) { - return authError - } - - const startedAt = Date.now() - const result = await processOutboxEvents(handlers, { - batchSize: 500, - maxRuntimeMs: 760_000, - minRemainingMs: 95_000, - }) - - let recoveredDocuments = 0 - try { - if (Date.now() - startedAt < 770_000) { - recoveredDocuments = await recoverKnowledgeDocumentProcessing() - } - } catch (error) { - logger.error('Stored document recovery failed', { - requestId, - error: getConnectorFailureDiagnostic(error) ?? { - category: error instanceof DeadlineExceededError ? 'deadline' : 'internal', - message: - error instanceof DeadlineExceededError - ? error.message - : 'Unexpected stored-document recovery failure', - }, - }) - } - - // Reap fork background-work rows stuck `processing` past their TTL (worker crash / - // restart has no in-task hook). Independent of the outbox; a failure here must not - // fail the outbox run, so it's guarded separately. - let reapedBackgroundWork = 0 - try { - reapedBackgroundWork = await reapStaleBackgroundWork(db) - } catch (error) { - logger.error('Background-work reap failed', { requestId, error: toError(error).message }) + const accepted = await enqueueOutboxProcessor() + if (accepted.backend === 'trigger-dev') { + logger.info('Outbox processor accepted', { jobId: accepted.jobId }) + return NextResponse.json( + { success: true, requestId, triggered: true, ...accepted }, + { status: 202 } + ) } - - logger.info('Outbox processing completed', { - requestId, - ...result, - reapedBackgroundWork, - recoveredDocuments, - }) - - return NextResponse.json({ - success: true, - requestId, - result, - reapedBackgroundWork, - recoveredDocuments, - }) + return NextResponse.json({ success: true, requestId, ...accepted.output }) } catch (error) { - logger.error('Outbox processing failed', { requestId, error: toError(error).message }) + logger.error('Outbox processing failed', { error: toError(error).message }) return NextResponse.json( { success: false, requestId, error: toError(error).message }, { status: 500 } diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index 2aa469b8ffa..661ad07dfa6 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -35,7 +35,11 @@ const { mockFindPendingGrantWorkspaceIds, mockFindPendingOrganizationInvitation, mockGetInvitePlanCategoryForUser, + mockListInvitationsForWorkspaces, + mockListAccessibleWorkspaceRowsForUser, } = vi.hoisted(() => ({ + mockListInvitationsForWorkspaces: vi.fn().mockResolvedValue([]), + mockListAccessibleWorkspaceRowsForUser: vi.fn().mockResolvedValue([]), MockConflictingPendingInvitationError: class extends Error {}, mockGetWorkspaceInvitePolicy: vi.fn(), mockValidateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), @@ -89,7 +93,11 @@ vi.mock('@/lib/invitations/send', () => ({ vi.mock('@/lib/invitations/core', () => ({ normalizeEmail: (email: string) => email.trim().toLowerCase(), - listInvitationsForWorkspaces: vi.fn().mockResolvedValue([]), + listInvitationsForWorkspaces: mockListInvitationsForWorkspaces, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + listAccessibleWorkspaceRowsForUser: mockListAccessibleWorkspaceRowsForUser, })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ @@ -112,6 +120,71 @@ const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' import { POST } from '@/app/api/workspaces/invitations/batch/route' +import { GET } from '@/app/api/workspaces/invitations/route' + +describe('GET /api/workspaces/invitations', () => { + const invitation = (workspaceId: string) => ({ + id: `inv-${workspaceId}`, + workspaceId, + email: 'invitee@example.com', + token: `token-${workspaceId}`, + status: 'pending', + permission: 'admin', + }) + + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockListAccessibleWorkspaceRowsForUser.mockResolvedValue([ + { workspace: { id: 'ws-managed' }, permissionType: 'admin', viaOrgAdmin: false }, + /** An org admin: the row reader promotes these to `admin` before the route sees them. */ + { workspace: { id: 'ws-org-admin' }, permissionType: 'admin', viaOrgAdmin: true }, + { workspace: { id: 'ws-read-only' }, permissionType: 'read', viaOrgAdmin: false }, + ]) + mockListInvitationsForWorkspaces.mockResolvedValue([ + invitation('ws-managed'), + invitation('ws-org-admin'), + invitation('ws-read-only'), + ]) + }) + + /** + * The token stands in for being the invitee or an admin on the invitation detail route, which + * answers with the invitee's address and every workspace the invitation grants — so a reader of + * one workspace must not be handed it for every invitation they can see. + */ + it('returns the token only for workspaces the caller may manage', async () => { + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(200) + const { invitations } = await response.json() + expect(invitations).toEqual([ + expect.objectContaining({ workspaceId: 'ws-managed', token: 'token-ws-managed' }), + expect.objectContaining({ workspaceId: 'ws-org-admin', token: 'token-ws-org-admin' }), + expect.not.objectContaining({ token: expect.anything() }), + ]) + expect(invitations[2]).toMatchObject({ + workspaceId: 'ws-read-only', + email: 'invitee@example.com', + }) + }) + + it('asks only for the workspaces the caller can reach', async () => { + await GET(createMockRequest('GET')) + + expect(mockListInvitationsForWorkspaces).toHaveBeenCalledWith([ + 'ws-managed', + 'ws-org-admin', + 'ws-read-only', + ]) + }) + + it('refuses an unauthenticated caller', async () => { + mockGetSession.mockResolvedValue(null) + + expect((await GET(createMockRequest('GET'))).status).toBe(401) + }) +}) afterAll(resetEnvFlagsMock) diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 101f0dc8fff..8eac267368d 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { omit } from '@sim/utils/object' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -21,9 +22,23 @@ export const GET = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ invitations: [] }) } - const invitations = await listInvitationsForWorkspaces( - accessibleRows.map((row) => row.workspace.id) + /** + * The token stands in for being the invitee or a workspace admin on + * `GET /api/invitations/[id]`, which answers with the invitee's address, the organization, and + * every workspace the invitation grants. Its one use in the product is the admin-only "Copy + * invite link" — yet every reader received it, for every invitation in every workspace they + * could see. + */ + /** Org admins arrive already promoted to `admin` by the row reader, so this covers them too. */ + const manageableWorkspaceIds = new Set( + accessibleRows.filter((row) => row.permissionType === 'admin').map((row) => row.workspace.id) ) + + const rows = await listInvitationsForWorkspaces(accessibleRows.map((row) => row.workspace.id)) + const invitations = rows.map((invitation) => + manageableWorkspaceIds.has(invitation.workspaceId) ? invitation : omit(invitation, ['token']) + ) + return NextResponse.json({ invitations }) } catch (error) { logger.error('Error fetching workspace invitations:', error) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx index 39bfb5f40e5..05f6c71b810 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx @@ -307,7 +307,7 @@ describe('focused Search enrollment', () => { }) it.each([ - ['github_email_mismatch', 'add and verify the email address'], + ['github_email_unverified', 'verify your primary email address'], ['github_email_access_denied', 'Email addresses: Read-only permission'], ['provider_unavailable', 'Try connecting again in a few minutes'], ])( diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 4550345da53..8c031917faa 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -110,7 +110,6 @@ function UnavailableSearchConnection({ const OAUTH_MESSAGES = { ...CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, denied: 'Authorization was canceled. Nothing was connected.', - account_mismatch: 'Choose the account matching the email address on this invitation.', permissions_required: 'All requested permissions are required to connect this account.', configuration_changed: 'This credential option changed. Reload the page and try again.', unavailable: 'Account authorization is temporarily unavailable. Please try again.', diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index f83504cecae..2915b67c981 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -8,6 +8,7 @@ import { ChatNavigationLink, CollapsedChatFlyoutItem, CollapsedSidebarMenu, + SidebarRowActions, SidebarSection, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' @@ -56,30 +57,27 @@ function ChatRow({ href={chat.href} chatId={chat.id} isCurrentRoute={isCurrentRoute} - className={chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true })} + className={cn( + chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true }), + 'group/sidebar-row' + )} onContextMenu={(e) => onContextMenu(e, chat.id)} > -
- {showStatusDot && ( -
+ ) } diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx index 8d1bfa0b942..faae4ebd437 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx @@ -18,6 +18,7 @@ import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context- import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' +import { SidebarRowActions } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' import { useFlyoutInlineRename } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename' import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu' import { useToggleWorkspacePin, useUpdateWorkspace } from '@/hooks/queries/workspace' @@ -139,6 +140,7 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis key={workspace.id} asChild active={isActive || isMenuOpen} + actionOpen={isMenuOpen} onPointerMove={(event) => { if (menu.isOpen || rename.editingId) event.preventDefault() }} @@ -176,34 +178,36 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis openMenu(event, workspace.id)} > {label} -
- {isPinned && ( - - )} + + ) : undefined + } + > -
+
) })} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts index 90b069f5f44..90cb38491ef 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts @@ -29,6 +29,7 @@ describe('organization source status labels', () => { ['sync_failed', 'Sync failed'], ['account_sync_incomplete', 'Some accounts are not up to date'], ['document_indexing_failed', 'Some documents failed to index'], + ['permission_sync_incomplete', 'Some permissions could not be verified'], ] as const)('describes %s and keeps concurrent recovery visible', (issue, label) => { expect(organizationSearchStatusLabel({ ...provider, status: 'needs_attention', issue })).toBe( label diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts index 5988b08ce56..d4eed45ee37 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts @@ -16,9 +16,11 @@ export function organizationSearchStatusLabel(provider: OrganizationSearchProvid const error = provider.issue === 'account_sync_incomplete' ? 'Some accounts are not up to date' - : provider.issue === 'document_indexing_failed' - ? 'Some documents failed to index' - : 'Sync failed' + : provider.issue === 'permission_sync_incomplete' + ? 'Some permissions could not be verified' + : provider.issue === 'document_indexing_failed' + ? 'Some documents failed to index' + : 'Sync failed' return provider.isSyncing ? `Indexing · ${error}` : error } return STATUS_LABELS[provider.status] diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.test.tsx index 11481d6a1f4..60d050adf5d 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.test.tsx @@ -14,7 +14,8 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/hooks/queries/organization-search-stats', () => ({ useOrganizationSearchStats: mocks.query, })) -vi.mock('@/components/charts', () => ({ +vi.mock('@sim/emcn', async (importOriginal) => ({ + ...(await importOriginal()), BarChart: (props: unknown) => { mocks.chart(props) return
Daily chart
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.tsx index 1765187b12d..5c3c7694bab 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.tsx @@ -1,10 +1,9 @@ 'use client' import { type ReactNode, useMemo } from 'react' -import { Chip, ChipSelect, Tooltip } from '@sim/emcn' +import { BarChart, Chip, ChipSelect, Tooltip } from '@sim/emcn' import { CircleInfo } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' -import { BarChart } from '@/components/charts' import { SEARCH_STATS_PEOPLE_LIMIT, SEARCH_STATS_SURFACE_LABELS, diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts index 94ec37b962a..0d82bf2f5fc 100644 --- a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts +++ b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts @@ -18,6 +18,7 @@ import { const enterprise: OrganizationSettingsFeatures = { billingEnabled: true, hasEnterprisePlan: true, + governanceActive: true, hosted: true, selfHosted: {}, } @@ -33,7 +34,7 @@ describe('organization settings navigation', () => { it('uses Sources for administration when Search is available', () => { expect(organizationSettingsNavigation(true, enterprise, available)).toEqual( - ORGANIZATION_SETTINGS_ITEMS.filter(({ id }) => id !== 'connected-accounts') + ORGANIZATION_SETTINGS_ITEMS ) expect( organizationSettingsNavigation(true, enterprise, available).find( @@ -46,12 +47,27 @@ describe('organization settings navigation', () => { expect( organizationSettingsNavigation( true, - { ...enterprise, hasEnterprisePlan: false }, + { ...enterprise, hasEnterprisePlan: false, governanceActive: false }, available ).map(({ id }) => id) ).toEqual(['billing', 'members', 'recently-deleted', 'search-mcp']) }) + /** + * A failing payment closes the plan gate while the organization's permission groups keep + * applying, so the page that edits them has to stay listed — otherwise its members are governed + * by rules nobody can reach until the invoice clears. + */ + it('keeps Access Control listed while the organization is still governed', () => { + expect( + organizationSettingsNavigation( + true, + { ...enterprise, hasEnterprisePlan: false, governanceActive: true }, + available + ).map(({ id }) => id) + ).toEqual(['billing', 'members', 'recently-deleted', 'access-control', 'search-mcp']) + }) + it('honors individual self-hosted feature flags and hides billing when disabled', () => { expect( organizationSettingsNavigation( diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.ts b/apps/sim/app/o/[organizationId]/settings/navigation.ts index 0a96e8a0754..f559e4ae62e 100644 --- a/apps/sim/app/o/[organizationId]/settings/navigation.ts +++ b/apps/sim/app/o/[organizationId]/settings/navigation.ts @@ -71,8 +71,7 @@ export function organizationSettingsNavigation( ) { return ORGANIZATION_SETTINGS_ITEMS.filter( (item) => - (item.id !== 'connected-accounts' || - (availability.connectedAccounts && !availability.search)) && + (item.id !== 'connected-accounts' || availability.connectedAccounts) && ((item.id !== 'search-mcp' && item.id !== 'search-slack' && item.id !== 'integrations') || availability.search) && resolveOrganizationSectionAccess({ diff --git a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx index ab5d7c8de34..6c03c4c8e5a 100644 --- a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx @@ -6,7 +6,10 @@ import { ORGANIZATION_SETTINGS_GROUPS } from '@/components/settings/navigation' import { SettingsSidebar } from '@/components/settings/settings-sidebar' import { isApiClientError } from '@/lib/api/client/errors' import { isEnterprise } from '@/lib/billing/plan-helpers' -import { hasUsableSubscriptionAccess } from '@/lib/billing/subscriptions/utils' +import { + hasPaidSubscriptionStatus, + hasUsableSubscriptionAccess, +} from '@/lib/billing/subscriptions/utils' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { @@ -42,6 +45,16 @@ export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarPr isEnterprise(summary.data.subscriptionPlan) && hasUsableSubscriptionAccess(summary.data.subscriptionStatus, summary.data.billingBlocked) : settingsFeatures.hasEnterprisePlan, + /** + * Refreshed from the same summary, or the item the plan gate just hid would reappear only on + * reload. Governance keeps its own rule — an entitled status, block state ignored — because a + * failing payment does not stop the organization's permission groups from applying. + */ + governanceActive: + refreshPlan && summary + ? isEnterprise(summary.data.subscriptionPlan) && + hasPaidSubscriptionStatus(summary.data.subscriptionStatus) + : settingsFeatures.governanceActive, } const routes = organizationRoutes(organization.id) diff --git a/apps/sim/app/oauth-error/page.tsx b/apps/sim/app/oauth-error/page.tsx index 6e06358de77..b198d99562e 100644 --- a/apps/sim/app/oauth-error/page.tsx +++ b/apps/sim/app/oauth-error/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { SSO_REQUIRED_ERROR_CODE, SSO_REQUIRED_MESSAGE } from '@/lib/auth/constants' import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell' export const metadata: Metadata = { @@ -40,6 +41,11 @@ const FRIENDLY: Record = { */ account_not_linked: 'An account already exists for this email address. Sign in using the method you originally signed up with.', + /** + * The person's organization requires single sign-on, so a social sign-in is + * refused. Retrying the same provider can never succeed — name the way in. + */ + [SSO_REQUIRED_ERROR_CODE]: SSO_REQUIRED_MESSAGE, /** The provider returned no email claim, so there is nothing to sign in as. */ email_not_found: 'Your identity provider didn’t share an email address with us, so we couldn’t complete sign-in. Please contact your administrator.', diff --git a/apps/sim/app/slack-search/install/[teamId]/page.test.tsx b/apps/sim/app/slack-search/install/[teamId]/page.test.tsx new file mode 100644 index 00000000000..32b82014180 --- /dev/null +++ b/apps/sim/app/slack-search/install/[teamId]/page.test.tsx @@ -0,0 +1,61 @@ +/** @vitest-environment node */ +import type { ComponentProps, ReactNode } from 'react' +import { authMockFns } from '@sim/testing' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ app: vi.fn() })) +vi.mock('@sim/emcn', () => ({ + ChipLink: ({ children, href }: ComponentProps<'a'>) => {children}, +})) +vi.mock('@/app/(auth)/components', () => ({ + AuthShell: ({ children }: { children: ReactNode }) =>
{children}
, +})) +vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true })) +vi.mock('@/lib/slack-search/shared-app-env', () => ({ + getSharedSlackSearchAppConfiguration: m.app, +})) +vi.mock('next/navigation', () => ({ + notFound: () => { + throw new Error('Not found') + }, +})) + +import SlackInstallPage from '@/app/slack-search/install/[teamId]/page' + +beforeEach(() => { + vi.clearAllMocks() + m.app.mockReturnValue({ id: 'A1' }) + authMockFns.mockGetSession.mockResolvedValue(null) +}) +describe('Slack-initiated install entry', () => { + it('shows setup guidance without asserting installation or requiring sign-in', async () => { + const markup = renderToStaticMarkup( + await SlackInstallPage({ params: Promise.resolve({ teamId: 'T1' }) }) + ) + expect(markup).toContain('Sim Search in Slack') + expect(markup).not.toContain('is installed') + expect(markup).toContain('https://slack.com/app_redirect?app=A1&team=T1') + expect(markup).toContain('href="/home"') + expect(markup).not.toContain('/login') + expect(authMockFns.mockGetSession).not.toHaveBeenCalled() + }) + it('does not infer an organization from an existing Sim session', async () => { + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user' } }) + const markup = renderToStaticMarkup( + await SlackInstallPage({ params: Promise.resolve({ teamId: 'T1' }) }) + ) + expect(markup).toContain('connect this workspace later') + expect(authMockFns.mockGetSession).not.toHaveBeenCalled() + }) + it('rejects malformed workspace hints and unavailable apps before reading a session', async () => { + await expect( + SlackInstallPage({ params: Promise.resolve({ teamId: 'https://attacker.test' }) }) + ).rejects.toThrow('Not found') + m.app.mockReturnValue(null) + await expect(SlackInstallPage({ params: Promise.resolve({ teamId: 'T1' }) })).rejects.toThrow( + 'Not found' + ) + expect(authMockFns.mockGetSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/slack-search/install/[teamId]/page.tsx b/apps/sim/app/slack-search/install/[teamId]/page.tsx new file mode 100644 index 00000000000..98b4cd34894 --- /dev/null +++ b/apps/sim/app/slack-search/install/[teamId]/page.tsx @@ -0,0 +1,42 @@ +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { isHosted } from '@/lib/core/config/env-flags' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' +import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env' +import { AuthShell } from '@/app/(auth)/components' + +export const metadata: Metadata = { + title: 'Sim Search in Slack', + robots: { index: false, follow: false }, + referrer: 'no-referrer', +} + +interface SlackInstallPageProps { + params: Promise<{ teamId: string }> +} + +export default async function SlackInstallPage({ params }: SlackInstallPageProps) { + const { teamId } = await params + const app = isHosted ? getSharedSlackSearchAppConfiguration() : null + if (!/^T[A-Z0-9]{1,199}$/.test(teamId) || !app) notFound() + const slackUrl = new URL('https://slack.com/app_redirect') + slackUrl.search = new URLSearchParams({ app: app.id, team: teamId }).toString() + return ( + +
+

Sim Search in Slack

+

+ To start searching, an admin can connect this workspace later from Settings → Sim Search + in Slack in their Sim organization. +

+
+ + Open Slack + + Open Sim +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx b/apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx new file mode 100644 index 00000000000..4eb54fb68e8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx @@ -0,0 +1,5 @@ +import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading' + +export default function Loading() { + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx b/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx new file mode 100644 index 00000000000..b3d49e88885 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading' +import { MyAccessRequests } from '@/components/access-requests/my-access-requests' + +export const metadata: Metadata = { title: 'My access requests' } + +interface AccessRequestsPageProps { + params: Promise<{ workspaceId: string }> +} + +export default async function AccessRequestsPage({ params }: AccessRequestsPageProps) { + const { workspaceId } = await params + return ( + }> + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx index 4fb2d63be38..a3d2e8df7e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx @@ -1,6 +1,6 @@ import { Chip, cn } from '@sim/emcn' import { Upload } from '@sim/emcn/icons' -import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyState, type EmptyStateProps } from '@/components/empty-state/empty-state' import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline' import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' @@ -64,25 +64,39 @@ function FilesGraphic() { ) } -interface FilesEmptyStateProps { +interface UploadFilesEmptyStateProps { /** Opens the file picker — the same action the header's upload chip runs. */ onUpload: () => void /** Mirrors the header chip's disabled state: no edit rights, or an upload in flight. */ uploadDisabled?: boolean } -/** Empty state for the files list when the workspace has none. */ -export function FilesEmptyState({ onUpload, uploadDisabled = false }: FilesEmptyStateProps) { +type FilesEmptyStateProps = UploadFilesEmptyStateProps | Omit + +/** Shared file illustration and actions for empty or unavailable files. */ +export function FilesEmptyState(props: FilesEmptyStateProps) { + const content = 'title' in props ? props : undefined return ( } - title='Files' - description='Upload files to share them across your team and every agent.' + title={content?.title ?? 'Files'} + description={ + content?.description ?? 'Upload files to share them across your team and every agent.' + } action={ <> - - Upload - + {'onUpload' in props ? ( + + Upload + + ) : ( + content?.action + )} } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx index dcfdc7a3664..0ac6e3a0d42 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx @@ -1,33 +1,44 @@ import { Chip } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' -import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyState, type EmptyStateProps } from '@/components/empty-state/empty-state' import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' import { KnowledgeIsoMark } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso' const KNOWLEDGE_DOCS_URL = 'https://docs.sim.ai/knowledgebase' -interface KnowledgeEmptyStateProps { +interface CreateKnowledgeEmptyStateProps { /** Opens the create-base modal — the same action the header's primary chip runs. */ onCreate: () => void /** Mirrors the header chip's disabled state: no edit rights on the workspace. */ createDisabled?: boolean } -/** Empty state for the knowledge bases list when the workspace has none. */ -export function KnowledgeEmptyState({ - onCreate, - createDisabled = false, -}: KnowledgeEmptyStateProps) { +type KnowledgeEmptyStateProps = CreateKnowledgeEmptyStateProps | Omit + +/** Shared knowledge illustration and actions for empty or unavailable bases. */ +export function KnowledgeEmptyState(props: KnowledgeEmptyStateProps) { + const content = 'title' in props ? props : undefined return ( } - title='Knowledge bases' - description='Upload documents to give your agents a memory they can search.' + title={content?.title ?? 'Knowledge bases'} + description={ + content?.description ?? 'Upload documents to give your agents a memory they can search.' + } action={ <> - - New base - + {'onCreate' in props ? ( + + New base + + ) : ( + content?.action + )} } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx index 1026d13ed3d..1f3cb12b3b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx @@ -1,6 +1,6 @@ import { Chip, cn } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' -import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyState, type EmptyStateProps } from '@/components/empty-state/empty-state' import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' @@ -105,25 +105,40 @@ function TablesGraphic() { const TABLES_DOCS_URL = 'https://docs.sim.ai/tables' -interface TablesEmptyStateProps { +interface CreateTableEmptyStateProps { /** Creates a table — the same action the header's primary chip runs. */ onCreate: () => void /** Mirrors the header chip's disabled state: no edit rights, or a create already in flight. */ createDisabled?: boolean } -/** Empty state for the tables list when the workspace has none. */ -export function TablesEmptyState({ onCreate, createDisabled = false }: TablesEmptyStateProps) { +type TablesEmptyStateProps = CreateTableEmptyStateProps | Omit + +/** Shared table illustration and actions for empty or unavailable tables. */ +export function TablesEmptyState(props: TablesEmptyStateProps) { + const content = 'title' in props ? props : undefined return ( } - title='Tables' - description='Create a table to store structured data your agents can read and write.' + title={content?.title ?? 'Tables'} + description={ + content?.description ?? + 'Create a table to store structured data your agents can read and write.' + } action={ <> - - New table - + {'onCreate' in props ? ( + + New table + + ) : ( + content?.action + )} } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index f18841c0821..610456cd742 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -24,6 +24,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { getDocumentIcon } from '@/components/icons/document-icons' import { useLimitUpgradeToast } from '@/lib/billing/client' import { captureEvent } from '@/lib/posthog/client' @@ -264,6 +265,14 @@ function formatFileType(storedType: string | null, filename: string): string { } export function Files() { + return ( + + + + ) +} + +function FilesContent() { const fileInputRef = useRef(null) const saveRef = useRef<(() => Promise) | null>(null) const downloadSourceRef = useRef(null) diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts index dd08f5fc925..9a6772782d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts @@ -1,7 +1,9 @@ import type { QueryClient } from '@tanstack/react-query' import { listWorkspaceFileFoldersContract } from '@/lib/api/contracts/workspace-file-folders' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { authorizeResourcePrefetch } from '@/app/workspace/[workspaceId]/lib/authorize-resource-prefetch' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files' import { @@ -35,6 +37,7 @@ export async function prefetchFilesBrowser( if (!userId) return const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId) if (!hostContext) return + if (!(await authorizeResourcePrefetch(listAllWorkspaceFiles, workspaceId))) return await Promise.all([ queryClient.prefetchQuery({ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx index 080eb4bd4c1..22aa2256a84 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx @@ -9,7 +9,7 @@ vi.mock('@/lib/browser-agent/open-in-panel', () => ({ shouldOpenInBrowserPanel: () => false, openInBrowserPanel: vi.fn(), })) -vi.mock('@/lib/integrations', () => ({ +vi.mock('@/lib/integrations/icon-mapping', () => ({ blockTypeToIconMap: { confluence_v2: () => }, })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx index dcc94a58b90..34c0875ae7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx @@ -7,7 +7,7 @@ vi.mock('@/lib/browser-agent/open-in-panel', () => ({ shouldOpenInBrowserPanel: () => false, openInBrowserPanel: vi.fn(), })) -vi.mock('@/lib/integrations', () => ({ blockTypeToIconMap: {} })) +vi.mock('@/lib/integrations/icon-mapping', () => ({ blockTypeToIconMap: {} })) import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card' import { SourceChip } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx index 5c3c924fc6b..8d4540d4b9e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx @@ -3,7 +3,7 @@ import { chipFilledFillTokens, chipHoverSurfaceClass, cn, OverflowText, Tooltip } from '@sim/emcn' import { stripVersionSuffix } from '@sim/utils/string' import { faviconUrl } from '@/lib/core/utils/favicon' -import { blockTypeToIconMap } from '@/lib/integrations' +import { blockTypeToIconMap } from '@/lib/integrations/icon-mapping' import { externalLinkHostname, handleExternalLinkClick, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 73ab07b3539..f68a9e8bcfb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -5,6 +5,7 @@ import { cn, Expandable, ExpandableContent, SecretReveal, Tooltip, toast } from import { ArrowRight, Check, ChevronDown, SquareArrowUpRight, TerminalWindow } from '@sim/emcn/icons' import { isRecordLike } from '@sim/utils/object' import { useParams } from 'next/navigation' +import { MemberLimitRequestAction } from '@/components/access-requests/member-limit-request-action' import { useSession } from '@/lib/auth/auth-client' import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' @@ -76,6 +77,7 @@ import { useTablesList } from '@/hooks/queries/tables' import { findWorkspaceFileByPath } from '@/hooks/queries/utils/find-workspace-file-by-src' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useWorkspaceUsageGate } from '@/hooks/queries/workspace-usage' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' export interface OptionsItemData { @@ -3184,6 +3186,9 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { ? buildHostedUpgradeUrl() : HOSTED_BILLING_SETTINGS_URL const canManageBilling = !hosted || canManageWorkspaceBilling(hostContext, session?.user?.id) + const usageGate = useWorkspaceUsageGate( + data.action === 'increase_limit' && !canManageBilling ? hostContext.workspace.id : undefined + ) const unavailableMessage = hostContext.hostOrganizationId ? 'Contact an organization admin to manage this workspace’s usage limits.' : 'Only the workspace owner can manage this workspace’s usage limits.' @@ -3225,7 +3230,16 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { {hosted ? : } ) : ( -

{unavailableMessage}

+
+

{unavailableMessage}

+ {usageGate.isSuccess && + usageGate.data.isExceeded && + usageGate.data.scope === 'member' && ( + + )} +
)} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/usage-upgrade-display.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/usage-upgrade-display.test.tsx new file mode 100644 index 00000000000..77a2ea45758 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/usage-upgrade-display.test.tsx @@ -0,0 +1,67 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' + +const { usageGate } = vi.hoisted(() => ({ usageGate: vi.fn() })) +vi.mock('@/hooks/queries/workspace-usage', () => ({ useWorkspaceUsageGate: usageGate })) +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: { user: { id: 'member' } } }), +})) +vi.mock('@/lib/core/config/deployment-shape', async (importOriginal) => ({ + ...(await importOriginal()), + useDeploymentShape: () => ({ hosted: true }), +})) +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ + useWorkspaceHostContext: () => ({ + workspace: { id: 'workspace', billedAccountUserId: 'owner' }, + hostOrganizationId: 'organization', + viewer: { isHostOrganizationAdmin: false }, + }), +})) +vi.mock('@/hooks/use-settings-navigation', () => ({ + useSettingsNavigation: () => ({ getSettingsHref: () => '/settings/billing' }), +})) +vi.mock('@/components/access-requests/member-limit-request-action', () => ({ + MemberLimitRequestAction: () => , +})) + +import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +describe('usage-limit request action', () => { + it.each([ + { scope: 'payer', isExceeded: true, isSuccess: true, visible: false }, + { scope: 'member', isExceeded: true, isSuccess: true, visible: true }, + { scope: 'member', isExceeded: false, isSuccess: true, visible: false }, + { scope: 'member', isExceeded: true, isSuccess: false, visible: false }, + ])( + 'offers the remedy for the current cap ($scope, exceeded $isExceeded, loaded $isSuccess)', + ({ scope, isExceeded, isSuccess, visible }) => { + usageGate.mockReturnValue({ isSuccess, data: { scope, isExceeded } }) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root = createRoot(container) + try { + act(() => + root.render( + + ) + ) + expect(container.textContent?.includes('Request increase')).toBe(visible) + } finally { + act(() => root.unmount()) + } + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx index 4901e6b2dca..973bf4587fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx @@ -1,6 +1,7 @@ 'use client' import { useMemo, useState } from 'react' +import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata' import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn' import { Table } from '@sim/emcn/icons' import { stripVersionSuffix } from '@sim/utils/string' @@ -8,10 +9,9 @@ import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { GmailIcon, SlackIcon } from '@/components/icons' import { - INTEGRATIONS, resolveOAuthServiceForIntegration, resolveOAuthServiceForSlug, -} from '@/lib/integrations' +} from '@/lib/integrations/oauth-service' import { captureEvent } from '@/lib/posthog/client' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import type { @@ -30,12 +30,12 @@ import { useTablesList } from '@/hooks/queries/tables' /** Lookup integration slug by OAuth service display name (case-insensitive). */ const SLUG_BY_LOWER_NAME: ReadonlyMap = new Map( - INTEGRATIONS.map((i) => [i.name.toLowerCase(), i.slug]) + INTEGRATION_METADATA.map((i) => [i.name.toLowerCase(), i.slug]) ) /** Lookup base block type by catalog slug, for the connect-row popularity weight. */ const TYPE_BY_SLUG: ReadonlyMap = new Map( - INTEGRATIONS.map((i) => [i.slug, stripVersionSuffix(i.type)]) + INTEGRATION_METADATA.map((i) => [i.slug, stripVersionSuffix(i.type)]) ) /** @@ -86,7 +86,10 @@ const TABLE_STARTERS: readonly Candidate[] = [ */ const CANDIDATES: readonly Candidate[] = (() => { const integrationByType = new Map( - INTEGRATIONS.flatMap((i) => [[i.type, i] as const, [stripVersionSuffix(i.type), i] as const]) + INTEGRATION_METADATA.flatMap((i) => [ + [i.type, i] as const, + [stripVersionSuffix(i.type), i] as const, + ]) ) const out: Candidate[] = [...TABLE_STARTERS] for (const [blockType, meta] of Object.entries(getAllBlockMeta())) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 05e78e0d2f1..ff7515d1711 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -19,6 +19,7 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' import { @@ -91,7 +92,15 @@ interface HomeProps { userId?: string } -export function Home({ chatId, userName, userId }: HomeProps) { +export function Home(props: HomeProps) { + return ( + + + + ) +} + +function HomeContent({ chatId, userName, userId }: HomeProps) { useOAuthReturnRouter() const { workspaceId } = useParams<{ workspaceId: string }>() const router = useRouter() diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx index d7765df9eb5..51890cea304 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { notFound } from 'next/navigation' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { INTEGRATIONS } from '@/lib/integrations' import { IntegrationBlockDetail } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail' import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback' @@ -27,8 +28,10 @@ export default async function IntegrationBlockPage({ if (!integration) notFound() return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index a0e7bc57dce..52d433fa254 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -18,7 +18,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' import { SaveDiscardChips } from '@/components/settings/save-discard-actions' import { writeOAuthReturnContext } from '@/lib/credentials/client-state' -import { resolveCredentialDisplay } from '@/lib/integrations' +import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { AddPeopleModal, diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx index 88003c84119..4267bc40e60 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { ConnectedCredentialDetail } from '@/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail' export const metadata: Metadata = { @@ -11,5 +12,9 @@ export default async function ConnectedCredentialPage({ params: Promise<{ workspaceId: string; credentialId: string }> }) { const { workspaceId, credentialId } = await params - return + return ( + + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 3fc4ab345da..a1b1ea26755 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -13,6 +13,7 @@ import { } from '@sim/emcn' import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { blockTypeToIconMap, formatIntegrationType, @@ -138,6 +139,14 @@ function ConnectedItem({ href, blockType, name, description, icon: Icon }: Conne } export function Integrations() { + return ( + + + + ) +} + +function IntegrationsContent() { const scrollContainerRef = useRef(null) const params = useParams() const workspaceId = (params?.workspaceId as string) || '' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx index 0753a1df61d..a49bbd1ee6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document' import DocumentLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading' @@ -26,12 +27,14 @@ export default async function DocumentChunksPage({ params, searchParams }: Docum return ( }> - + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-history.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-history.tsx index 838960d35f8..f13d50a22c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-history.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-history.tsx @@ -62,7 +62,7 @@ export function ConnectorSyncHistory({ ) } -type SyncLogState = 'running' | 'interrupted' | 'failed' | 'completed' | 'partial' +type SyncLogState = 'running' | 'interrupted' | 'failed' | 'completed' | 'partial' | 'continuing' const SYNC_LOG_LABELS: Record = { running: 'In progress…', @@ -70,6 +70,7 @@ const SYNC_LOG_LABELS: Record = { failed: 'Failed', completed: 'Completed', partial: 'Partial', + continuing: 'Continuing', } /** Reclaimed stale locks leave started log rows behind; both views use the engine's own TTL. */ @@ -92,9 +93,10 @@ interface SyncHistoryRowProps { startedAt: string state: SyncLogState description?: string + notice?: string | null } -function SyncHistoryRow({ startedAt, state, description }: SyncHistoryRowProps) { +function SyncHistoryRow({ startedAt, state, description, notice }: SyncHistoryRowProps) { return ( · {SYNC_LOG_LABELS[state]}} } - description={description} + description={[description, notice].filter(Boolean).join(' · ') || undefined} badge={ state === 'completed' ? undefined : ( {logs.map((log) => { - const state = getSyncLogState(log, CONNECTOR_SYNC_STALE_LOCK_TTL_MS, now) + const continuing = + log.status === 'partial' && + log.listedCount === null && + log.docsFailed === 0 && + !log.errorMessage + const state = continuing + ? 'continuing' + : getSyncLogState(log, CONNECTOR_SYNC_STALE_LOCK_TTL_MS, now) const changes = [ log.docsAdded > 0 && `${log.docsAdded} added`, log.docsUpdated > 0 && `${log.docsUpdated} updated`, @@ -150,11 +159,13 @@ export function SyncHistory({ logs, isLoading }: SyncHistoryProps) { key={log.id} startedAt={log.startedAt} state={state} + notice={state === 'failed' ? undefined : log.errorMessage} description={ state === 'failed' ? (log.errorMessage ?? undefined) - : state === 'completed' || state === 'partial' - ? changes || 'No changes' + : state === 'completed' || state === 'partial' || state === 'continuing' + ? changes || + (state === 'continuing' || log.errorMessage ? undefined : 'No changes') : undefined } /> @@ -193,17 +204,29 @@ function MemberSyncHistory({ logs, members, isLoading }: MemberSyncHistoryProps) No member sync history yet. ) : ( logs.map((log) => { - const state = getSyncLogState(log, MEMBER_SYNC_STALE_LOCK_TTL_MS, now) + const continuing = + log.status === 'partial' && + log.membersIncomplete > 0 && + log.membersFailed === 0 && + log.docsFailed === 0 && + log.processingDispatchFailed === 0 && + !log.errorMessage + const state = continuing + ? 'continuing' + : getSyncLogState(log, MEMBER_SYNC_STALE_LOCK_TTL_MS, now) const changes = [ log.docsAdded > 0 && `${log.docsAdded} added`, log.docsUpdated > 0 && `${log.docsUpdated} updated`, log.docsTombstoned + log.docsPurged > 0 && `${log.docsTombstoned + log.docsPurged} deleted`, + (log.docsFailed ?? 0) > 0 && `${log.docsFailed} failed`, + (log.processingDispatchFailed ?? 0) > 0 && + `${log.processingDispatchFailed} failed to queue`, ] .filter(Boolean) .join(' · ') const description = [ - changes || 'No changes', + changes || (continuing || log.errorMessage ? undefined : 'No changes'), log.membersFailed > 0 && `${log.membersFailed} ${log.membersFailed === 1 ? 'account' : 'accounts'} failed`, log.membersIncomplete > 0 && @@ -216,10 +239,11 @@ function MemberSyncHistory({ logs, members, isLoading }: MemberSyncHistoryProps) key={log.id} startedAt={log.startedAt} state={state} + notice={state === 'failed' ? undefined : log.errorMessage} description={ state === 'failed' ? (log.errorMessage ?? undefined) - : state === 'completed' || state === 'partial' + : state === 'completed' || state === 'partial' || state === 'continuing' ? description : undefined } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx index c8574915cbe..fa07965ebc2 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx @@ -1063,6 +1063,41 @@ describe('shared connector sync history', () => { } ) + it.each([ + { docsFailed: 0, processingDispatchFailed: 0, continuing: true }, + { docsFailed: 1, processingDispatchFailed: 0, continuing: false }, + { docsFailed: 0, processingDispatchFailed: 1, continuing: false }, + { docsFailed: null, processingDispatchFailed: null, continuing: false }, + { docsFailed: undefined, processingDispatchFailed: undefined, continuing: false }, + ])('requires known healthy member counters for continuation: %j', (fields) => { + lifecycle.detail.current = { + memberSyncLogs: [ + { + ...makeLog({ status: 'partial' }), + membersCompleted: 1, + membersIncomplete: 1, + membersFailed: 0, + docsFailed: fields.docsFailed, + processingDispatchFailed: fields.processingDispatchFailed, + docsTombstoned: 0, + docsPurged: 0, + }, + ], + } + const container = renderComponent( + + ) + expect(container.textContent).toContain(fields.continuing ? 'Continuing' : 'Partial') + expect(container.textContent).not.toContain(fields.continuing ? 'Partial' : 'Continuing') + if (fields.continuing) expect(container.textContent).not.toContain('No changes') + if (fields.docsFailed) expect(container.textContent).toContain('1 failed') + if (fields.processingDispatchFailed) + expect(container.textContent).toContain('1 failed to queue') + }) + it('loads the member engine history rather than the content history', () => { lifecycle.detail.current = { syncLogs: [makeLog({ status: 'completed', docsAdded: 999 })], @@ -1129,13 +1164,37 @@ describe('SyncHistory', () => { expect(container.textContent).not.toContain('No changes') }) - it('renders a continued listing as partial with the work already completed', () => { - const container = render(makeLog({ status: 'partial', docsAdded: 3 })) - expect(container.textContent).toContain('Partial') + it('distinguishes a continued listing from a partial failure', () => { + const container = render(makeLog({ status: 'partial', docsAdded: 3, listedCount: null })) + expect(container.textContent).toContain('Continuing') expect(container.textContent).toContain('3 added') expect(container.textContent).not.toContain('In progress…') }) + it('keeps permission failures visible even when document processing succeeded', () => { + const container = render( + makeLog({ + status: 'partial', + listedCount: 4, + errorMessage: 'Some document permissions could not be verified.', + }) + ) + expect(container.textContent).toContain('Some document permissions could not be verified.') + expect(container.textContent).toContain('Partial') + expect(container.textContent).not.toContain('No changes') + expect(container.textContent).not.toContain('Continuing') + }) + + it.each([ + { docsFailed: 1, listedCount: null }, + { docsFailed: 0, listedCount: 4 }, + { docsFailed: 0 }, + ])('does not label failed, finished, or legacy partial logs as continuing: %j', (fields) => { + const container = render(makeLog({ status: 'partial', ...fields })) + expect(container.textContent).toContain('Partial') + expect(container.textContent).not.toContain('Continuing') + }) + it('keeps completion accessible without repeating decorative status on every row', () => { const log = makeLog({ status: 'completed', docsAdded: 3 }) const container = render(log) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx index 36e58dffe07..a441f86dfd8 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading' @@ -22,7 +23,9 @@ export default async function KnowledgeBasePage({ params, searchParams }: PagePr return ( }> - + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx index 6496a088c2e..a03d3348d72 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx @@ -34,6 +34,12 @@ vi.mock('nuqs', () => ({ useQueryStates: () => [{ search: '', connector: [], content: [], owner: [] }, vi.fn()], })) vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({ config: {} }) })) +vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ + useUserPermissionConfig: () => ({ data: { config: {} }, isPending: false }), +})) +vi.mock('@/hooks/queries/access-requests', () => ({ + useDiscoverAccessRequests: () => ({ data: { enabled: false, entries: [] }, isPending: false }), +})) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ useUserPermissionsContext: () => mocks.permissions, })) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 530208ed9ce..2bcfc892f11 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/constants' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' @@ -194,6 +195,14 @@ function connectorCell(connectorTypes?: string[]): ResourceCell { } export function Knowledge() { + return ( + + + + ) +} + +function KnowledgeContent() { const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 7aad80a5bca..f5080114a9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -2,7 +2,11 @@ import type { QueryClient } from '@tanstack/react-query' import { listKnowledgeBasesContract } from '@/lib/api/contracts/knowledge' import { internalSessionAuth } from '@/lib/api/server/routes' import { internalKnowledgePresenters } from '@/lib/knowledge/api/internal-route' -import { listInternalKnowledgeBases } from '@/lib/knowledge/application/knowledge-bases' +import { + listInternalKnowledgeBases, + listKnowledgeBases, +} from '@/lib/knowledge/application/knowledge-bases' +import { authorizeResourcePrefetch } from '@/app/workspace/[workspaceId]/lib/authorize-resource-prefetch' import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' @@ -37,6 +41,7 @@ export async function prefetchKnowledgeBases( userId: string | undefined ): Promise { if (!userId) return + if (!(await authorizeResourcePrefetch(listKnowledgeBases, workspaceId))) return await Promise.all([ queryClient.prefetchQuery({ diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index 79f619de2cd..1254f58c678 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -12,11 +12,13 @@ const { mockGetOrgWhitelabelSettings, mockPrefetchWorkspaceHostContext, mockPrefetchWorkspaceSidebar, + mockPrefetchWorkspaceAccess, } = vi.hoisted(() => ({ mockBrandingProvider: vi.fn(({ children }: { children: ReactNode }) => children), mockGetOrgWhitelabelSettings: vi.fn(), mockPrefetchWorkspaceHostContext: vi.fn(), mockPrefetchWorkspaceSidebar: vi.fn(), + mockPrefetchWorkspaceAccess: vi.fn(), })) vi.mock('@sim/emcn', () => ({ @@ -45,6 +47,10 @@ vi.mock('@/app/workspace/[workspaceId]/prefetch', () => ({ prefetchWorkspaceSidebar: mockPrefetchWorkspaceSidebar, })) +vi.mock('@/app/workspace/[workspaceId]/prefetch-access', () => ({ + prefetchWorkspaceAccess: mockPrefetchWorkspaceAccess, +})) + vi.mock('@/ee/whitelabeling/org-branding', () => ({ getOrgWhitelabelSettings: mockGetOrgWhitelabelSettings, })) @@ -146,10 +152,11 @@ describe('WorkspaceLayout host context', () => { vi.clearAllMocks() mockGetSession.mockResolvedValue({ user: { id: 'viewer-1' }, - session: { activeOrganizationId: 'org-a' }, + session: { id: 'session-1', activeOrganizationId: 'org-a' }, }) mockPrefetchWorkspaceHostContext.mockResolvedValue(HOST_CONTEXT) mockPrefetchWorkspaceSidebar.mockResolvedValue(undefined) + mockPrefetchWorkspaceAccess.mockResolvedValue(undefined) mockGetOrgWhitelabelSettings.mockResolvedValue({ brandName: 'Host B' }) }) @@ -169,6 +176,11 @@ describe('WorkspaceLayout host context', () => { HOST_CONTEXT, 'org-a' ) + expect(mockPrefetchWorkspaceAccess).toHaveBeenCalledWith(expect.anything(), 'workspace-b', { + kind: 'session', + userId: 'viewer-1', + sessionId: 'session-1', + }) expect(mockBrandingProvider).toHaveBeenCalledWith( expect.objectContaining({ hostOrganizationId: 'org-b', @@ -191,6 +203,7 @@ describe('WorkspaceLayout host context', () => { expect(html).toContain('Workspace access denied') expect(html).not.toContain('Secret workspace child') expect(mockPrefetchWorkspaceSidebar).not.toHaveBeenCalled() + expect(mockPrefetchWorkspaceAccess).not.toHaveBeenCalled() expect(mockGetOrgWhitelabelSettings).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 1e93ff58add..5db58722fe9 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -13,6 +13,7 @@ import { prefetchWorkspaceHostContext, prefetchWorkspaceSidebar, } from '@/app/workspace/[workspaceId]/prefetch' +import { prefetchWorkspaceAccess } from '@/app/workspace/[workspaceId]/prefetch-access' import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader' import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' import { DesktopOAuthConnectListener } from '@/app/workspace/[workspaceId]/providers/desktop-oauth-connect-listener' @@ -60,6 +61,11 @@ export default async function WorkspaceLayout({ activeOrganizationId ), isTableRowTtlEnabled(), + prefetchWorkspaceAccess(queryClient, workspaceId, { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + }), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' diff --git a/apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts b/apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts new file mode 100644 index 00000000000..ae63bece1a9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts @@ -0,0 +1,28 @@ +import { internalSessionAuth } from '@/lib/api/server/routes' +import { InternalUnauthenticatedError } from '@/lib/api/server/routes/internal-json-route' +import type { AuthorizingUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import type { ApplicationOperation } from '@/lib/core/application/operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** Prove module access through its application operation before seeding resource data or chrome. */ +export async function authorizeResourcePrefetch( + useCase: Pick< + AuthorizingUseCase, + 'authorize' + >, + workspaceId: string +): Promise { + try { + const principal = await internalSessionAuth.authenticate() + await useCase.authorize({ principal, input: { workspaceId } }) + return true + } catch (error) { + if (error instanceof InternalUnauthenticatedError) return false + if ( + error instanceof OrchestrationError && + (error.code === 'forbidden' || error.code === 'not_found' || error.code === 'unauthorized') + ) + return false + throw error + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 6bb77574bb4..fada5c1bb1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -1,10 +1,14 @@ /** * @vitest-environment node */ + import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { InternalUnauthenticatedError } from '@/lib/api/server/routes/internal-json-route' +import { OrchestrationError } from '@/lib/core/orchestration/types' const { + mockAuthorizeResource, mockAuthenticate, mockGetWorkspaceHostContextForViewer, mockGetWorkspaceMemberProfiles, @@ -21,6 +25,7 @@ const { mockListWorkspaceFileFolders, mockListWorkspaceFilesWithShares, } = vi.hoisted(() => ({ + mockAuthorizeResource: vi.fn(), mockAuthenticate: vi.fn(), mockGetWorkspaceHostContextForViewer: vi.fn(), mockGetWorkspaceMemberProfiles: vi.fn(), @@ -69,6 +74,12 @@ vi.mock('@/lib/users/queries', () => ({ vi.mock('@/lib/copilot/chat/list-mothership-chats', () => ({ listMothershipChats: mockListMothershipChats, })) +vi.mock('@/lib/table/application/tables', () => ({ + listTableDefinitionsUseCase: { authorize: mockAuthorizeResource }, +})) +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { authorize: mockAuthorizeResource }, +})) vi.mock('@/lib/table/service', () => ({ listTables: mockListTables, })) @@ -85,7 +96,10 @@ vi.mock('@/lib/api/server/routes', () => ({ internalSessionAuth: { authenticate: mockAuthenticate }, })) vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ - listInternalKnowledgeBases: { execute: mockListInternalKnowledgeBases }, + listKnowledgeBases: { authorize: mockAuthorizeResource }, + listInternalKnowledgeBases: { + execute: mockListInternalKnowledgeBases, + }, })) vi.mock('@/lib/knowledge/api/internal-route', () => ({ internalKnowledgePresenters: { list: mockKnowledgePresenterList }, @@ -117,6 +131,7 @@ function makeClient() { describe('workspace list prefetches', () => { beforeEach(() => { vi.clearAllMocks() + mockAuthorizeResource.mockResolvedValue(undefined) mockGetWorkspaceHostContextForViewer.mockResolvedValue({ viewer: { permission: 'admin' } }) mockListFoldersForWorkspace.mockResolvedValue([]) mockListWorkspaceFilesWithShares.mockResolvedValue([]) @@ -195,6 +210,24 @@ describe('workspace list prefetches', () => { }) }) + it.each([prefetchTables, prefetchKnowledgeBases, prefetchFilesBrowser])( + 'seeds no protected data or chrome when the module operation refuses access', + async (prefetch) => { + mockAuthorizeResource.mockRejectedValue( + new OrchestrationError('forbidden', 'Module withheld') + ) + const client = makeClient() + await prefetch(client, WORKSPACE_ID, USER_ID) + expect(client.getQueryCache().getAll()).toHaveLength(0) + expect(mockListTables).not.toHaveBeenCalled() + expect(mockListInternalKnowledgeBases).not.toHaveBeenCalled() + expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled() + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + expect(mockListWorkspaceFileFolders).not.toHaveBeenCalled() + expect(mockListPinnedItemsForUser).not.toHaveBeenCalled() + } + ) + describe('prefetchKnowledgeBases', () => { /** * The bases list is a protected read behind an application operation, so the prefetch runs @@ -215,7 +248,7 @@ describe('workspace list prefetches', () => { }) it('caches nothing when the session principal cannot be built', async () => { - mockAuthenticate.mockRejectedValue(new Error('Unauthorized')) + mockAuthenticate.mockRejectedValue(new InternalUnauthenticatedError()) const client = makeClient() await prefetchKnowledgeBases(client, WORKSPACE_ID, USER_ID) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index 2ea928e270d..9a009452d21 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx @@ -1,9 +1,8 @@ 'use client' import { memo, useCallback, useMemo, useRef, useState } from 'react' -import { Loader } from '@sim/emcn' +import { LineChart, Loader } from '@sim/emcn' import { useParams } from 'next/navigation' -import { LineChart } from '@/components/charts' import { DashboardSegmentsContext, type SegmentSelectionMode, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index b45d859aee4..c795883b965 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -1,8 +1,7 @@ import React from 'react' -import { Badge } from '@sim/emcn' +import { Badge, formatChartLatency } from '@sim/emcn' import { formatRelativeTime } from '@sim/utils/formatting' import { format } from 'date-fns' -import { formatChartLatency } from '@/components/charts/chart-format' import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { getBlock } from '@/blocks/registry' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx new file mode 100644 index 00000000000..f74b6c91084 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx @@ -0,0 +1,262 @@ +/** @vitest-environment jsdom */ +import { act, type ReactNode } from 'react' +import { dehydrate, hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + policy: vi.fn(), + discovery: vi.fn(), + requestJson: vi.fn(), + workspaceId: 'workspace', +})) +vi.mock('@/lib/permission-groups/application/read-user-config', () => ({ + readUserPermissionConfig: { execute: mocks.policy }, +})) +vi.mock('@/lib/permission-access-requests/application/requests', () => ({ + discoverAccessRequests: { execute: mocks.discovery }, +})) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: mocks.workspaceId }), + useRouter: () => ({ refresh: vi.fn() }), +})) +vi.mock('@sim/emcn', () => ({ + cn: (...values: string[]) => values.join(' '), + Chip: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + ChipLink: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})) +vi.mock('@sim/emcn/icons', () => ({ + Lock: () => null, + Plus: () => null, + Upload: () => null, + BookOpen: () => null, +})) +vi.mock('@/components/access-requests/request-access-action', () => ({ + RequestAccessAction: ({ pendingRequestId }: { pendingRequestId: string | null }) => ( + + ), +})) + +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' +import { ApiClientError } from '@/lib/api/client/errors' +import { getUserPermissionConfigContract } from '@/lib/api/contracts/permission-groups' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { prefetchWorkspaceAccess } from '@/app/workspace/[workspaceId]/prefetch-access' +import { + accessRequestKeys, + workspaceFeatureDiscoveryQuery, +} from '@/hooks/queries/utils/access-request-keys' +import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' + +const principal = { kind: 'session', userId: 'viewer', sessionId: 'session' } as const +const policy = { + permissionGroupId: 'group', + groupName: 'Group', + config: DEFAULT_PERMISSION_GROUP_CONFIG, + entitled: true, + organizationId: 'org', + isOrgAdmin: false, +} +const discovery = { + enabled: true, + organizationId: 'org', + entries: [ + { + target: { kind: 'feature', configKey: 'hideCopilot' }, + label: 'Chat', + state: 'requestable', + reason: null, + pendingRequestId: null, + }, + ], + total: 1, + hasMore: false, +} + +describe('workspace access hydration', () => { + let server: QueryClient + let client: QueryClient + let root: Root | undefined + let container: HTMLDivElement + + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + mocks.workspaceId = 'workspace' + mocks.policy.mockResolvedValue(policy) + mocks.discovery.mockResolvedValue(discovery) + mocks.requestJson.mockImplementation(() => new Promise(() => {})) + server = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + container = document.createElement('div') + document.body.appendChild(container) + }) + afterEach(() => { + if (root) act(() => root?.unmount()) + root = undefined + server.clear() + client.clear() + container.remove() + }) + async function prefetch() { + await prefetchWorkspaceAccess(server, 'workspace', principal) + hydrate(client, dehydrate(server)) + } + function tree() { + return ( + + +
Workspace chat
+
+
+ ) + } + function render() { + root = createRoot(container) + act(() => root?.render(tree())) + } + function restrict() { + mocks.policy.mockResolvedValue({ ...policy, config: { ...policy.config, hideCopilot: true } }) + } + + it('renders allowed chat immediately from the server seed without a second policy request', async () => { + await prefetch() + const html = renderToString(tree()) + expect(html).toContain('Workspace chat') + expect(html).not.toContain('Checking access') + render() + expect(container.textContent).toBe('Workspace chat') + expect(mocks.discovery).not.toHaveBeenCalled() + expect( + mocks.requestJson.mock.calls.some( + ([contract]) => contract === getUserPermissionConfigContract + ) + ).toBe(false) + expect(mocks.policy).toHaveBeenCalledWith({ principal, input: { workspaceId: 'workspace' } }) + }) + it.each([null, 'request'])( + 'renders restricted chat and request state %s on the first render', + async (pendingRequestId) => { + restrict() + mocks.discovery.mockResolvedValue({ + ...discovery, + entries: [{ ...discovery.entries[0], pendingRequestId }], + }) + await prefetch() + expect(renderToString(tree())).toContain('Access required') + render() + expect(container.textContent).not.toContain('Checking access') + expect(container.textContent).not.toContain('Workspace chat') + expect(container.textContent).toContain( + pendingRequestId ? 'Your request is pending.' : 'Request access' + ) + expect(mocks.requestJson).not.toHaveBeenCalled() + } + ) + it('awaits restricted discovery before dehydrating', async () => { + restrict() + const deferred = Promise.withResolvers() + mocks.discovery.mockReturnValue(deferred.promise) + const done = vi.fn() + const work = prefetch().then(done) + await vi.waitFor(() => expect(mocks.discovery).toHaveBeenCalledOnce()) + expect(done).not.toHaveBeenCalled() + deferred.resolve(discovery) + await work + expect( + client.getQueryData(accessRequestKeys.discovery(workspaceFeatureDiscoveryQuery('workspace'))) + ).toEqual(discovery) + }) + it('preserves legacy behavior when access requests are disabled', async () => { + restrict() + mocks.discovery.mockResolvedValue({ ...discovery, enabled: false, entries: [], total: 0 }) + await prefetch() + render() + expect(container.textContent).toBe('Workspace chat') + expect(mocks.requestJson).not.toHaveBeenCalled() + }) + it('does not load discovery for personal or unrestricted workspaces', async () => { + mocks.policy.mockResolvedValue({ + ...policy, + config: null, + organizationId: null, + entitled: false, + permissionGroupId: null, + groupName: null, + }) + await prefetch() + expect(renderToString(tree())).toContain('Workspace chat') + expect(mocks.discovery).not.toHaveBeenCalled() + }) + it.each(['rejected', 'invalid'])( + 'never hydrates a permissive policy after a %s server read', + async (failure) => { + if (failure === 'rejected') mocks.policy.mockRejectedValue(new Error('unavailable')) + else mocks.policy.mockResolvedValue({ config: null }) + await prefetch() + expect(dehydrate(server).queries).toHaveLength(0) + expect(mocks.discovery).not.toHaveBeenCalled() + expect(renderToString(tree())).toContain('Checking access') + expect(renderToString(tree())).not.toContain('Workspace chat') + } + ) + it('keeps restricted content closed when discovery fails', async () => { + restrict() + mocks.discovery.mockRejectedValue(new Error('unavailable')) + await prefetch() + expect(dehydrate(server).queries).toHaveLength(1) + expect(renderToString(tree())).toContain('Checking access') + expect(renderToString(tree())).not.toContain('Workspace chat') + }) + it('shows a retryable error after a failed seed and recovers through the existing query', async () => { + mocks.policy.mockRejectedValue(new Error('unavailable')) + await prefetch() + mocks.requestJson.mockRejectedValue( + new ApiClientError({ status: 403, message: 'Access unavailable', body: {} }) + ) + render() + await vi.waitFor(() => expect(container.textContent).toContain('Unable to check access')) + expect(container.textContent).not.toContain('Workspace chat') + mocks.requestJson.mockResolvedValue(policy) + await act(async () => container.querySelector('button')?.click()) + await vi.waitFor(() => expect(container.textContent).toBe('Workspace chat')) + }) + it('isolates workspace keys during navigation', async () => { + await prefetch() + mocks.workspaceId = 'different-workspace' + expect(renderToString(tree())).toContain('Checking access') + expect(renderToString(tree())).not.toContain('Workspace chat') + }) + it('keeps background policy invalidation effective after hydration', async () => { + await prefetch() + render() + const mountedContent = container.firstChild + const deferred = Promise.withResolvers() + mocks.requestJson.mockImplementation((contract) => + contract === getUserPermissionConfigContract ? deferred.promise : new Promise(() => {}) + ) + let refresh: Promise + act(() => { + refresh = client.invalidateQueries({ queryKey: permissionGroupKeys.userConfig('workspace') }) + }) + expect(container.firstChild).toBe(mountedContent) + await act(async () => { + client.setQueryData( + accessRequestKeys.discovery(workspaceFeatureDiscoveryQuery('workspace')), + discovery + ) + deferred.resolve({ ...policy, config: { ...policy.config, hideCopilot: true } }) + await refresh + }) + await vi.waitFor(() => expect(container.textContent).toContain('Access required')) + expect(container.textContent).not.toContain('Workspace chat') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts new file mode 100644 index 00000000000..182426e174a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts @@ -0,0 +1,57 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import type { QueryClient } from '@tanstack/react-query' +import { discoverAccessRequestsContract } from '@/lib/api/contracts/access-requests' +import { + type UserPermissionConfig, + userPermissionConfigSchema, +} from '@/lib/api/contracts/permission-groups' +import { readUserPermissionConfig } from '@/lib/permission-groups/application/read-user-config' +import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' +import { + ACCESS_REQUESTS_STALE_TIME, + accessRequestKeys, + workspaceFeatureDiscoveryQuery, +} from '@/hooks/queries/utils/access-request-keys' +import { + PERMISSION_GROUPS_STALE_TIME, + permissionGroupKeys, +} from '@/hooks/queries/utils/permission-group-keys' + +/** Seeds the boundary's existing queries; failed reads remain unhydrated and recover in the client. */ +export async function prefetchWorkspaceAccess( + queryClient: QueryClient, + workspaceId: string, + principal: SessionPrincipal +): Promise { + const queryKey = permissionGroupKeys.userConfig(workspaceId) + await queryClient.prefetchQuery({ + queryKey, + queryFn: async () => + userPermissionConfigSchema.parse( + await readUserPermissionConfig.execute({ principal, input: { workspaceId } }) + ), + staleTime: PERMISSION_GROUPS_STALE_TIME, + }) + + const policy = queryClient.getQueryData(queryKey) + if ( + !PLATFORM_FEATURES.some( + (feature) => feature.scope !== 'organization' && policy?.config?.[feature.configKey] + ) + ) + return + + const query = workspaceFeatureDiscoveryQuery(workspaceId) + await queryClient.prefetchQuery({ + queryKey: accessRequestKeys.discovery(query), + queryFn: async () => { + const { discoverAccessRequests } = await import( + '@/lib/permission-access-requests/application/requests' + ) + return discoverAccessRequestsContract.response.schema.parse( + await discoverAccessRequests.execute({ principal, input: query }) + ) + }, + staleTime: ACCESS_REQUESTS_STALE_TIME, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index bfd7f13b9f7..04e42904940 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -28,6 +28,9 @@ const { vi.mock('next/navigation', () => ({ notFound: mockNotFound, redirect: mockRedirect })) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/components/access-requests/permission-access-boundary', () => ({ + PermissionAccessBoundary: vi.fn(() => null), +})) vi.mock('@/lib/settings/application/workspace-section-access', () => ({ authorizeWorkspaceSettingsSection: mockAuthorizeSection, })) @@ -182,6 +185,22 @@ describe('WorkspaceSettingsSectionPage', () => { expect(mockGetQueryClient).not.toHaveBeenCalled() }) + it('renders a request-only boundary without protected children or section prefetches', async () => { + mockAuthorizeSection.mockResolvedValue({ + allowed: false, + disposition: 'request-access', + configKey: 'hideApiKeysTab', + }) + + const element = await WorkspaceSettingsSectionPage(pageProps('billing')) + + expect(element.props.children.props).toEqual({ configKey: 'hideApiKeysTab' }) + expect(mockSectionPrefetch).not.toHaveBeenCalled() + expect(mockGetQueryClient).not.toHaveBeenCalled() + expect(mockGetHostContext).not.toHaveBeenCalled() + expect(mockRedirect).not.toHaveBeenCalled() + }) + it('redirects unavailable visible-catalog sections to General', async () => { mockAuthorizeSection.mockResolvedValue({ allowed: false, disposition: 'redirect-general' }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 04ba51f88e6..97cf21c2783 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -2,6 +2,8 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' +import { EmptyState } from '@/components/empty-state/empty-state' import { getOrganizationSettingsHref, UNIFIED_TO_ORGANIZATION_SECTION, @@ -54,6 +56,20 @@ export default async function WorkspaceSettingsSectionPage({ }) if (!access.allowed) { if (access.disposition === 'not-found') notFound() + if (access.disposition === 'request-access') { + return ( + + } + > + + + ) + } redirectToGeneralSettings(workspaceId) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 6fc7c8495aa..016de4cb3e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -3,6 +3,8 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' import { usePostHog } from 'posthog-js/react' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' +import { getSettingsPermissionConfigKey } from '@/components/settings/navigation' import { useSession } from '@/lib/auth/auth-client' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { captureEvent } from '@/lib/posthog/client' @@ -128,7 +130,17 @@ interface SettingsPageProps { section: SettingsSection } -export function SettingsPage({ section }: SettingsPageProps) { +export function SettingsPage(props: SettingsPageProps) { + const configKey = getSettingsPermissionConfigKey(props.section) + if (!configKey) return + return ( + + + + ) +} + +function SettingsPageContent({ section }: SettingsPageProps) { const { data: session, isPending: sessionLoading } = useSession() const hostContext = useWorkspaceHostContext() const { billingEnabled } = useDeploymentShape() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index fdad1a693a0..246a11b9cc9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -31,9 +31,9 @@ describe('unified settings navigation', () => { { id: 'billing', label: 'Subscription', section: 'account' }, { id: 'teammates', label: 'Teammates', section: 'workspace' }, { id: 'organization', label: 'Members', section: 'organization' }, - { id: 'usage', label: 'Usage tracking', section: 'organization' }, + { id: 'usage', label: 'Insights', section: 'organization' }, { id: 'secrets', label: 'Secrets', section: 'workspace' }, - { id: 'connected-accounts', label: 'Connected accounts', section: 'organization' }, + { id: 'connected-accounts', label: 'Credential Groups', section: 'organization' }, { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, { id: 'mcp', label: 'MCP tools', section: 'workspace' }, { id: 'apikeys', label: 'Sim API keys', section: 'workspace' }, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 9f3c382c3ff..152bd121970 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading' import { Table } from './table' @@ -15,7 +16,9 @@ export const metadata: Metadata = { export default function TablePage() { return ( }> - + +
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index a937a26e753..c9cbbe31fb3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -1,7 +1,9 @@ import type { QueryClient } from '@tanstack/react-query' +import { listTableDefinitionsUseCase } from '@/lib/table/application/tables' import { listTables } from '@/lib/table/service' import { toTableListItem } from '@/lib/table/wire' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { authorizeResourcePrefetch } from '@/app/workspace/[workspaceId]/lib/authorize-resource-prefetch' import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys' @@ -33,6 +35,7 @@ export async function prefetchTables( if (!userId) return const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId) if (!hostContext) return + if (!(await authorizeResourcePrefetch(listTableDefinitionsUseCase, workspaceId))) return await Promise.all([ queryClient.prefetchQuery({ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index b7876cd65f2..4c68bbdb44c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import type { TableDefinition } from '@/lib/table' import { generateUniqueTableName, MAX_TABLE_BATCH_ITEMS } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' @@ -133,6 +134,14 @@ type TableResourceItem = | { kind: 'folder'; folder: WorkflowFolder } export function Tables() { + return ( + + + + ) +} + +function TablesContent() { const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 453970cb5e6..ed334922a89 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' -import { Chip, ChipCombobox, type ComboboxOptionGroup } from '@sim/emcn' +import { Chip, Combobox, type ComboboxOptionGroup } from '@sim/emcn' import { Key, SquareArrowUpRight } from '@sim/emcn/icons' import { useParams } from 'next/navigation' import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' @@ -445,7 +445,7 @@ export function CredentialSelector({ return (
- ({ + addBlock: vi.fn(), + dragBlock: vi.fn(), + discovery: vi.fn(), + toolbarState: { + expandedSections: { triggers: true, blocks: true, customBlocks: true, tools: true }, + setSectionExpanded: vi.fn(), + }, +})) + +vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) })) +vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) +vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn() })) +vi.mock('@sim/emcn', () => ({ + Button: ({ children, onClick }: { children: ReactNode; onClick: () => void }) => ( + + ), + chipVariants: () => '', + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + Expandable: ({ children, expanded }: { children: ReactNode; expanded: boolean }) => + expanded ? children : null, + ExpandableContent: ({ children }: { children: ReactNode }) => children, + Info: () => null, + OverflowText: ({ label }: { label: string }) => {label}, + handleKeyboardActivation: (event: React.KeyboardEvent, callback: () => void) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.stopPropagation() + callback() + } + }, +})) +vi.mock('@sim/emcn/icons', () => ({ + ChevronDown: () => null, + Lock: () => null, + Search: () => null, +})) +vi.mock('@/blocks/block-tile', () => ({ BlockTile: () => null })) +vi.mock('@/blocks/custom/build-config', () => ({ + isCustomBlockType: () => false, + buildCustomBlockConfig: vi.fn(), +})) +vi.mock('@/blocks/custom/client-overlay', () => ({ useCustomBlockOverlayVersion: () => 1 })) +vi.mock('@/blocks/custom/custom-block-icon', () => ({ getCustomBlockTile: vi.fn() })) +vi.mock('@/blocks/registry', () => ({ + getCanonicalBlocksByCategory: (category: string) => + category === 'blocks' + ? [ + { name: 'Allowed core', type: 'allowed-core' }, + { name: 'Locked core', type: 'locked-core' }, + ] + : [ + { name: 'Allowed tool', type: 'allowed-tool' }, + { name: 'Locked tool', type: 'locked-tool' }, + ], +})) +vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({ + getTriggersForSidebar: () => [ + { name: 'Allowed trigger', type: 'allowed-trigger' }, + { name: 'Locked trigger', type: 'locked-trigger' }, + ], + hasTriggerCapability: () => true, +})) +vi.mock('@/ee/whitelabeling/components/branding-provider', () => ({ + useOrgBrandConfig: () => ({}), +})) +vi.mock('@/hooks/queries/custom-blocks', () => ({ useCustomBlocks: () => ({ data: [] }) })) +vi.mock('@/hooks/use-sandbox-block-constraints', () => ({ useSandboxBlockConstraints: () => null })) +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ + filterBlocks: (items: T[]) => + items.filter((item) => !item.type.startsWith('locked-')), + isBlockRequestable: (type: string) => type.startsWith('locked-'), + }), +})) +vi.mock('@/components/access-requests/permission-access-boundary', () => ({ + useWorkspaceAccessRequestFeatures: discovery, +})) +vi.mock('@/components/access-requests/request-access-action', () => ({ + RequestAccessModal: ({ label, onClose }: { label: string; onClose: () => void }) => ( +
+ Request {label} + +
+ ), +})) +vi.mock('@/stores/panel', () => ({ + useToolbarStore: (selector: (state: typeof toolbarState) => unknown) => selector(toolbarState), +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/hooks', + () => ({ + useToolbarItemInteractions: () => ({ handleItemClick: addBlock, handleDragStart: dragBlock }), + }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components', + () => ({ ToolbarItemContextMenu: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config', + () => ({ LoopTool: { name: 'Loop', type: 'loop' } }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config', + () => ({ ParallelTool: { name: 'Parallel', type: 'parallel' } }) +) + +import { Toolbar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar' + +describe('toolbar access requests', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + discovery.mockReturnValue({ data: { enabled: true } }) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('places every enabled category above one restricted section in trigger/core/integration order', () => { + act(() => root.render()) + const sections = Array.from(container.querySelectorAll('section')) + expect(sections).toHaveLength(4) + expect(sections.at(-1)?.getAttribute('aria-label')).toBe('Access required') + expect( + Array.from(sections.at(-1)!.querySelectorAll('[role="button"]')).map((row) => row.textContent) + ).toEqual(['Locked trigger', 'Locked core', 'Locked tool']) + expect(sections.slice(0, -1).every((section) => !section.textContent?.includes('Locked'))).toBe( + true + ) + }) + + it.each(['click', 'Enter', ' '])( + 'opens a request with %s without inserting or dragging a block', + (activation) => { + act(() => root.render()) + const row = container.querySelector( + '[aria-label="Request access to Locked tool"]' + )! + expect(row.draggable).toBe(false) + act(() => { + row.dispatchEvent(new Event('dragstart', { bubbles: true })) + row.dispatchEvent( + activation === 'click' + ? new MouseEvent('click', { bubbles: true }) + : new KeyboardEvent('keydown', { key: activation, bubbles: true }) + ) + }) + expect(container.querySelector('[role="dialog"]')?.textContent).toContain( + 'Request Locked tool' + ) + expect(addBlock).not.toHaveBeenCalled() + expect(dragBlock).not.toHaveBeenCalled() + } + ) + + it('moves keyboard focus from enabled rows through the restricted section', async () => { + act(() => root.render()) + act(() => + container.querySelector('[data-toolbar-root] > [role="button"]')!.click() + ) + const input = container.querySelector('input')! + act(() => input.focus()) + const rows = Array.from( + container.querySelectorAll( + '[aria-label^="Add "], [aria-label^="Request access to "]' + ) + ) + for (const row of rows) { + act(() => + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) + ) + ) + expect(document.activeElement).toBe(row) + } + expect(document.activeElement?.getAttribute('aria-label')).toBe('Request access to Locked tool') + expect(addBlock).not.toHaveBeenCalled() + }) + + it('restores existing hiding when requests are off', () => { + discovery.mockReturnValue({ data: { enabled: false } }) + act(() => root.render()) + expect(container.textContent).not.toContain('Access required') + expect(container.textContent).not.toContain('Locked') + }) + + it('does not reopen a request after requests are disabled and re-enabled', () => { + act(() => root.render()) + act(() => + container + .querySelector('[aria-label="Request access to Locked tool"]') + ?.click() + ) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + discovery.mockReturnValue({ data: { enabled: false } }) + act(() => root.render()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + discovery.mockReturnValue({ data: { enabled: true } }) + act(() => root.render()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(container.querySelector('[aria-label="Request access to Locked tool"]')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 1dd0e4634b1..e4323e2c711 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -21,9 +21,11 @@ import { Info, OverflowText, } from '@sim/emcn' -import { ChevronDown, Search } from '@sim/emcn/icons' +import { ChevronDown, Lock, Search } from '@sim/emcn/icons' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { useWorkspaceAccessRequestFeatures } from '@/components/access-requests/permission-access-boundary' +import { RequestAccessModal } from '@/components/access-requests/request-access-action' import { captureEvent } from '@/lib/posthog/client' import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { @@ -53,6 +55,11 @@ interface BlockItem { icon?: ComponentType<{ className?: string }> bgColor?: string docsLink?: string + restricted?: boolean +} + +interface RestrictedBlockItem extends BlockItem { + section: 'triggers' | 'blocks' | 'tools' } interface ToolbarItemProps { @@ -116,15 +123,16 @@ const ToolbarItem = memo(function ToolbarItem({
@@ -135,6 +143,7 @@ const ToolbarItem = memo(function ToolbarItem({ data-toolbar-item-icon='' /> + {item.restricted && }
) }) @@ -379,11 +388,13 @@ export const Toolbar = memo( const blockItemRefs = useRef>([]) const customBlockItemRefs = useRef>([]) const toolItemRefs = useRef>([]) + const restrictedItemRefs = useRef>([]) const triggerRefCallbacks = useRef void>>({}) const blockRefCallbacks = useRef void>>({}) const customBlockRefCallbacks = useRef void>>({}) const toolRefCallbacks = useRef void>>({}) + const restrictedRefCallbacks = useRef void>>({}) const getTriggerRefCallback = useCallback((index: number) => { if (!triggerRefCallbacks.current[index]) { @@ -421,8 +432,20 @@ export const Toolbar = memo( return toolRefCallbacks.current[index] }, []) + const getRestrictedRefCallback = (index: number) => { + if (!restrictedRefCallbacks.current[index]) { + restrictedRefCallbacks.current[index] = (el) => { + restrictedItemRefs.current[index] = el + } + } + return restrictedRefCallbacks.current[index] + } + const posthog = usePostHog() - const { filterBlocks } = usePermissionConfig() + const { filterBlocks, isBlockRequestable } = usePermissionConfig() + const accessRequests = useWorkspaceAccessRequestFeatures() + const accessRequestsEnabled = accessRequests.data?.enabled === true + const [requestedBlockType, setRequestedBlockType] = useState(null) const sandboxAllowedBlocks = useSandboxBlockConstraints() const expandedSections = useToolbarStore((state) => state.expandedSections) @@ -471,6 +494,18 @@ export const Toolbar = memo( const allTriggers = getTriggers(blockOverlayVersion) const allBlocks = getBlocks(blockOverlayVersion) const allTools = getTools(blockOverlayVersion) + const requestedBlock = requestedBlockType + ? (allTriggers.find((item) => item.type === requestedBlockType) ?? + allBlocks.find((item) => item.type === requestedBlockType) ?? + allTools.find((item) => item.type === requestedBlockType)) + : undefined + + if ( + requestedBlockType !== null && + (!requestedBlock || !workspaceId || !accessRequestsEnabled) + ) { + setRequestedBlockType(null) + } // Published custom blocks are their own section. Exclude disabled blocks (still // resolvable so placed instances survive, but not offered for new placement) and @@ -502,6 +537,13 @@ export const Toolbar = memo( .sort((a, b) => a.name.localeCompare(b.name)) }, [customBlocksData, currentWorkflowId, fallbackIconUrl]) + const handleRequestItemClick = useCallback( + (type: string) => { + if (accessRequestsEnabled) setRequestedBlockType(type) + }, + [accessRequestsEnabled] + ) + const visibleTriggers = useMemo(() => { if (sandboxAllowedBlocks !== null) return [] return filterBlocks(allTriggers) @@ -525,6 +567,32 @@ export const Toolbar = memo( return permitted.filter((b) => sandboxAllowedBlocks.includes(b.type)) }, [filterBlocks, allTools, sandboxAllowedBlocks]) + const restrictedItems = useMemo((): RestrictedBlockItem[] => { + if (!accessRequestsEnabled) return [] + const categories = [ + { section: 'triggers', items: sandboxAllowedBlocks === null ? allTriggers : [] }, + { section: 'blocks', items: allBlocks }, + { section: 'tools', items: allTools }, + ] as const + return categories.flatMap(({ section, items }) => + items + .filter( + (item) => + !isCustomBlockType(item.type) && + isBlockRequestable(item.type) && + (sandboxAllowedBlocks === null || sandboxAllowedBlocks.includes(item.type)) + ) + .map((item) => ({ ...item, restricted: true, section })) + ) + }, [ + accessRequestsEnabled, + allTriggers, + allBlocks, + allTools, + isBlockRequestable, + sandboxAllowedBlocks, + ]) + const normalizedQuery = searchQuery.trim().toLowerCase() const isSearching = normalizedQuery.length > 0 @@ -552,6 +620,11 @@ export const Toolbar = memo( return visibleTools.filter((tool) => tool.name.toLowerCase().includes(normalizedQuery)) }, [visibleTools, isSearching, normalizedQuery]) + const filteredRestrictedItems = useMemo(() => { + if (!isSearching) return restrictedItems + return restrictedItems.filter((item) => item.name.toLowerCase().includes(normalizedQuery)) + }, [restrictedItems, isSearching, normalizedQuery]) + /** * Trim ref arrays to current filtered length to prevent stale refs from * polluting keyboard navigation when items disappear (search, sandbox). @@ -560,6 +633,7 @@ export const Toolbar = memo( blockItemRefs.current.length = filteredBlocks.length customBlockItemRefs.current.length = filteredCustomBlocks.length toolItemRefs.current.length = filteredTools.length + restrictedItemRefs.current.length = filteredRestrictedItems.length /** * Section expansion is derived during search (force-expand sections with @@ -596,11 +670,11 @@ export const Toolbar = memo( * If there's a query, keep search mode active so ArrowUp/Down navigation continues * to work after focus moves into the section lists. */ - const handleSearchBlur = useCallback(() => { - if (!searchQuery.trim()) { + const handleSearchBlur = (event: React.FocusEvent) => { + if (!searchQuery.trim() && !rootRef.current?.contains(event.relatedTarget)) { setIsSearchActive(false) } - }, [searchQuery]) + } const handleItemContextMenu = useCallback( (e: React.MouseEvent, type: string, isTrigger: boolean, docsLink?: string) => { @@ -652,11 +726,11 @@ export const Toolbar = memo( }, [isContextMenuOpen, closeContextMenu]) /** - * Keyboard navigation across the three sections. + * Keyboard navigation follows visible section order, ending with access requests. * * - Active only when the toolbar tab is active and search mode is on. * - Skips collapsed or empty sections so focus only lands on visible items. - * - ArrowDown traverses search → triggers → blocks → tools. + * - ArrowDown traverses search → triggers → blocks → custom blocks → tools → access required. * - ArrowUp moves backward; from the first item of the first visible section * it wraps back to the search input. */ @@ -671,7 +745,7 @@ export const Toolbar = memo( if (!toolbarRoot || !activeEl || !toolbarRoot.contains(activeEl)) return type SectionList = { - key: ToolbarSectionKey + key: ToolbarSectionKey | 'restricted' items: HTMLDivElement[] } @@ -688,6 +762,12 @@ export const Toolbar = memo( ? blockItemRefs.current.filter((el): el is HTMLDivElement => el !== null) : [], }, + { + key: 'customBlocks', + items: sectionExpanded.customBlocks + ? customBlockItemRefs.current.filter((el): el is HTMLDivElement => el !== null) + : [], + }, { key: 'tools', items: sectionExpanded.tools @@ -695,6 +775,10 @@ export const Toolbar = memo( : [], }, ] + allSections.push({ + key: 'restricted', + items: restrictedItemRefs.current.filter((el): el is HTMLDivElement => el !== null), + }) const sections = allSections.filter((section) => section.items.length > 0) let sectionIndex = -1 @@ -769,6 +853,7 @@ export const Toolbar = memo( isSearchActive, sectionExpanded.triggers, sectionExpanded.blocks, + sectionExpanded.customBlocks, sectionExpanded.tools, ]) @@ -811,6 +896,16 @@ export const Toolbar = memo(
+ {requestedBlock && workspaceId && accessRequestsEnabled && ( + setRequestedBlockType(null)} + /> + )} + {/* Single scroll container with three collapsible sections */}
+ {filteredRestrictedItems.length > 0 && ( +
+
+ Access required + + Ask your organization admin to enable these blocks for your permission group. + +
+
+ {filteredRestrictedItems.map((item, index) => ( + + ))} +
+
+ )}
{/* Toolbar Item Context Menu */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 7b77fb5678b..8cdecd5834c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -10,17 +10,13 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuItemAction, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Duplicate, Layout, MoreHorizontal, - Popover, - PopoverContent, - PopoverItem, - PopoverScrollArea, - PopoverSection, - PopoverTrigger, Trash, toast, } from '@sim/emcn' @@ -31,6 +27,7 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' +import { RequestAccessModal } from '@/components/access-requests/request-access-action' import { VariableIcon } from '@/components/icons' import { ThinkingLoader } from '@/components/ui' import { requestJson } from '@/lib/api/client/request' @@ -71,6 +68,7 @@ import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId] import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' +import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' import { useCopilotChatSelection } from '@/hooks/queries/copilot-chat-selection' import { type CopilotChatListItem, @@ -219,6 +217,20 @@ export const Panel = memo(function Panel() { scope: usageLimitScope, isLoading: isUsageGateLoading, } = useUsageLimits({ workspaceId }) + const isMemberLimitExceeded = usageExceeded && usageLimitScope === 'member' + const memberLimitRequest = useDiscoverAccessRequests( + { kind: 'workspace', workspaceId, targetKind: 'usage_limit', limit: 1, offset: 0 }, + isMemberLimitExceeded + ) + const [showLimitRequest, setShowLimitRequest] = useState(false) + const memberLimitTarget = + isMemberLimitExceeded && memberLimitRequest.isSuccess && memberLimitRequest.data.enabled + ? memberLimitRequest.data.entries.find((entry) => entry.state === 'requestable') + : undefined + + if (showLimitRequest && !memberLimitTarget) { + setShowLimitRequest(false) + } // Workflow execution hook const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution() @@ -243,10 +255,19 @@ export const Panel = memo(function Panel() { /** * Runs the workflow with usage limit check */ - const runWorkflow = useCallback(async () => { + const runWorkflow = async () => { if (isUsageGateLoading) return if (usageExceeded) { + if (usageLimitScope === 'member' && memberLimitTarget) { + if (memberLimitTarget.pendingRequestId) { + const params = new URLSearchParams({ requestId: memberLimitTarget.pendingRequestId }) + router.push(`/workspace/${encodeURIComponent(workspaceId)}/access-requests?${params}`) + } else { + setShowLimitRequest(true) + } + return + } const action = getWorkspaceUsageLimitAction(hostContext, session?.user?.id, { message: usageLimitMessage, scope: usageLimitScope, @@ -259,15 +280,7 @@ export const Panel = memo(function Panel() { return } await handleRunWorkflow() - }, [ - usageExceeded, - usageLimitMessage, - usageLimitScope, - isUsageGateLoading, - hostContext, - session?.user?.id, - handleRunWorkflow, - ]) + } // Chat state const { isChatOpen, setIsChatOpen } = useChatStore( @@ -708,6 +721,14 @@ export const Panel = memo(function Panel() { return ( <> + {showLimitRequest && memberLimitTarget && ( + setShowLimitRequest(false)} + /> + )}
+ + + {DIMENSION_LABELS[dimension]} + {!isMember && ( + + Workflow runs + + )} + {(isMember || dimension === 'workspace') && ( + + Chat runs + + )} + {!isMember && ( + <> + {dimension !== 'workspace' && ( + + Failed + + )} + + Failure rate + + + Avg. duration + + + )} + + + + {rows.map((row) => ( + + + {dimension === 'workspace' && row.workspaceId ? ( + { + if (row.workspaceId) onSelectWorkspace(row.workspaceId) + }} + className='max-w-full' + > + {row.label} + + ) : ( +
+ {isMember && } + +
+ )} + {dimension === 'workflow' && row.workspaceName && ( + + )} +
+ {!isMember && ( + + {row.workflowRuns.toLocaleString()} + + )} + {(isMember || dimension === 'workspace') && ( + + {row.chatRuns.toLocaleString()} + + )} + {!isMember && ( + <> + {dimension !== 'workspace' && ( + + {row.failed.toLocaleString()} + + )} + + {formatFailureRate(row.failureRate)} + + + {row.averageDurationMs === null + ? '—' + : row.averageDurationMs === 0 + ? '0 ms' + : formatDuration(row.averageDurationMs, { precision: 2 })} + + + )} +
+ ))} +
+
+ + ) +} diff --git a/apps/sim/ee/organization-usage/components/usage-consumers.tsx b/apps/sim/ee/organization-usage/components/usage-consumers.tsx index 6039688d960..a4b9342bdba 100644 --- a/apps/sim/ee/organization-usage/components/usage-consumers.tsx +++ b/apps/sim/ee/organization-usage/components/usage-consumers.tsx @@ -1,9 +1,8 @@ 'use client' import type { ComponentType } from 'react' -import { cn, disclosureChevronClass } from '@sim/emcn' +import { cn, disclosureChevronClass, formatChartCompactNumber } from '@sim/emcn' import { ArrowRight, ChevronDown } from '@sim/emcn/icons' -import { formatChartCompactNumber } from '@/components/charts' import { AnthropicIcon, AzureIcon, diff --git a/apps/sim/ee/organization-usage/components/usage-monitoring.tsx b/apps/sim/ee/organization-usage/components/usage-monitoring.tsx index 648fae039b4..23f4d1369d1 100644 --- a/apps/sim/ee/organization-usage/components/usage-monitoring.tsx +++ b/apps/sim/ee/organization-usage/components/usage-monitoring.tsx @@ -26,6 +26,8 @@ import { import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { serializeAuditLogFilters } from '@/ee/audit-logs/search-params' +import { ActivityPanel } from '@/ee/organization-usage/components/activity-panel' +import { OrganizationActivityOverview } from '@/ee/organization-usage/components/activity-summary' import { UsageConsumers } from '@/ee/organization-usage/components/usage-consumers' import { UsageSourceMix } from '@/ee/organization-usage/components/usage-source-mix' import { UsageSummary } from '@/ee/organization-usage/components/usage-summary' @@ -51,11 +53,6 @@ const TABS = USAGE_TAB_ORDER.map((tab) => ({ value: tab, label: USAGE_TAB_LABELS const DAY_MS = 24 * 60 * 60 * 1000 -/** - * One labelled band per view. The unit lives here rather than on every row — ten rows - * each ending in the word "credits" is noise, and a column header is where a reader - * already looks for it. - */ function UsageSection({ dimension, unit, @@ -86,14 +83,6 @@ interface UsageMonitoringProps { auditLogsHref: string } -/** - * Organization usage monitoring. - * - * The panel reads as one question per tab: how much and what kind of work - * (Overview), then who (Members), where (Workspaces), and on what (Models, BYOK). - * Only the visible tab's dimension is fetched, which is also the performance story — - * half the dimensions heap-scan the ledger, and a tab nobody opens never pays for one. - */ export function UsageMonitoring({ organizationId, eventsHref: eventsBaseHref, @@ -105,40 +94,17 @@ export function UsageMonitoring({ useUsageWindow() const [datePickerOpen, setDatePickerOpen] = useState(false) const [isExporting, setIsExporting] = useState(false) - /** The member whose credit limit is being edited, or null when the modal is closed. */ const [creditsTarget, setCreditsTarget] = useState(null) const isOverview = tab === USAGE_OVERVIEW_TAB - /** - * A selected workspace turns the Workspaces tab into that workspace's workflows — - * but only once the id resolves against the loaded list. A bookmarked id for a - * deleted workspace, or one belonging to another organization, would otherwise open - * a detail view with an untitled header and empty sections. Falling back to the - * list is the rule for every deep-linked entity id (`sim-url-state.md`); the - * lingering param is harmless. - */ + /** Resolve bookmarked workspace IDs before opening the credit drill-down. */ const isWorkspaceSelected = tab === 'workspace' && Boolean(workspace) - /** - * Per-member caps are hosted-only: the usage-limit route 404s where Sim does not - * own billing, and there is no enforcement to hang a cap off. This panel is the - * one organization surface a self-hosted enterprise can reach — Members is - * `requiresHosted` with no self-hosted override — so without this the menu would - * offer an action that could only fail. - */ + /** Member credit caps are enforced only on hosted deployments. */ const canManageCredits = tab === 'member' && hosted - const summary = useOrganizationUsageSummary(organizationId, window) - /** - * Kept alive in the drill-down purely to name it. The rule is to store the id and - * derive the entity from the loaded list. - * - * Pinned to the full page rather than to the panel's current row limit: the id can - * come from an expanded list or from a bookmark, and a lookup that only held the - * top ten resolved nothing for either — which reads as the drill-down refusing to - * open, since `isWorkspaceDetail` gates on the name. Requesting the ceiling means a - * click from an expanded list is served from that list's own cache entry. - */ + const summary = useOrganizationUsageSummary(organizationId, window, { enabled: isOverview }) + /** Use the full workspace page to resolve IDs selected from an expanded list. */ const workspaceList = useOrganizationUsageBreakdown(organizationId, window, 'workspace', { enabled: isWorkspaceSelected, limit: EXPANDED_ROW_COUNT, @@ -151,11 +117,12 @@ export function UsageMonitoring({ const isWorkspaceDetail = isWorkspaceSelected && (workspaceList.isLoading || workspaceName !== undefined) - const dimension: UsageBreakdownDimension = isOverview - ? 'source' - : isWorkspaceDetail - ? 'workflow' - : (tab as UsageBreakdownDimension) + const dimension: UsageBreakdownDimension = + isOverview || tab === 'activity' + ? 'source' + : isWorkspaceDetail + ? 'workflow' + : (tab as UsageBreakdownDimension) /** * Per breakdown, not per page: the drill-down shows two lists at once, so opening @@ -164,17 +131,14 @@ export function UsageMonitoring({ const rowLimitFor = (target: UsageBreakdownDimension) => expanded.includes(target) ? EXPANDED_ROW_COUNT : COLLAPSED_ROW_COUNT - /** - * Opens one list's tail, unless it is already at the API's ceiling — past that the - * `Other` row is a true remainder and the control would do nothing. `undefined` - * rather than a no-op handler, so the row renders as text instead of as a button. - */ + /** Offer expansion only while the API can return additional rows. */ const expandOtherFor = (target: UsageBreakdownDimension) => rowLimitFor(target) < EXPANDED_ROW_COUNT ? () => void setState({ expanded: [...expanded, target] }) : undefined const breakdown = useOrganizationUsageBreakdown(organizationId, window, dimension, { + enabled: tab !== 'activity', limit: rowLimitFor(dimension), ...(isWorkspaceDetail && workspace ? { workspaceId: workspace } : {}), }) @@ -183,41 +147,19 @@ export function UsageMonitoring({ limit: rowLimitFor('source'), ...(workspace ? { workspaceId: workspace } : {}), }) - /** - * The same headline and trend the Overview draws, narrowed to this workspace. - * - * A second summary rather than a figure derived from the lists below it: they carry - * totals but no time series, and the shape of the period is the question the chart - * answers. It is also the only place the drill-down states its window, which is why - * its section is labelled with the period rather than with the word "Usage". - */ + const workspaceSummary = useOrganizationUsageSummary(organizationId, window, { enabled: isWorkspaceDetail, ...(workspace ? { workspaceId: workspace } : {}), }) - // Already cached by Members and Billing, so the meter costs nothing extra and - // cannot report a different allowance than they do. - const billing = useOrganizationBilling(organizationId) + const billing = useOrganizationBilling(organizationId, { + enabled: isOverview && preset === 'current-period', + }) - /** - * The organization audit feed, narrowed to the workspace being drilled into. - * - * Only offered where that section exists. Usage and Audit logs carry the same - * hosted and enterprise gates, so reaching this panel already proves both — but - * their self-hosted overrides are separate flags, and an install with usage - * monitoring on and audit logs off would have been handed an action pointing at a - * section it had switched off. The window is deliberately not carried across: the - * audit feed speaks in rolling ranges (`Past 30 days`) and this panel in billing - * periods, so there is no honest mapping for `current-period`. - */ + /** Audit logs have a separate deployment flag and incompatible period presets. */ const auditLogsHref = hosted || features.auditLogs ? serializeAuditLogFilters(auditLogsBaseHref, { workspace }) : null - /** - * The drill-down is the same window, in more detail. Without the params it read its - * own defaults and silently showed the current period while the panel behind it - * showed a custom range — two pages disagreeing about what "this" means. - */ const eventsHref = serializeOrganizationUsageParams(eventsBaseHref, { preset: window.preset, startDate: window.startDate ?? null, @@ -229,16 +171,15 @@ export function UsageMonitoring({ setDatePickerOpen(true) return } - void setState({ preset: value as typeof preset, startDate: null, endDate: null }) + void setState({ + preset: value as typeof preset, + startDate: null, + endDate: null, + activityPage: 0, + }) } const handleDateRangeApply = (nextStart: string, nextEnd: string) => { - /** - * Refuse an over-long range here rather than committing it and letting all four - * reads fail. The server still enforces the cap — this is the same rule stated - * where the user can act on it, with the picker left open on the selection that - * needs changing. - */ const spanDays = Math.ceil( (new Date(nextEnd).getTime() - new Date(nextStart).getTime()) / DAY_MS ) @@ -246,15 +187,13 @@ export function UsageMonitoring({ toast.error(`Select a range of ${MAX_CUSTOM_RANGE_DAYS} days or fewer`) return } - void setState({ preset: 'custom', startDate: nextStart, endDate: nextEnd }) + void setState({ preset: 'custom', startDate: nextStart, endDate: nextEnd, activityPage: 0 }) setDatePickerOpen(false) } const handleExport = async () => { if (isExporting) return setIsExporting(true) - // The organization is the path segment below; the query no longer carries a - // second copy of it. const params = new URLSearchParams({ preset: window.preset, timezone: window.timezone, @@ -262,11 +201,6 @@ export function UsageMonitoring({ if (window.startDate) params.set('startDate', window.startDate) if (window.endDate) params.set('endDate', window.endDate) - /** - * Wrapped because the action is fire-and-forget: `onSelect` cannot await this, so - * a rejection — a dropped connection, a blob read that fails — became an unhandled - * promise and the button appeared to do nothing at all. - */ try { // boundary-raw-fetch: downloads a CSV blob and reads X-Export-Truncated before saving — a plain anchor navigation can do neither const response = await fetch( @@ -296,15 +230,10 @@ export function UsageMonitoring({ } } - /** - * The drill-down is a detail view, so it takes over the header: a back chip out of - * it, and the one action that belongs to a workspace rather than the organization. - */ if (isWorkspaceDetail && workspace) { return ( /logs`. Organization admin is not workspace - membership, and `WorkspaceLayout` answers a non-member with - `WorkspaceAccessDenied`, so the run-logs route was a one-way trip - to a dead end for any workspace the admin had not joined. Audit - logs live in the settings section the admin is already inside. - */ + /** Organization admins may lack workspace membership, so link to organization audit logs. */ text: 'Open logs', onSelect: () => router.push(auditLogsHref), onPrefetch: () => router.prefetch(auditLogsHref), @@ -331,16 +253,7 @@ export function UsageMonitoring({ : [] } > - {/* - Labelled with the period, not "Usage": the picker lives on the list behind - this view, so once you are in here the window is carried but invisible — and - a total with no stated period is a number people read as all-time. The - heading the chart already needs is where that belongs. - - No allowance passed, unlike the Overview: the limit is pooled across the - whole organization, and printing it under one workspace's figure would read - as that workspace's own cap. - */} + {/** Organization allowances do not apply to a single workspace. */} - {/* - Sources first, because in most workspaces the majority of usage is Chat - rather than workflow runs — and a workflow list alone hid that behind a - single unexplained row. Sources reconciles to the workspace total; Workflows - is explicitly the workflow-run subset of it. - */} + ) } @@ -392,27 +304,28 @@ export function UsageMonitoring({ text: 'Export', icon: Download, onSelect: () => void handleExport(), - disabled: summary.isLoading || isExporting, + disabled: isExporting, }, ]} > -
- - void setState({ tab: value as UsageTab, workspace: null, expanded: null }) - } - /> +
+
+ + void setState({ + tab: value as UsageTab, + workspace: null, + expanded: null, + activityPage: 0, + }) + } + /> +
- {/* ChipCombobox (Radix Popover, non-modal), not ChipSelect (Radix - DropdownMenu, modal by default) — a modal trigger closing in the - same tick that opens the Calendar popover below traps it behind - the modal's focus lock, so "Custom range" silently does nothing. */} + {/** A non-modal picker lets the calendar open without a competing focus lock. */} - {/* - No `showTime`: the panel buckets by calendar day, so a time of day is - precision it cannot render. It also emitted the end bound as an - inclusive `…T23:59:59` local wall time, which the window resolver then - treated as a midnight and pushed a further 24h — every custom range - covered an extra day, and a legal 92-day pick measured 93 and was - rejected. Bare `YYYY-MM-DD` bounds parse as UTC midnight, matching the - rest of the window logic. - */} + {/** Calendar-day bounds stay date-only; the server makes the end exclusive. */} - {/* - The allowance is a per-billing-period figure, so it is only comparable - to the current period's total. Against a rolling window or a custom - range it measures a different span than the limit covers — a 30-day - window spanning two periods could read "Over limit" while neither - period was — so those windows show the figure without an allowance. - */} + {/** Compare the allowance only with its billing period. */} - {/* - "What kind of work was this?" belongs beside the total it explains, not - behind a tab — it is the second half of the same sentence. - - One section, two readings of it: the list ranks the sources, the web shows - whether spend is concentrated or spread. Two `SettingsSection`s side by - side would have drawn two half-width hairlines on one line — every other - rule in this panel spans the column — and left one header carrying the - `credits` unit while its neighbour, showing the same data, carried none. - - `auto-fit` on a track minimum rather than a `lg:` breakpoint: the settings - content column is a fixed `max-w-[48rem]`, so viewport width says nothing - about how wide this actually is. Same rule as `RESOURCE_LIST_GRID`. - */} + - {/* - `min(320px, 100%)` rather than a bare `320px`: a track minimum is a - hard floor, so on a column narrower than the minimum the grid would - be wider than its container and overflow. Capping the floor at the - available width collapses it to one column instead. - */} -
- - -
+
+ ) : tab === 'activity' ? ( + ) : ( void setState({ workspace: row.id, expanded: null }, { history: 'push' }), } @@ -544,14 +412,7 @@ export function UsageMonitoring({ )} - {/* - A sibling of the panel, not a child. `SettingsPanel` renders its children - straight into the shell's gap-7 content column, so a modal mounted inside it - is a body slot that contributes to that spacing. - - The same modal the Members settings page opens, driven by the same hooks — - setting a cap here and there is one implementation, not two. - */} + {/** Keep the modal outside the panel’s content-spacing layout. */} {canManageCredits && ( > = { + workflow: 'var(--indicator-seat-filled)', + 'sim-chat': 'var(--brand-agent)', + mcp_copilot: 'var(--badge-purple-text)', + mothership_block: 'var(--badge-pink-text)', + 'knowledge-base': 'var(--badge-teal-text)', + enrichment: 'var(--badge-amber-text)', + wand: 'var(--badge-cyan-text)', + 'voice-input': 'var(--badge-orange-text)', + 'voice-output': 'var(--text-success)', + 'api-tool': 'var(--badge-blue-text)', +} +const MAX_SOURCES = 5 interface UsageSourceMixProps { breakdown?: OrganizationUsageBreakdown @@ -28,73 +23,28 @@ interface UsageSourceMixProps { isError: boolean } -/** - * The source list's shape, beside the list itself. - * - * The rows answer "how much did each source cost"; they cannot answer "is this - * organization's spend concentrated or spread", which is the question an admin - * actually opens this tab with. Reading the same rows as a polygon makes a single - * dominant source and an even split visibly different at a glance. - */ export function UsageSourceMix({ breakdown, isLoading, isError }: UsageSourceMixProps) { - /* - Stabilized so `RadarChart`'s `memo()` can pass — built inline it was a new array - on every render of the panel. - */ - const axes = useMemo(() => { - const rows = breakdown?.rows ?? [] - const head = rows.slice(0, MAX_AXES) - const tail = rows.slice(MAX_AXES) - /* - The folded axis carries the API's own remainder as well as the rows this chart - dropped, so the web reconciles to the same total as the list beside it. - - It is deliberately *not* labelled `Other (N more)`: the chart folds at MAX_AXES - and the list folds at COLLAPSED_ROW_COUNT, so the two counts genuinely differ, - and printing both a few pixels apart under identical wording reads as a bug. The - count moves into the hover row, where it is attributed. - */ - const otherRowCount = tail.length + (breakdown?.other.rowCount ?? 0) - const otherCredits = - tail.reduce((total, row) => total + row.credits, 0) + (breakdown?.other.credits ?? 0) - return [ - ...head.map((row) => ({ - label: row.label, - value: row.credits, - display: row.credits.toLocaleString(), - })), - ...(otherRowCount > 0 - ? [ - { - label: 'Other', - value: otherCredits, - display: `${otherCredits.toLocaleString()} · ${otherRowCount} sources`, - }, - ] - : []), - ] - }, [breakdown]) - - if (isError) { - return ( - - Couldn't load this view. - - ) - } - if (isLoading || !breakdown) { - return Loading… - } - - /* - The chart refuses fewer than three axes — a two-gon is a line, not a distribution — - but its own fallback is a `height`-tall "No data" box, which beside a list holding - two populated rows says the wrong thing at the wrong size. The wrapper answers - instead, in the same inline empty state its neighbour uses. - */ - if (axes.length < 3) { - return Not enough sources to compare. - } - - return + const rows = breakdown?.rows ?? [] + const head = rows.slice(0, MAX_SOURCES) + const tail = rows.slice(MAX_SOURCES) + const otherCredits = + tail.reduce((total, row) => total + row.credits, 0) + (breakdown?.other.credits ?? 0) + const segments = [ + ...head.map((row) => ({ + label: row.label, + value: row.credits, + color: SOURCE_COLORS[row.id ?? ''] ?? 'var(--text-muted)', + })), + ...(otherCredits > 0 ? [{ label: 'Other', value: otherCredits, color: 'var(--border)' }] : []), + ] + + return ( + + + + ) } diff --git a/apps/sim/ee/organization-usage/components/usage-summary.test.tsx b/apps/sim/ee/organization-usage/components/usage-summary.test.tsx new file mode 100644 index 00000000000..d730a2f5abc --- /dev/null +++ b/apps/sim/ee/organization-usage/components/usage-summary.test.tsx @@ -0,0 +1,28 @@ +/** @vitest-environment node */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import type { OrganizationUsageSummary } from '@/lib/api/contracts/organization-usage' +import { UsageSummary } from '@/ee/organization-usage/components/usage-summary' + +const summary: OrganizationUsageSummary = { + window: { start: '2026-01-01', end: '2026-01-08', source: 'range' }, + bucket: 'day', + totals: { credits: 200 }, + previousTotals: { credits: 100 }, + series: [], +} + +describe('UsageSummary', () => { + it('hides stale usage badges when a refresh fails', () => { + const render = (isError: boolean) => + renderToStaticMarkup( + + ) + expect(render(false)).toContain('Over limit') + expect(render(false)).toContain('compared with the previous period') + const failed = render(true) + expect(failed).not.toContain('Over limit') + expect(failed).not.toContain('compared with the previous period') + expect(failed).toContain('load credits.') + }) +}) diff --git a/apps/sim/ee/organization-usage/components/usage-summary.tsx b/apps/sim/ee/organization-usage/components/usage-summary.tsx index 41e5d5643dd..329ab208644 100644 --- a/apps/sim/ee/organization-usage/components/usage-summary.tsx +++ b/apps/sim/ee/organization-usage/components/usage-summary.tsx @@ -1,34 +1,18 @@ 'use client' import { useMemo } from 'react' -import { Badge, cn } from '@sim/emcn' -import { BarChart } from '@/components/charts' +import { Badge, BarChart, ChartFrame, cn } from '@sim/emcn' import type { OrganizationUsageSummary } from '@/lib/api/contracts/organization-usage' import { formatCreditsLabel } from '@/lib/billing/credits/conversion' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' - -/** Consumption, matching the seat meter's indicator rather than an outcome colour. */ -const USAGE_SERIES_COLOR = 'var(--indicator-seat-filled)' interface UsageSummaryProps { summary?: OrganizationUsageSummary - /** Pooled allowance in credits, from the organization's billing data. `null` when uncapped. */ limitCredits?: number | null isLoading: boolean isError: boolean - /** - * Dims the figures while a re-keyed fetch resolves, rather than blanking them — the - * same treatment `UsageConsumers` gives a retained list. Without it the headline and - * chart present the previous period's numbers as though they were the new period's. - */ isPlaceholderData?: boolean } -function percentDelta(current: number, previous: number): number | null { - if (previous <= 0) return null - return ((current - previous) / previous) * 100 -} - export function UsageSummary({ summary, limitCredits, @@ -36,72 +20,57 @@ export function UsageSummary({ isError, isPlaceholderData, }: UsageSummaryProps) { - /* - Stabilized so `BarChart`'s `memo()` can actually pass. Built inline it was a new - array on every render of the panel — a date-picker toggle or an export click - re-rendered ninety bars for nothing. - */ const series = useMemo( () => summary?.series.map((point) => ({ timestamp: point.timestamp, value: point.credits })) ?? [], [summary] ) - - if (isError) { - return ( - - Couldn't load usage. - - ) - } - if (isLoading || !summary) { - return Loading usage… - } - - const used = summary.totals.credits - const delta = summary.previousTotals ? percentDelta(used, summary.previousTotals.credits) : null + const used = !isError ? (summary?.totals.credits ?? 0) : 0 + const previous = !isError ? (summary?.previousTotals?.credits ?? 0) : 0 + const delta = previous > 0 ? ((used - previous) / previous) * 100 : null const hasLimit = limitCredits != null && limitCredits > 0 - const isOverLimit = hasLimit && used > limitCredits - return ( -
- {/* - One line, and the allowance sits beside the figure rather than under it — - restating "4,958 credits used" below a "4,958 credits" headline said the same - number twice and read as a rendering bug. - */} -
- {/* - `text-base`, not `text-lg`: the shell's page title is `text-lg`, and a - metric drawn at the same size competed with the header for the first read. - */} - - {formatCreditsLabel(used)} - - {hasLimit && ( - // Bare number, not `formatCreditsLabel`: the headline beside it already - // names the unit, and "4,958 credits of 200,000 credits" says it twice. - - of {limitCredits.toLocaleString()} +
+
+
+ + {isError || !summary ? '—' : formatCreditsLabel(used)} - )} - {delta !== null && ( - 0 ? 'amber' : 'gray-secondary'} size='sm'> - {`${delta > 0 ? '↑' : '↓'} ${Math.abs(delta).toFixed(0)}% vs last period`} - - )} - {isOverLimit && ( - // `red`, not `amber`: past the pooled allowance is a violation, and the - // trend badge sitting immediately beside it is already amber. - - Over limit - - )} + {hasLimit && ( + + of {limitCredits.toLocaleString()} + + )} +
+
+ {delta !== null && ( + 0 ? 'amber' : 'gray-secondary'} + size='sm' + aria-label={`${Math.abs(delta).toFixed(0)}% ${delta > 0 ? 'increase' : delta < 0 ? 'decrease' : 'change'} compared with the previous period`} + >{`${delta > 0 ? '↑' : '↓'} ${Math.abs(delta).toFixed(0)}%`} + )} + {hasLimit && used > limitCredits && ( + + Over limit + + )} +
- - + + +
) } diff --git a/apps/sim/ee/organization-usage/constants.ts b/apps/sim/ee/organization-usage/constants.ts index 510933e6485..9f2e08e3c7d 100644 --- a/apps/sim/ee/organization-usage/constants.ts +++ b/apps/sim/ee/organization-usage/constants.ts @@ -25,19 +25,22 @@ export const PERIOD_OPTIONS: ComboboxOption[] = USAGE_WINDOW_PRESETS.map((preset })) export const USAGE_OVERVIEW_TAB = 'overview' as const -export type UsageTab = typeof USAGE_OVERVIEW_TAB | 'member' | 'workspace' | 'model' | 'byok' +export type UsageTab = + | typeof USAGE_OVERVIEW_TAB + | 'activity' + | 'member' + | 'workspace' + | 'model' + | 'byok' /** - * The panel reads as one question per tab, in the order an admin asks them: - * how much (Overview, which also answers *what kind* via its source mix), then who, - * then where, then on what. - * - * Workflows is deliberately not a tab. A workflow is only meaningful inside its - * workspace, and a flat org-wide workflow list is dominated by a bucket of usage that - * has no workflow at all — so it lives as the Workspaces drill-down instead. + * Activity ranks retained executions; the member, workspace, and model tabs rank + * ledger spend. Workflow spend remains within the workspace drill-down because + * charges from other sources do not carry workflow attribution. */ export const USAGE_TAB_ORDER: readonly UsageTab[] = [ USAGE_OVERVIEW_TAB, + 'activity', 'member', 'workspace', 'model', @@ -51,6 +54,7 @@ export const USAGE_TAB_ORDER: readonly UsageTab[] = [ export const USAGE_TAB_LABELS: Record = { overview: 'Overview', + activity: 'Activity', member: 'Members', workspace: 'Workspaces', model: 'Models', diff --git a/apps/sim/ee/organization-usage/hooks/use-usage-window.ts b/apps/sim/ee/organization-usage/hooks/use-usage-window.ts index a10ac5251b4..27c48c500c1 100644 --- a/apps/sim/ee/organization-usage/hooks/use-usage-window.ts +++ b/apps/sim/ee/organization-usage/hooks/use-usage-window.ts @@ -5,6 +5,7 @@ import { MAX_CUSTOM_RANGE_DAYS, type UsageWindowPreset, } from '@/lib/api/contracts/organization-usage' +import { ACTIVITY_MAX_PAGE } from '@/lib/billing/core/organization-activity' import { formatDateShort } from '@/lib/core/utils/date-display' import { getBrowserTimezone } from '@/lib/core/utils/timezone' import { DEFAULT_USAGE_PRESET, PERIOD_LABELS } from '@/ee/organization-usage/constants' @@ -22,15 +23,7 @@ function isCalendarDate(value: string | null): value is string { return new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value } -/** - * Every rule the window resolver enforces, checked here too. - * - * The server refuses an unreal date, an inverted pair, and a span past the cap — each - * as a 400. A deep link carrying any of them would otherwise be marked "resolved" and - * fail all four queries on the page, which is a worse outcome than the fallback this - * guard exists to provide. Duplicated deliberately, and narrowly: these are the three - * conditions that turn a link into an error rather than into different data. - */ +/** Match server date validation so invalid links fall back before issuing requests. */ export function isUsableCustomRange(start: string | null, end: string | null): boolean { if (!isCalendarDate(start) || !isCalendarDate(end)) return false const from = new Date(`${start}T00:00:00.000Z`).getTime() @@ -39,34 +32,16 @@ export function isUsableCustomRange(start: string | null, end: string | null): b return Math.round((to - from) / DAY_MS) + 1 <= MAX_CUSTOM_RANGE_DAYS } -/** - * The panel's URL state, resolved into the window every query is keyed on. - * - * A `custom` preset missing either bound falls back to the default rather than - * querying unbounded — the same partial-deep-link guard audit-logs uses. - */ +/** Resolve shared URL filters, falling back when a custom range is invalid. */ export function useUsageWindow() { const [state, setState] = useQueryStates(organizationUsageParsers, organizationUsageUrlKeys) const timezone = getBrowserTimezone() - /** - * Both bounds present, and a range the API will actually accept. - * - * Every condition the window resolver refuses with a 400 — an unreal date, an - * inverted pair, a span past the cap — has to be checked here too, or a bookmarked - * link carrying one is marked resolved and fails all four queries on the page. The - * fallback exists precisely so a bad link degrades to the default window instead. - */ const isResolvedCustom = state.preset === 'custom' && isUsableCustomRange(state.startDate, state.endDate) const preset: UsageWindowPreset = state.preset === 'custom' && !isResolvedCustom ? DEFAULT_USAGE_PRESET : state.preset - /* - Not memoized: this object is only ever hashed, never compared by identity — - React Query hashes a query key structurally, and the panel reads the primitive - fields off it directly. - */ const window: OrganizationUsageWindowKey = { preset, ...(isResolvedCustom @@ -83,13 +58,10 @@ export function useUsageWindow() { window, tab: state.tab, workspace: state.workspace, + activityDimension: state.activityDimension, + activitySort: state.activitySort, + activityPage: Math.min(ACTIVITY_MAX_PAGE, Math.max(0, state.activityPage)), expanded: state.expanded, - /** - * The *resolved* preset, not the raw URL value. A partial custom deep link - * queries the current period, so surfacing `state.preset` left the picker - * reading "Custom range" over data that was not custom — and the allowance - * gate, which keys on `current-period`, disagreed with the window too. - */ preset, startDate: state.startDate, endDate: state.endDate, diff --git a/apps/sim/ee/organization-usage/search-params.ts b/apps/sim/ee/organization-usage/search-params.ts index c8a74d44e17..201ab5a7da9 100644 --- a/apps/sim/ee/organization-usage/search-params.ts +++ b/apps/sim/ee/organization-usage/search-params.ts @@ -1,8 +1,15 @@ -import { createSerializer, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { + createSerializer, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringLiteral, +} from 'nuqs/server' import { USAGE_BREAKDOWN_DIMENSIONS, USAGE_WINDOW_PRESETS, } from '@/lib/api/contracts/organization-usage' +import { ACTIVITY_DIMENSIONS, ACTIVITY_SORTS } from '@/lib/billing/core/organization-activity' import { parseAsDateString } from '@/app/workspace/[workspaceId]/logs/search-params' import { DEFAULT_USAGE_PRESET, @@ -10,35 +17,18 @@ import { USAGE_TAB_ORDER, } from '@/ee/organization-usage/constants' -/** - * URL state for the organization usage panel. - * - * `startDate`/`endDate` are deliberately nullable (no `.withDefault`): they exist only - * while `preset` is `custom`. Every other preset derives its window server-side from - * the organization's subscription period, so a default here would be meaningless — and - * worse, would silently pin the window to a stale date. - */ +/** Custom dates stay nullable because other presets resolve their bounds server-side. */ export const organizationUsageParsers = { preset: parseAsStringLiteral(USAGE_WINDOW_PRESETS).withDefault(DEFAULT_USAGE_PRESET), startDate: parseAsDateString, endDate: parseAsDateString, tab: parseAsStringLiteral(USAGE_TAB_ORDER).withDefault(DEFAULT_USAGE_TAB), - /** - * Nullable by design: only the id is stored, and the detail view opens only once it - * resolves against the loaded list — a stale id from an old link falls back to the - * list rather than rendering an empty drill-down. - */ + workspace: parseAsString, - /** - * Which breakdowns have had their `Other` row opened, named by dimension. In the URL - * because it is shareable view-state like every other filter here — and because it - * changes which rows the page fetched, so a shared link that omitted it would not - * show the list the sender was looking at. - * - * A list, not a flag: the workspace drill-down renders two breakdowns at once, and - * one boolean meant opening either tail silently opened the other's — refetching a - * list nobody asked to expand, and leaving its `Other` row as inert text. - */ + activityDimension: parseAsStringLiteral(ACTIVITY_DIMENSIONS).withDefault('workspace'), + activitySort: parseAsStringLiteral(ACTIVITY_SORTS).withDefault('runs'), + activityPage: parseAsInteger.withDefault(0), + /** Track expanded dimensions separately because workspace detail contains multiple lists. */ expanded: parseAsArrayOf(parseAsStringLiteral(USAGE_BREAKDOWN_DIMENSIONS)).withDefault([]), } as const @@ -50,15 +40,13 @@ export const organizationUsageUrlKeys = { urlKeys: { startDate: 'start-date', endDate: 'end-date', + activityDimension: 'activity-group', + activitySort: 'activity-sort', + activityPage: 'activity-page', }, } as const -/** - * Outbound links into the usage drill-downs, serialized from the same parser map the - * destination reads. Hand-writing the wire keys duplicated the `urlKeys` remap, so - * renaming `start-date` would have silently dropped the window from every such link — - * exactly the panel/drill-down disagreement the events href exists to prevent. - */ +/** Serialize links with the destination’s parser map and URL keys. */ export const serializeOrganizationUsageParams = createSerializer(organizationUsageParsers, { clearOnDefault: true, urlKeys: organizationUsageUrlKeys.urlKeys, diff --git a/apps/sim/ee/sso/components/require-sso-section.test.tsx b/apps/sim/ee/sso/components/require-sso-section.test.tsx new file mode 100644 index 00000000000..428b292754a --- /dev/null +++ b/apps/sim/ee/sso/components/require-sso-section.test.tsx @@ -0,0 +1,159 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPolicyState, mockUpdatePolicy, mockToast } = vi.hoisted(() => ({ + mockPolicyState: vi.fn(), + mockUpdatePolicy: vi.fn(), + mockToast: { error: vi.fn(), success: vi.fn() }, +})) + +interface ConfirmModalProps { + open: boolean + title: string + confirm: { label: string; onClick: () => void } +} + +interface SwitchProps { + value: string + disabled?: boolean + options: ReadonlyArray<{ value: string; label: string }> + onChange: (value: string) => void +} + +vi.mock('@sim/emcn', () => ({ + ChipConfirmModal: ({ open, title, confirm }: ConfirmModalProps) => + open ? ( +
+ +
+ ) : null, + ChipSwitch: ({ value, disabled, options, onChange }: SwitchProps) => ( +
+ {options.map((option) => ( + + ))} +
+ ), + Info: ({ children }: { children?: ReactNode }) => {children}, + Label: ({ children }: { children?: ReactNode }) => {children}, + toast: mockToast, +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsQueryErrorState: ({ fallback }: { fallback: string }) => ( +
{fallback}
+ ), +})) + +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', + () => ({ + SettingsSection: ({ children }: { children?: ReactNode }) =>
{children}
, + }) +) + +vi.mock('@/ee/sso/hooks/sso-policy', () => ({ + useOrganizationSsoPolicy: () => mockPolicyState(), + useUpdateOrganizationSsoPolicy: () => ({ mutateAsync: mockUpdatePolicy, isPending: false }), +})) + +import { RequireSsoSection } from '@/ee/sso/components/require-sso-section' + +let container: HTMLDivElement +let root: Root + +function render() { + act(() => { + root.render() + }) +} + +function button(label: string): HTMLButtonElement { + const match = Array.from(container.querySelectorAll('button')).find( + (entry) => entry.textContent === label + ) + if (!match) throw new Error(`No button labelled ${label}`) + return match +} + +describe('RequireSsoSection', () => { + beforeEach(() => { + vi.clearAllMocks() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mockUpdatePolicy.mockResolvedValue(undefined) + mockPolicyState.mockReturnValue({ + data: { requireSso: false, hasVerifiedProvider: true, isEnforced: false }, + }) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('confirms before requiring single sign-on', async () => { + render() + act(() => button('Single sign-on').click()) + + expect(mockUpdatePolicy).not.toHaveBeenCalled() + expect(container.querySelector('[role="dialog"]')).not.toBeNull() + + await act(async () => button('Require SSO').click()) + expect(mockUpdatePolicy).toHaveBeenCalledWith({ organizationId: 'org-1', requireSso: true }) + }) + + it('turns the requirement off without a confirmation step', async () => { + mockPolicyState.mockReturnValue({ + data: { requireSso: true, hasVerifiedProvider: true, isEnforced: true }, + }) + render() + + await act(async () => button('Any method').click()) + expect(mockUpdatePolicy).toHaveBeenCalledWith({ organizationId: 'org-1', requireSso: false }) + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('says so when the requirement could not be read', () => { + mockPolicyState.mockReturnValue({ data: undefined, error: new Error('nope') }) + render() + + expect(container.textContent).toContain('Failed to load the sign-in requirement') + }) + + it('says the requirement is stored but not enforced when nothing can satisfy it', () => { + mockPolicyState.mockReturnValue({ + data: { requireSso: true, hasVerifiedProvider: false, isEnforced: false }, + }) + render() + + expect(container.textContent).toContain('it is not enforced') + /** Switching back must stay available, or the setting would be stranded. */ + expect(button('Any method').disabled).toBe(false) + }) + + it('cannot be turned on without a provider that could satisfy it', () => { + mockPolicyState.mockReturnValue({ + data: { requireSso: false, hasVerifiedProvider: false, isEnforced: false }, + }) + render() + + expect(button('Single sign-on').disabled).toBe(true) + expect(container.textContent).toContain('Add an identity provider on a verified domain') + }) +}) diff --git a/apps/sim/ee/sso/components/require-sso-section.tsx b/apps/sim/ee/sso/components/require-sso-section.tsx new file mode 100644 index 00000000000..cd56611f408 --- /dev/null +++ b/apps/sim/ee/sso/components/require-sso-section.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal, ChipSwitch, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { OrganizationSsoPolicy } from '@/lib/api/contracts/organization' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { SettingRow } from '@/ee/components/setting-row' +import { useOrganizationSsoPolicy, useUpdateOrganizationSsoPolicy } from '@/ee/sso/hooks/sso-policy' + +const OPTIONS = [ + { value: 'any', label: 'Any method' }, + { value: 'sso-only', label: 'Single sign-on' }, +] as const + +function describePolicy(policy: OrganizationSsoPolicy): string { + if (policy.requireSso && !policy.isEnforced) { + return 'Nothing can satisfy the requirement right now, so it is not enforced. Restore an identity provider on a verified domain, or switch back to any method.' + } + if (!policy.hasVerifiedProvider) { + return 'Add an identity provider on a verified domain to require single sign-on.' + } + return policy.requireSso + ? 'Members sign in through your identity provider. Password and email sign-in are refused.' + : 'Members can sign in with a password, email code, or your identity provider.' +} + +interface RequireSsoSectionProps { + organizationId: string +} + +/** + * Whether members must sign in through the organization's identity provider. + * + * The requirement is read when a session is created, so turning it on ends no + * session that already exists — signing everyone out stays the separate action + * under Session policies. Owners keep password sign-in either way, so a broken + * identity provider never locks the organization out of its own settings. + */ +export function RequireSsoSection({ organizationId }: RequireSsoSectionProps) { + const { data, error, isFetching, refetch } = useOrganizationSsoPolicy(organizationId) + const updatePolicy = useUpdateOrganizationSsoPolicy() + const [showEnableConfirm, setShowEnableConfirm] = useState(false) + + if (!data) { + /** A failed read must say so rather than leaving the requirement looking absent. */ + if (error) { + return ( + + void refetch()} + variant='inline' + /> + + ) + } + return ( + + Loading sign-in requirement... + + ) + } + + const save = async (requireSso: boolean) => { + try { + await updatePolicy.mutateAsync({ organizationId, requireSso }) + setShowEnableConfirm(false) + toast.success( + requireSso ? 'Members must now sign in through SSO' : 'Members can sign in with any method' + ) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to update the sign-in requirement')) + } + } + + return ( + <> + + + { + if (value === 'sso-only') { + setShowEnableConfirm(true) + return + } + void save(false) + }} + /** Turning it off stays available: losing the provider must not strand the setting. */ + disabled={updatePolicy.isPending || (!data.hasVerifiedProvider && !data.requireSso)} + aria-label='Allowed sign-in methods' + options={OPTIONS} + /> +

{describePolicy(data)}

+
+
+ + !open && setShowEnableConfirm(false)} + title='Require single sign-on' + text={[ + 'Members will have to sign in through your identity provider from their next sign-in. ', + { text: 'Nobody is signed out', bold: true }, + ', and owners keep password sign-in so you can undo this if the provider breaks.', + ]} + confirm={{ + label: 'Require SSO', + variant: 'primary', + onClick: () => void save(true), + pending: updatePolicy.isPending, + pendingLabel: 'Saving...', + }} + /> + + ) +} diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx index bbd66655693..06583cd0cf3 100644 --- a/apps/sim/ee/sso/components/sso-settings.test.tsx +++ b/apps/sim/ee/sso/components/sso-settings.test.tsx @@ -224,6 +224,13 @@ vi.mock('@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard }), })) +vi.mock('@/ee/sso/hooks/sso-policy', () => ({ + useOrganizationSsoPolicy: () => ({ + data: { requireSso: false, hasVerifiedProvider: true, isEnforced: false }, + }), + useUpdateOrganizationSsoPolicy: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + vi.mock('@/ee/sso/hooks/sso', () => ({ useConfigureSSO: mockUseConfigureSSO, useDeleteSSOProvider: mockUseDeleteSSOProvider, diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index 1eef94d0714..cbaf2af126f 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -12,6 +12,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { ScimSection } from '@/ee/scim/components/scim-section' +import { RequireSsoSection } from '@/ee/sso/components/require-sso-section' import { SsoProviderList } from '@/ee/sso/components/sso-provider-list' import { SsoProviderSettings } from '@/ee/sso/components/sso-provider-settings' import { VerifiedDomainsSection } from '@/ee/sso/components/verified-domains-section' @@ -155,13 +156,18 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { onRetry={() => void providers.refetch()} /> ) : signInView === 'list' ? ( - void setParams({ provider: null, createProvider: true })} - onOpen={(providerId) => void setParams({ provider: providerId, createProvider: null })} - /> +
+ void setParams({ provider: null, createProvider: true })} + onOpen={(providerId) => + void setParams({ provider: providerId, createProvider: null }) + } + /> + +
) : ( { queryClient.invalidateQueries({ queryKey: domainKeys.list(orgId) }) + /** Domain trust decides whether a provider can satisfy the sign-in requirement. */ + queryClient.invalidateQueries({ queryKey: ssoKeys.policy(orgId) }) }, }) } @@ -56,6 +59,7 @@ export function useVerifyOrganizationDomain() { requestJson(verifyOrganizationDomainContract, { params: { id: orgId, domainId } }), onSettled: (_data, _error, { orgId }) => { queryClient.invalidateQueries({ queryKey: domainKeys.list(orgId) }) + queryClient.invalidateQueries({ queryKey: ssoKeys.policy(orgId) }) }, }) } @@ -67,6 +71,7 @@ export function useRemoveOrganizationDomain() { requestJson(removeOrganizationDomainContract, { params: { id: orgId, domainId } }), onSettled: (_data, _error, { orgId }) => { queryClient.invalidateQueries({ queryKey: domainKeys.list(orgId) }) + queryClient.invalidateQueries({ queryKey: ssoKeys.policy(orgId) }) }, }) } diff --git a/apps/sim/ee/sso/hooks/sso-policy.ts b/apps/sim/ee/sso/hooks/sso-policy.ts new file mode 100644 index 00000000000..b20fa0551d3 --- /dev/null +++ b/apps/sim/ee/sso/hooks/sso-policy.ts @@ -0,0 +1,53 @@ +'use client' + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + getOrganizationSsoPolicyContract, + type OrganizationSsoPolicy, + updateOrganizationSsoPolicyContract, +} from '@/lib/api/contracts/organization' +import { ssoKeys } from '@/ee/sso/hooks/sso' + +export const SSO_POLICY_STALE_TIME = 60 * 1000 + +async function fetchSsoPolicy( + organizationId: string, + signal?: AbortSignal +): Promise { + const response = await requestJson(getOrganizationSsoPolicyContract, { + params: { id: organizationId }, + signal, + }) + return response.data +} + +/** Whether members of this organization must sign in through its identity provider. */ +export function useOrganizationSsoPolicy(organizationId?: string) { + return useQuery({ + queryKey: ssoKeys.policy(organizationId), + queryFn: ({ signal }) => fetchSsoPolicy(organizationId as string, signal), + enabled: Boolean(organizationId), + staleTime: SSO_POLICY_STALE_TIME, + }) +} + +interface UpdateSsoPolicyVariables { + organizationId: string + requireSso: boolean +} + +export function useUpdateOrganizationSsoPolicy() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ organizationId, requireSso }: UpdateSsoPolicyVariables) => + requestJson(updateOrganizationSsoPolicyContract, { + params: { id: organizationId }, + body: { requireSso }, + }), + /** Settled, not success: a rejected write usually means the provider state moved underneath. */ + onSettled: (_data, _error, variables) => + queryClient.invalidateQueries({ queryKey: ssoKeys.policy(variables.organizationId) }), + }) +} diff --git a/apps/sim/ee/sso/hooks/sso.ts b/apps/sim/ee/sso/hooks/sso.ts index 6d32b4cca3f..ac758823ab6 100644 --- a/apps/sim/ee/sso/hooks/sso.ts +++ b/apps/sim/ee/sso/hooks/sso.ts @@ -21,6 +21,12 @@ export const ssoKeys = { providers: () => [...ssoKeys.all, 'providers'] as const, providerList: (organizationId?: string) => [...ssoKeys.providers(), organizationId ?? ''] as const, + /** + * Whether members must sign in through the identity provider. Under the same root as the + * providers it depends on, so a provider change invalidates both in one call. + */ + policies: () => [...ssoKeys.all, 'policy'] as const, + policy: (organizationId?: string) => [...ssoKeys.policies(), organizationId ?? ''] as const, } /** @@ -62,7 +68,7 @@ export function useConfigureSSO() { onSettled: (_data, _error, variables) => { /** Awaited, so the caller navigates against a list that already holds the change. */ return Promise.all([ - queryClient.invalidateQueries({ queryKey: ssoKeys.providers() }), + queryClient.invalidateQueries({ queryKey: ssoKeys.all }), queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }), queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }), ]) @@ -77,7 +83,7 @@ export function useDeleteSSOProvider() { return useMutation({ mutationFn: (providerId: string) => requestJson(deleteSsoProviderContract, { params: { providerId } }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ssoKeys.providers() }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ssoKeys.all }), }) } @@ -91,6 +97,6 @@ export function useSetPrimarySSOProvider() { params: { providerId }, body: { isPrimary: true }, }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ssoKeys.providers() }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ssoKeys.all }), }) } diff --git a/apps/sim/ee/workspace-forking/application/revision.postgres.test.ts b/apps/sim/ee/workspace-forking/application/revision.postgres.test.ts new file mode 100644 index 00000000000..dee42d3ad93 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/revision.postgres.test.ts @@ -0,0 +1,180 @@ +/** + * @vitest-environment node + * + * Set FORK_REVISION_TEST_DATABASE_URL to a local PostgreSQL database. Each run uses an + * isolated schema and executes the real revision query against execution-heavy workspaces. + */ +import * as schema from '@sim/db/schema' +import { generateShortId } from '@sim/utils/id' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + assertForkPreviewFresh, + loadForkPreviewRevision, +} from '@/ee/workspace-forking/application/revision' + +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') + +const databaseUrl = process.env.FORK_REVISION_TEST_DATABASE_URL +if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) { + throw new Error('Fork revision PostgreSQL tests require a local database') +} + +describe.runIf(Boolean(databaseUrl))('fork revision scope in PostgreSQL', () => { + const testSchema = `fork_revision_${generateShortId() + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase()}` + let client: ReturnType + let executor: ReturnType> + const scope = { + sourceWorkspaceId: 'source', + targetWorkspaceId: 'target', + edge: { parentWorkspaceId: 'target', childWorkspaceId: 'source' }, + } + + beforeAll(async () => { + client = postgres(databaseUrl!, { max: 1, connection: { search_path: testSchema } }) + executor = drizzle(client, { schema }) + await client.unsafe(`CREATE SCHEMA ${testSchema}`) + await client.unsafe(` + CREATE TABLE workspace ( + id text PRIMARY KEY, organization_id text, name text, + storage_used_bytes bigint DEFAULT 0, updated_at timestamp + ); + CREATE TABLE workflow ( + id text PRIMARY KEY, workspace_id text, name text, archived_at timestamp, + fork_sync_excluded boolean DEFAULT false, is_deployed boolean DEFAULT true, + run_count integer DEFAULT 0, last_run_at timestamp, updated_at timestamp + ); + CREATE TABLE workflow_deployment_version ( + id text PRIMARY KEY, workflow_id text, is_active boolean, state jsonb + ); + CREATE TABLE workspace_files ( + id text PRIMARY KEY, workspace_id text, context text, deleted_at timestamp, + key text, original_name text, size_bytes bigint, content_updated_at timestamp + ); + CREATE INDEX ON workspace_files (workspace_id) + WHERE context = 'workspace' AND deleted_at IS NULL; + CREATE TABLE permissions (id text PRIMARY KEY, entity_id text, entity_type text, permission_type text); + CREATE TABLE custom_block (id text PRIMARY KEY, organization_id text, workflow_id text); + `) + for (const table of ['workflow_blocks', 'workflow_edges', 'workflow_subflows', 'webhook']) { + await client.unsafe( + `CREATE TABLE ${table} (id text PRIMARY KEY, workflow_id text, data jsonb)` + ) + } + for (const table of [ + 'folder', + 'user_table_definitions', + 'knowledge_base', + 'custom_tools', + 'skill', + 'mcp_servers', + 'credential', + 'workspace_environment', + 'workspace_sandbox', + ]) { + await client.unsafe( + `CREATE TABLE ${table} (id text PRIMARY KEY, workspace_id text, data jsonb)` + ) + } + for (const table of [ + 'workspace_fork_resource_map', + 'workspace_fork_block_map', + 'workspace_fork_dependent_value', + ]) { + await client.unsafe( + `CREATE TABLE ${table} (id text PRIMARY KEY, child_workspace_id text, data jsonb)` + ) + } + await client`INSERT INTO workspace (id, name) VALUES ('source', 'Source'), ('target', 'Target')` + await client`INSERT INTO workflow (id, workspace_id, name) + VALUES ('source-workflow', 'source', 'Source workflow'), ('target-workflow', 'target', 'Target workflow')` + await client`INSERT INTO workflow_deployment_version (id, workflow_id, is_active, state) + VALUES ('deployment', 'source-workflow', true, '{"blocks":{}}')` + }) + + afterAll(async () => { + if (!client) return + await client.unsafe(`DROP SCHEMA IF EXISTS ${testSchema} CASCADE`) + await client.end() + }) + + beforeEach(async () => { + await client`TRUNCATE workspace_files, workflow_blocks, permissions` + await client`UPDATE workspace SET storage_used_bytes = 0, updated_at = null` + await client`INSERT INTO workspace_files + (id, workspace_id, context, key, original_name, size_bytes, content_updated_at) + VALUES ('source-file', 'source', 'workspace', 'workspace/source/file', 'file.txt', 24, '2026-09-01'), + ('target-file', 'target', 'workspace', 'workspace/target/file', 'file.txt', 24, '2026-09-01')` + }) + + it('ignores more than 100,000 execution files, deleted files, chat uploads, and runtime storage changes', async () => { + const before = await loadForkPreviewRevision(executor, scope, {}) + await client`INSERT INTO workspace_files (id, workspace_id, context, key, original_name, size_bytes) + SELECT 'execution-' || n, CASE WHEN n % 2 = 0 THEN 'source' ELSE 'target' END, + 'execution', 'execution/' || n, repeat('x', 700), 128 + FROM generate_series(1, 100001) n` + await client`INSERT INTO workspace_files (id, workspace_id, context, deleted_at) + VALUES ('deleted', 'source', 'workspace', now()), ('upload', 'target', 'mothership', null), + ('kb-document', 'source', 'knowledge-base', null), ('other-workspace', 'unrelated', 'workspace', null)` + await client`UPDATE workspace SET storage_used_bytes = 999999, updated_at = now()` + await client`UPDATE workflow SET run_count = run_count + 1, last_run_at = now(), updated_at = now()` + + const after = await loadForkPreviewRevision(executor, scope, {}) + expect(after).toEqual(before) + await expect( + assertForkPreviewFresh(executor, scope, { + workspaceId: 'source', + requestId: 'request', + requestHash: 'hash', + previewFingerprint: before.fingerprint, + choices: {}, + }) + ).resolves.toBeUndefined() + }) + + it.each(['source', 'target'])( + 'invalidates previews when an active %s file changes or disappears', + async (workspaceId) => { + const before = await loadForkPreviewRevision(executor, scope, {}) + await client`UPDATE workspace_files SET content_updated_at = '2026-09-02' WHERE workspace_id = ${workspaceId}` + const edited = await loadForkPreviewRevision(executor, scope, {}) + expect(edited.categories.files).not.toBe(before.categories.files) + + await client`UPDATE workspace_files SET deleted_at = now() WHERE workspace_id = ${workspaceId}` + const deleted = await loadForkPreviewRevision(executor, scope, {}) + expect(deleted.categories.files).not.toBe(edited.categories.files) + + await client`UPDATE workspace_files SET deleted_at = null WHERE workspace_id = ${workspaceId}` + expect((await loadForkPreviewRevision(executor, scope, {})).fingerprint).toBe( + edited.fingerprint + ) + } + ) + + it('still detects graph edits, access changes, and changed copy choices', async () => { + const before = await loadForkPreviewRevision(executor, scope, {}) + await client`INSERT INTO workflow_blocks (id, workflow_id, data) + VALUES ('block', 'target-workflow', '{"value":"changed"}')` + await client`INSERT INTO permissions (id, entity_id, entity_type, permission_type) + VALUES ('member', 'source', 'workspace', 'admin')` + const after = await loadForkPreviewRevision(executor, scope, {}) + expect(after.categories.target_graph).not.toBe(before.categories.target_graph) + expect(after.categories.membership).not.toBe(before.categories.membership) + expect( + (await loadForkPreviewRevision(executor, scope, { copyResources: [] })).fingerprint + ).not.toBe(after.fingerprint) + }) + + it('retains the row limit for actual fork resources', async () => { + await client`INSERT INTO workspace_files (id, workspace_id, context) + SELECT 'durable-' || n, 'source', 'workspace' FROM generate_series(1, 100001) n` + await expect(loadForkPreviewRevision(executor, scope, {})).rejects.toMatchObject({ + statusCode: 413, + message: 'Fork preview files exceeds its 100000 row ceiling', + }) + }) +}) diff --git a/apps/sim/ee/workspace-forking/application/revision.test.ts b/apps/sim/ee/workspace-forking/application/revision.test.ts new file mode 100644 index 00000000000..bfd8b911ec5 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/revision.test.ts @@ -0,0 +1,111 @@ +/** @vitest-environment node */ +import type { SQL } from 'drizzle-orm' +import { PgDialect } from 'drizzle-orm/pg-core' +import { describe, expect, it, vi } from 'vitest' +import type { DbOrTx } from '@/lib/db/types' +import { WorkspaceOperationConflict } from '@/lib/workspaces/operations/receipts' +import { + assertForkPreviewFresh, + loadForkPreviewRevision, +} from '@/ee/workspace-forking/application/revision' + +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') + +const scope = { + sourceWorkspaceId: 'source', + targetWorkspaceId: 'target', + edge: { parentWorkspaceId: 'target', childWorkspaceId: 'source' }, +} + +function mockRevisionExecutor(overrides: { count?: string; bytes?: string; digest?: string } = {}) { + const execute = vi.fn(async (_query: SQL) => [ + { category: 'files', count: '3', bytes: '1024', digest: 'file-revision', ...overrides }, + ]) + return { execute, executor: { execute } as unknown as DbOrTx } +} + +describe('fork preview revisions', () => { + it('reads every category in one bounded query and excludes files the sync cannot copy', async () => { + const { execute, executor } = mockRevisionExecutor() + await loadForkPreviewRevision(executor, scope, {}) + + expect(execute).toHaveBeenCalledTimes(1) + const query = new PgDialect().sqlToQuery(execute.mock.calls[0][0]) + expect(query.sql).toMatch(/"workspace_files"\."context" = \$\d+/) + expect(query.sql).toContain('"workspace_files"."deleted_at" is null') + expect(query.params).toContain('workspace') + expect(query.sql).toContain("ARRAY['updated_at', 'storage_used_bytes']") + expect(query.sql.match(/LIMIT \$\d+/g)).toHaveLength(20) + expect(query.params.filter((value) => value === 100_001)).toHaveLength(20) + expect(query.params).toContain('source') + expect(query.params).toContain('target') + expect(query.params).toContain('mappings') + expect(query.params).toContain('block_identities') + expect(query.params).toContain('dependent_values') + expect(query.params.some(Array.isArray)).toBe(false) + }) + + it('supports creating a fork without a target or existing edge', async () => { + const { execute, executor } = mockRevisionExecutor() + await loadForkPreviewRevision(executor, { sourceWorkspaceId: 'source' }, {}) + + const query = new PgDialect().sqlToQuery(execute.mock.calls[0][0]) + expect(query.sql.match(/LIMIT \$\d+/g)).toHaveLength(17) + expect(query.params).not.toContain('target') + expect(query.params).not.toContain('mappings') + expect(query.params.some((value) => value == null)).toBe(false) + }) + + it.each([ + { count: '100001', bytes: '1', message: 'Fork preview files exceeds its 100000 row ceiling' }, + { + count: '1', + bytes: String(64 * 1024 * 1024 + 1), + message: 'Fork preview files exceeds its 64 MiB byte ceiling', + }, + ])('rejects an oversized category with the actual limiting budget: $message', async (row) => { + const { executor } = mockRevisionExecutor(row) + await expect(loadForkPreviewRevision(executor, scope, {})).rejects.toMatchObject({ + statusCode: 413, + message: row.message, + }) + }) + + it('accepts categories exactly at both limits', async () => { + const { executor } = mockRevisionExecutor({ count: '100000', bytes: String(64 * 1024 * 1024) }) + await expect(loadForkPreviewRevision(executor, scope, {})).resolves.toMatchObject({ + categories: { files: 'file-revision' }, + }) + }) + + it('keeps scope and copy choices bound to the fingerprint', async () => { + const { executor } = mockRevisionExecutor() + const original = await loadForkPreviewRevision(executor, scope, {}) + const changedScope = await loadForkPreviewRevision( + executor, + { ...scope, targetWorkspaceId: 'other-target' }, + {} + ) + const changedChoices = await loadForkPreviewRevision(executor, scope, { copyResources: [] }) + expect(changedScope.fingerprint).not.toBe(original.fingerprint) + expect(changedChoices.fingerprint).not.toBe(original.fingerprint) + }) + + it('still refuses apply when a reviewed resource changes', async () => { + const { executor, execute } = mockRevisionExecutor() + const preview = await loadForkPreviewRevision(executor, scope, {}) + const admission = { + workspaceId: 'source', + requestId: 'request', + requestHash: 'request-hash', + previewFingerprint: preview.fingerprint, + choices: {}, + } + await expect(assertForkPreviewFresh(executor, scope, admission)).resolves.toBeUndefined() + execute.mockResolvedValue([{ category: 'files', count: '3', bytes: '1024', digest: 'changed' }]) + await expect(assertForkPreviewFresh(executor, scope, admission)).rejects.toBeInstanceOf( + WorkspaceOperationConflict + ) + }) +}) diff --git a/apps/sim/ee/workspace-forking/application/revision.ts b/apps/sim/ee/workspace-forking/application/revision.ts index 2bb20288ae4..3c27d3c592e 100644 --- a/apps/sim/ee/workspace-forking/application/revision.ts +++ b/apps/sim/ee/workspace-forking/application/revision.ts @@ -22,9 +22,10 @@ import { workspaceForkResourceMap, workspaceSandbox, } from '@sim/db/schema' -import { type SQL, sql } from 'drizzle-orm' +import { and, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import { acquireFolderMutationLock } from '@/lib/folders/locks' +import { activeWorkspaceFileConditions } from '@/lib/workspace-files/query-scope' import { WorkspaceOperationConflict, workflowOperationFingerprint, @@ -46,7 +47,14 @@ export interface ForkMutationAdmission { choices: Record } -/** Digests are bounded database aggregates; graph and secret values never enter preview diagnostics. */ +const MAX_REVISION_ROWS = 100_000 +const MAX_REVISION_BYTES = 64 * 1024 * 1024 + +/** + * Fingerprints fork configuration in one database snapshot. Runtime file outputs and their + * storage ledger are not sync inputs; including them makes ordinary executions invalidate + * previews. Only bounded aggregates leave the database, never graph or secret values. + */ export async function loadForkPreviewRevision( executor: DbOrTx, scope: ForkRevisionScope, @@ -64,7 +72,7 @@ export async function loadForkPreviewRevision( ) const workflowIds = sql`SELECT id FROM ${workflow} WHERE workspace_id IN (${values})` const queries: Record = { - workspaces: sql`SELECT id, to_jsonb(r) - ARRAY['updated_at'] AS state FROM ${workspace} r WHERE id IN (${values})`, + workspaces: sql`SELECT id, to_jsonb(r) - ARRAY['updated_at', 'storage_used_bytes'] AS state FROM ${workspace} r WHERE id IN (${values})`, workflows: sql`SELECT id, to_jsonb(r) - ARRAY['run_count', 'last_run_at', 'last_synced', 'updated_at'] AS state FROM ${workflow} r WHERE workspace_id IN (${values})`, source_deployments: sql`SELECT d.id, to_jsonb(d) AS state FROM ${workflowDeploymentVersion} d JOIN ${workflow} w ON w.id = d.workflow_id WHERE w.workspace_id = ${scope.sourceWorkspaceId} AND d.is_active = true AND w.archived_at IS NULL AND w.fork_sync_excluded = false`, target_graph: sql`SELECT 'block:' || b.id AS id, to_jsonb(b) - ARRAY['updated_at', 'created_at'] AS state FROM ${workflowBlocks} b JOIN ${workflow} w ON w.id = b.workflow_id WHERE w.workspace_id = ${scope.targetWorkspaceId ?? scope.sourceWorkspaceId} @@ -78,7 +86,7 @@ export async function loadForkPreviewRevision( tools: sql`SELECT id, to_jsonb(r) AS state FROM ${customTools} r WHERE workspace_id IN (${values})`, skills: sql`SELECT id, to_jsonb(r) AS state FROM ${skill} r WHERE workspace_id IN (${values})`, servers: sql`SELECT id, to_jsonb(r) - ARRAY['updated_at', 'last_connected_at', 'last_tools_refresh', 'tool_count', 'connection_status', 'last_error'] AS state FROM ${mcpServers} r WHERE workspace_id IN (${values})`, - files: sql`SELECT id, to_jsonb(r) AS state FROM ${workspaceFiles} r WHERE workspace_id IN (${values})`, + files: sql`SELECT id, to_jsonb(${workspaceFiles}) AS state FROM ${workspaceFiles} WHERE ${and(...activeWorkspaceFileConditions(ids))}`, credentials: sql`SELECT id, to_jsonb(r) - ARRAY['updated_at', 'last_used_at'] AS state FROM ${credential} r WHERE workspace_id IN (${values})`, secrets: sql`SELECT id, to_jsonb(r) - 'updated_at' AS state FROM ${workspaceEnvironment} r WHERE workspace_id IN (${values})`, sandboxes: sql`SELECT id, to_jsonb(r) AS state FROM ${workspaceSandbox} r WHERE workspace_id IN (${values})`, @@ -89,17 +97,34 @@ export async function loadForkPreviewRevision( queries.block_identities = sql`SELECT id, to_jsonb(r) AS state FROM ${workspaceForkBlockMap} r WHERE child_workspace_id = ${scope.edge.childWorkspaceId}` queries.dependent_values = sql`SELECT id, to_jsonb(r) AS state FROM ${workspaceForkDependentValue} r WHERE child_workspace_id = ${scope.edge.childWorkspaceId}` } - const categories: Record = {} - for (const [category, rows] of Object.entries(queries)) { - const [size] = await executor.execute<{ count: string; bytes: string }>( - sql`SELECT count(*)::text AS count, coalesce(sum(octet_length(state::text)), 0)::text AS bytes FROM (${rows}) revision_rows` - ) - if (Number(size.count) > 100000 || Number(size.bytes) > 64 * 1024 * 1024) - throw new ForkError(`Fork preview ${category} exceeds its row or 64 MiB byte ceiling`, 413) - const [revision] = await executor.execute<{ digest: string }>( - sql`SELECT md5(coalesce(string_agg(md5(state::text), '' ORDER BY id), '')) AS digest FROM (${rows}) revision_rows` + const revisions = await executor.execute<{ + category: string + count: string + bytes: string + digest: string + }>( + sql.join( + Object.entries(queries).map( + ([category, rows]) => sql` + SELECT ${category}::text AS category, count(*)::text AS count, + coalesce(sum(octet_length(state::text)), 0)::text AS bytes, + md5(coalesce(string_agg(md5(state::text), '' ORDER BY id), '')) AS digest + FROM (SELECT id, state FROM (${rows}) revision_source LIMIT ${MAX_REVISION_ROWS + 1}) revision_rows + ` + ), + sql` UNION ALL ` ) - categories[category] = revision.digest + ) + const categories: Record = {} + for (const revision of revisions) { + if (Number(revision.count) > MAX_REVISION_ROWS) + throw new ForkError( + `Fork preview ${revision.category} exceeds its ${MAX_REVISION_ROWS} row ceiling`, + 413 + ) + if (Number(revision.bytes) > MAX_REVISION_BYTES) + throw new ForkError(`Fork preview ${revision.category} exceeds its 64 MiB byte ceiling`, 413) + categories[revision.category] = revision.digest } return { categories, diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts index b2fc0597106..4817ce987b9 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts @@ -1,6 +1,5 @@ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' -import { workspaceFileColumns, workspaceFiles } from '@sim/db/schema' +import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -23,6 +22,7 @@ import { } from '@/lib/uploads/core/storage-service' import { getWorkspaceFileSize, type StorageContext } from '@/lib/uploads/shared/types' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { activeWorkspaceFileConditions } from '@/lib/workspace-files/query-scope' import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows' import { assertForkCopyActive, @@ -197,14 +197,12 @@ export async function planForkFileCopies(params: { selectors.length === 0 ? [] : await tx - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( and( selectors.length === 1 ? selectors[0] : or(...selectors), - eq(workspaceFiles.workspaceId, sourceWorkspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) + ...activeWorkspaceFileConditions([sourceWorkspaceId]) ) ) @@ -363,7 +361,7 @@ export async function executeForkFileBlobCopies( await db.transaction(async (tx) => { assertForkCopyActive(control) const [inserted] = await tx - .insert(withInsertColumns(workspaceFiles, workspaceFileColumns)) + .insert(workspaceFiles) .values({ id: task.targetFileId, key: task.targetKey, diff --git a/apps/sim/executor/handlers/credential/credential-handler.test.ts b/apps/sim/executor/handlers/credential/credential-handler.test.ts index 417616009af..3b9bcf0951e 100644 --- a/apps/sim/executor/handlers/credential/credential-handler.test.ts +++ b/apps/sim/executor/handlers/credential/credential-handler.test.ts @@ -42,7 +42,7 @@ describe('Credential organization operations', () => { vi.clearAllMocks() mocks.principal.mockResolvedValue({ delegationId: 'current-run' }) mocks.oauth.mockResolvedValue({ - credentials: [account], + credentials: [{ ...account, accountEmail: 'personal@example.com' }], count: 1, hasMore: false, nextCursor: null, @@ -103,6 +103,85 @@ describe('Credential organization operations', () => { }) ) }) + it('discovers provider emails across pages without requiring an enrollment email', async () => { + const accounts = [ + { ...account, accountEmail: 'first@example.com' }, + { ...account, credentialId: 'credential-2', accountEmail: 'second@example.com' }, + { + ...account, + credentialId: 'credential-3', + email: 'colleague@example.com', + accountEmail: 'third@example.com', + }, + ] + mocks.oauth + .mockResolvedValueOnce({ + credentials: accounts.slice(0, 2), + count: 2, + hasMore: true, + nextCursor: 'credential-2', + }) + .mockResolvedValueOnce({ + credentials: accounts.slice(2), + count: 1, + hasMore: false, + nextCursor: null, + }) + const input = { + operation: 'list_organization_accounts', + organizationProviders: ['google-email'], + limit: 2, + } + const first = await handler.execute(ctx, block, input) + const second = await handler.execute(ctx, block, { ...input, cursor: first.nextCursor }) + expect(first).toMatchObject({ + credentials: accounts.slice(0, 2), + emails: ['first@example.com', 'second@example.com'], + count: 2, + hasMore: true, + }) + expect(second).toMatchObject({ + credentials: accounts.slice(2), + emails: ['third@example.com'], + hasMore: false, + nextCursor: null, + }) + expect(mocks.oauth).toHaveBeenLastCalledWith({ + principal: { delegationId: 'current-run' }, + input: { + workspaceId: 'child-workspace', + email: undefined, + credentialProviderIds: ['google-email'], + limit: 2, + cursor: 'credential-2', + }, + }) + }) + it('preserves the optional exact enrollment-email filter for organization lists', async () => { + await handler.execute(ctx, block, { + operation: 'list_organization_accounts', + organizationProviders: ['google-email'], + email: 'person@example.com', + }) + expect(mocks.oauth).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ email: 'person@example.com' }) }) + ) + }) + it('returns empty email and account arrays when no accessible accounts match', async () => { + mocks.oauth.mockResolvedValue({ credentials: [], count: 0, hasMore: false, nextCursor: null }) + await expect( + handler.execute(ctx, block, { + operation: 'list_organization_accounts', + organizationProviders: ['google-email'], + }) + ).resolves.toEqual({ + credentials: [], + emails: [], + count: 0, + hasMore: false, + nextCursor: null, + }) + }) it('returns the person’s MCP credential separately from the shared server', async () => { const connection = { credentialId: 'mcp-cg-person', diff --git a/apps/sim/executor/handlers/credential/credential-handler.ts b/apps/sim/executor/handlers/credential/credential-handler.ts index f570e00aa73..ac7b0f888a9 100644 --- a/apps/sim/executor/handlers/credential/credential-handler.ts +++ b/apps/sim/executor/handlers/credential/credential-handler.ts @@ -1,3 +1,4 @@ +import { omit } from '@sim/utils/object' import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization' import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' import { listCredentialGroupMcpConnections } from '@/lib/credential-groups/application/list-mcp-connections' @@ -114,12 +115,14 @@ export class CredentialBlockHandler implements BlockHandler { cursor: find ? undefined : parseOptionalString(inputs.cursor, 'Cursor'), }, }) - if (!find) return result + if (!find) { + return { ...result, emails: result.credentials.map((account) => account.accountEmail) } + } if (result.credentials.length !== 1 || result.hasMore) throw new Error( `Expected exactly one organization account; found ${result.credentials.length}${result.hasMore ? '+' : ''}. Check the email, provider, and connection status.` ) - return result.credentials[0]! + return omit(result.credentials[0]!, ['accountEmail']) } case 'find_organization_mcp_connection': case 'list_organization_mcp_connections': { diff --git a/apps/sim/hooks/queries/access-requests.test.tsx b/apps/sim/hooks/queries/access-requests.test.tsx new file mode 100644 index 00000000000..21d97bf12b7 --- /dev/null +++ b/apps/sim/hooks/queries/access-requests.test.tsx @@ -0,0 +1,321 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { requestJson } = vi.hoisted(() => ({ requestJson: vi.fn() })) +vi.mock('@/lib/api/client/request', () => ({ requestJson })) + +import { + discoverAccessRequestsContract, + listMyAccessRequestsContract, + resolveAccessRequestContract, +} from '@/lib/api/contracts/access-requests' +import { + useDiscoverAccessRequests, + useMyAccessRequests, + useResolveAccessRequest, +} from '@/hooks/queries/access-requests' +import { accessRequestKeys } from '@/hooks/queries/utils/access-request-keys' +import { organizationKeys } from '@/hooks/queries/utils/organization-keys' +import { workspaceUsageKeys } from '@/hooks/queries/utils/workspace-usage-keys' + +describe('access request query lifecycle', () => { + let container: HTMLDivElement + let root: Root + let client: QueryClient + + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + }) + + afterEach(() => { + act(() => root.unmount()) + client.clear() + container.remove() + }) + + it('refreshes stale policy after discovery sees an external administrator update', async () => { + const policyKey = ['permissionGroups', 'userConfig', 'workspace-1'] + client.setQueryData( + policyKey, + { config: { hideTablesTab: true } }, + { updatedAt: Date.now() - 60_001 } + ) + requestJson.mockResolvedValue({ enabled: true, entries: [], hasMore: false, total: 0 }) + const fetchPolicy = vi.fn().mockResolvedValue({ config: { hideTablesTab: false } }) + function Probe() { + useQuery({ + queryKey: policyKey, + queryFn: fetchPolicy, + staleTime: 60_000, + refetchOnMount: false, + }) + useDiscoverAccessRequests({ + kind: 'workspace', + workspaceId: 'workspace-1', + targetKind: 'usage_limit', + limit: 1, + offset: 0, + }) + useDiscoverAccessRequests({ + kind: 'workspace', + workspaceId: 'workspace-1', + targetKind: 'feature', + limit: 100, + offset: 0, + }) + return null + } + await act(async () => { + root.render( + + + + ) + }) + expect(requestJson).toHaveBeenCalledWith(discoverAccessRequestsContract, { + query: { + kind: 'workspace', + workspaceId: 'workspace-1', + targetKind: 'feature', + limit: 100, + offset: 0, + }, + signal: expect.any(AbortSignal), + }) + expect(fetchPolicy).toHaveBeenCalledOnce() + expect(client.getQueryData(policyKey)).toEqual({ config: { hideTablesTab: false } }) + }) + + it('respects fresh policy and leaves unrelated usage gates alone', async () => { + const policyKey = ['permissionGroups', 'userConfig', 'workspace-1'] + const usageKey = workspaceUsageKeys.gate('workspace-1') + client.setQueryData(policyKey, { config: { hideTablesTab: true } }) + client.setQueryData( + usageKey, + { scope: 'payer', isExceeded: true }, + { updatedAt: Date.now() - 60_001 } + ) + const fetchPolicy = vi.fn() + const fetchUsage = vi.fn() + requestJson.mockResolvedValue({ enabled: true, entries: [], total: 0, hasMore: false }) + function Probe() { + useQuery({ + queryKey: policyKey, + queryFn: fetchPolicy, + staleTime: 60_000, + refetchOnMount: false, + }) + useQuery({ + queryKey: usageKey, + queryFn: fetchUsage, + staleTime: 30_000, + refetchOnMount: false, + }) + useDiscoverAccessRequests({ + kind: 'workspace', + workspaceId: 'workspace-1', + targetKind: 'feature', + }) + return null + } + await act(async () => { + root.render( + + + + ) + }) + expect(fetchPolicy).not.toHaveBeenCalled() + expect(fetchUsage).not.toHaveBeenCalled() + }) + + it('refreshes an exceeded member cap without needing a permission-config observer', async () => { + const usageKey = workspaceUsageKeys.gate('workspace-1') + client.setQueryData( + usageKey, + { scope: 'member', isExceeded: true }, + { updatedAt: Date.now() - 30_001 } + ) + const fetchUsage = vi.fn().mockResolvedValue({ scope: null, isExceeded: false }) + requestJson.mockResolvedValue({ enabled: true, entries: [], total: 0, hasMore: false }) + function Probe() { + useQuery({ + queryKey: usageKey, + queryFn: fetchUsage, + staleTime: 30_000, + refetchOnMount: false, + }) + useDiscoverAccessRequests({ + kind: 'workspace', + workspaceId: 'workspace-1', + targetKind: 'usage_limit', + }) + return null + } + await act(async () => { + root.render( + + + + ) + }) + expect(fetchUsage).toHaveBeenCalledOnce() + expect(client.getQueryData(usageKey)).toEqual({ scope: null, isExceeded: false }) + }) + + it('refreshes stale member credits without a usage-gate observer', async () => { + const key = workspaceUsageKeys.creditAvailability('workspace-1') + client.setQueryData( + key, + { scope: 'member', remainingDollars: 0 }, + { updatedAt: Date.now() - 30_001 } + ) + const fetchCredits = vi.fn().mockResolvedValue({ scope: 'member', remainingDollars: 25 }) + requestJson.mockResolvedValue({ enabled: true, entries: [], total: 0, hasMore: false }) + function Probe() { + useQuery({ queryKey: key, queryFn: fetchCredits, staleTime: 30_000, refetchOnMount: false }) + useDiscoverAccessRequests({ + kind: 'workspace', + workspaceId: 'workspace-1', + targetKind: 'usage_limit', + }) + return null + } + await act(async () => { + root.render( + + + + ) + }) + expect(fetchCredits).toHaveBeenCalledOnce() + expect(client.getQueryData(key)).toEqual({ scope: 'member', remainingDollars: 25 }) + }) + + it('refreshes both balance and usage-gate families after a cap is fulfilled', async () => { + let resolve: ReturnType + const creditKey = workspaceUsageKeys.creditAvailability('workspace-1') + const gateKey = workspaceUsageKeys.gate('workspace-1') + const memberLimitKey = organizationKeys.memberUsageLimit('org-1', 'member-1') + const otherMemberLimitKey = organizationKeys.memberUsageLimit('org-1', 'member-2') + client.setQueryData(creditKey, { remainingDollars: 0 }) + client.setQueryData(gateKey, { isExceeded: true }) + client.setQueryData(memberLimitKey, { usageLimit: 10 }) + client.setQueryData(otherMemberLimitKey, { usageLimit: 20 }) + requestJson.mockResolvedValue({ + request: { + status: 'fulfilled', + target: { kind: 'usage_limit', id: 'member' }, + organizationId: 'org-1', + requester: { id: 'member-1' }, + }, + }) + function Probe() { + resolve = useResolveAccessRequest() + return null + } + await act(async () => { + root.render( + + + + ) + }) + await act(async () => { + await resolve!.mutateAsync({ + organizationId: 'org-1', + requestId: 'request-1', + body: { action: 'apply', expectedFingerprint: 'current', newLimitCredits: 100 }, + }) + }) + expect(client.getQueryState(creditKey)?.isInvalidated).toBe(true) + expect(client.getQueryState(gateKey)?.isInvalidated).toBe(true) + expect(client.getQueryState(memberLimitKey)?.isInvalidated).toBe(true) + expect(client.getQueryState(otherMemberLimitKey)?.isInvalidated).toBe(false) + }) + + it('does not fetch history while its view is inactive', async () => { + function Probe() { + useMyAccessRequests({ kind: 'workspace', workspaceId: 'workspace-1' }, 0, undefined, false) + return null + } + await act(async () => { + root.render( + + + + ) + }) + expect(requestJson).not.toHaveBeenCalled() + }) + + it.each([ + { kind: 'workspace', workspaceId: 'workspace-1' } as const, + { kind: 'organization', organizationId: 'org-1' } as const, + ])('loads an exact requester deep link within its authorized $kind scope', async (scope) => { + requestJson.mockResolvedValue({ requests: [], hasMore: false, total: 0 }) + function Probe() { + useMyAccessRequests(scope, 0, 'request-1') + return null + } + await act(async () => { + root.render( + + + + ) + }) + expect(requestJson).toHaveBeenCalledWith(listMyAccessRequestsContract, { + query: { ...scope, offset: 0, limit: 25, requestId: 'request-1' }, + signal: expect.any(AbortSignal), + }) + expect(accessRequestKeys.mine(scope, 0, 'request-1')).not.toEqual( + accessRequestKeys.mine(scope, 0) + ) + }) + + it('invalidates a conflicted preview without granting access optimistically', async () => { + let resolve: ReturnType + const previewKey = accessRequestKeys.preview('org-1', 'request-1') + client.setQueryData(previewKey, { fingerprint: 'old-preview', canApply: true }) + requestJson.mockRejectedValue(new Error('The policy changed. Review the current preview.')) + function Probe() { + resolve = useResolveAccessRequest() + return null + } + await act(async () => { + root.render( + + + + ) + }) + await act(async () => { + await expect( + resolve!.mutateAsync({ + organizationId: 'org-1', + requestId: 'request-1', + body: { action: 'apply', expectedFingerprint: 'old-preview' }, + }) + ).rejects.toThrow('The policy changed') + }) + expect(requestJson).toHaveBeenCalledWith(resolveAccessRequestContract, { + params: { id: 'org-1', requestId: 'request-1' }, + body: { action: 'apply', expectedFingerprint: 'old-preview' }, + }) + expect(client.getQueryState(previewKey)?.isInvalidated).toBe(true) + expect(client.getQueryData(previewKey)).toEqual({ fingerprint: 'old-preview', canApply: true }) + }) +}) diff --git a/apps/sim/hooks/queries/access-requests.ts b/apps/sim/hooks/queries/access-requests.ts new file mode 100644 index 00000000000..2c1b6d3a38a --- /dev/null +++ b/apps/sim/hooks/queries/access-requests.ts @@ -0,0 +1,230 @@ +'use client' + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type AccessRequestScope, + type AccessRequestStatus, + type CreateAccessRequestBody, + cancelAccessRequestContract, + createAccessRequestContract, + type DiscoverAccessRequestsQuery, + discoverAccessRequestsContract, + getAccessRequestSettingsContract, + listMyAccessRequestsContract, + listOrganizationAccessRequestsContract, + previewAccessRequestContract, + type ResolveAccessRequestBody, + resolveAccessRequestContract, + updateAccessRequestSettingsContract, +} from '@/lib/api/contracts/access-requests' +import type { + WorkspaceCreditAvailability, + WorkspaceUsageGate, +} from '@/lib/api/contracts/workspaces' +import { ACCESS_REQUEST_LIST_PAGE_SIZE } from '@/lib/permission-access-requests/constants' +import { + ACCESS_REQUESTS_STALE_TIME, + accessRequestKeys, +} from '@/hooks/queries/utils/access-request-keys' +import { invalidateWorkspaceUsage } from '@/hooks/queries/utils/invalidate-usage' +import { organizationKeys } from '@/hooks/queries/utils/organization-keys' +import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' +import { workspaceUsageKeys } from '@/hooks/queries/utils/workspace-usage-keys' + +export const ACCESS_REQUESTS_POLL_INTERVAL = 30_000 +export const ACCESS_REQUEST_PAGE_SIZE = ACCESS_REQUEST_LIST_PAGE_SIZE + +export function useDiscoverAccessRequests(query: DiscoverAccessRequestsQuery, enabled = true) { + const queryClient = useQueryClient() + return useQuery({ + queryKey: accessRequestKeys.discovery(query), + queryFn: async ({ signal }) => { + const result = await requestJson(discoverAccessRequestsContract, { query, signal }) + if (result.enabled && query.kind === 'workspace') { + void queryClient.refetchQueries( + { + queryKey: permissionGroupKeys.userConfig(query.workspaceId), + exact: true, + type: 'active', + stale: true, + }, + { cancelRefetch: false } + ) + const usageKey = workspaceUsageKeys.gate(query.workspaceId) + const usage = queryClient.getQueryData(usageKey) + if (usage?.scope === 'member' && usage.isExceeded) { + void queryClient.refetchQueries( + { queryKey: usageKey, exact: true, type: 'active', stale: true }, + { cancelRefetch: false } + ) + } + const creditKey = workspaceUsageKeys.creditAvailability(query.workspaceId) + const credit = queryClient.getQueryData(creditKey) + if (credit?.scope === 'member') { + void queryClient.refetchQueries( + { queryKey: creditKey, exact: true, type: 'active', stale: true }, + { cancelRefetch: false } + ) + } + } + return result + }, + enabled: + Boolean(query.kind === 'workspace' ? query.workspaceId : query.organizationId) && enabled, + staleTime: ACCESS_REQUESTS_STALE_TIME, + refetchInterval: (query) => + query.state.data?.enabled === false ? false : ACCESS_REQUESTS_POLL_INTERVAL, + }) +} + +export function useMyAccessRequests( + scope: AccessRequestScope, + offset = 0, + requestId?: string, + enabled = true +) { + return useQuery({ + queryKey: accessRequestKeys.mine(scope, offset, requestId), + queryFn: ({ signal }) => + requestJson(listMyAccessRequestsContract, { + query: { + ...scope, + offset, + limit: ACCESS_REQUEST_PAGE_SIZE, + ...(requestId ? { requestId } : {}), + }, + signal, + }), + enabled: + Boolean(scope.kind === 'workspace' ? scope.workspaceId : scope.organizationId) && enabled, + staleTime: ACCESS_REQUESTS_STALE_TIME, + refetchInterval: ACCESS_REQUESTS_POLL_INTERVAL, + }) +} + +export function useOrganizationAccessRequests( + organizationId: string, + offset = 0, + status: AccessRequestStatus | 'all' = 'pending' +) { + return useQuery({ + queryKey: accessRequestKeys.organization(organizationId, offset, status), + queryFn: ({ signal }) => + requestJson(listOrganizationAccessRequestsContract, { + params: { id: organizationId }, + query: { offset, limit: ACCESS_REQUEST_PAGE_SIZE, ...(status === 'all' ? {} : { status }) }, + signal, + }), + enabled: Boolean(organizationId), + staleTime: ACCESS_REQUESTS_STALE_TIME, + refetchInterval: ACCESS_REQUESTS_POLL_INTERVAL, + }) +} + +export function useAccessRequestPreview(organizationId: string, requestId: string) { + return useQuery({ + queryKey: accessRequestKeys.preview(organizationId, requestId), + queryFn: ({ signal }) => + requestJson(previewAccessRequestContract, { + params: { id: organizationId, requestId }, + signal, + }), + enabled: Boolean(organizationId && requestId), + staleTime: ACCESS_REQUESTS_STALE_TIME, + }) +} + +export function useCreateAccessRequest() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: CreateAccessRequestBody) => + requestJson(createAccessRequestContract, { body }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: accessRequestKeys.lists() }) + void queryClient.invalidateQueries({ queryKey: accessRequestKeys.discoveries() }) + }, + }) +} + +interface CancelAccessRequestVariables { + scope: AccessRequestScope + requestId: string +} + +export function useCancelAccessRequest() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ scope, requestId }: CancelAccessRequestVariables) => + requestJson(cancelAccessRequestContract, { params: { requestId }, body: { scope } }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: accessRequestKeys.lists() }) + void queryClient.invalidateQueries({ queryKey: accessRequestKeys.discoveries() }) + void queryClient.invalidateQueries({ queryKey: accessRequestKeys.details() }) + }, + }) +} + +interface ResolveAccessRequestVariables { + organizationId: string + requestId: string + body: ResolveAccessRequestBody +} + +export function useResolveAccessRequest() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ organizationId, requestId, body }: ResolveAccessRequestVariables) => + requestJson(resolveAccessRequestContract, { + params: { id: organizationId, requestId }, + body, + }), + onError: (_error, { organizationId, requestId }) => { + void queryClient.invalidateQueries({ + queryKey: accessRequestKeys.preview(organizationId, requestId), + }) + }, + onSuccess: ({ request }) => { + void queryClient.invalidateQueries({ queryKey: accessRequestKeys.lists() }) + void queryClient.invalidateQueries({ queryKey: accessRequestKeys.details() }) + void queryClient.invalidateQueries({ queryKey: accessRequestKeys.discoveries() }) + if (request.status !== 'fulfilled') return + if (request.target.kind === 'usage_limit') { + void invalidateWorkspaceUsage(queryClient) + void queryClient.invalidateQueries({ + queryKey: organizationKeys.memberUsageLimit(request.organizationId, request.requester.id), + }) + } else { + void queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all }) + } + }, + }) +} + +export function useAccessRequestSettings(organizationId: string) { + return useQuery({ + queryKey: accessRequestKeys.settings(organizationId), + queryFn: ({ signal }) => + requestJson(getAccessRequestSettingsContract, { params: { id: organizationId }, signal }), + enabled: Boolean(organizationId), + staleTime: ACCESS_REQUESTS_STALE_TIME, + }) +} + +export function useUpdateAccessRequestSettings(organizationId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (allowRequests: boolean) => + requestJson(updateAccessRequestSettingsContract, { + params: { id: organizationId }, + body: { allowRequests }, + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: accessRequestKeys.settings(organizationId) }) + void queryClient.invalidateQueries({ queryKey: accessRequestKeys.discoveries() }) + void queryClient.invalidateQueries({ + queryKey: accessRequestKeys.organizationDetails(organizationId), + }) + }, + }) +} diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 33b28996de2..49cc718dfc6 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -85,7 +85,8 @@ export interface WorkspaceInvitation { isPendingInvitation: boolean isExternal: boolean invitationId?: string - token: string + /** Absent unless the viewer may manage the workspace; the copy-link action is gated on it. */ + token?: string } async function fetchPendingInvitations( @@ -96,6 +97,7 @@ async function fetchPendingInvitations( return ( data.invitations + /** The server returns pending rows only; the status check stays as a cheap contract guard. */ ?.filter( (inv: PendingInvitationRow) => inv.status === 'pending' && inv.workspaceId === workspaceId ) diff --git a/apps/sim/hooks/queries/kb/connectors.test.ts b/apps/sim/hooks/queries/kb/connectors.test.ts index ae98455db5a..e65012d3802 100644 --- a/apps/sim/hooks/queries/kb/connectors.test.ts +++ b/apps/sim/hooks/queries/kb/connectors.test.ts @@ -45,6 +45,7 @@ import { } from '@/lib/api/contracts/knowledge' import { type ConnectorDetailData, + type OrganizationSearchOverview, readSearchIndexContract, } from '@/lib/api/contracts/knowledge/connectors' import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants' @@ -55,6 +56,7 @@ import { useConnectorDetail, useConnectorDocuments, useConnectorList, + useOrganizationSearchOverview, useSearchIndex, useSearchSources, useTriggerSync, @@ -159,6 +161,36 @@ describe('isConnectorSyncingOrPending', () => { ) }) +describe('organization overview polling', () => { + it.each([ + { isSyncing: true, hasPendingSync: false, polling: true }, + { isSyncing: false, hasPendingSync: true, polling: true }, + { isSyncing: false, hasPendingSync: false, polling: false }, + { isSyncing: false, hasPendingSync: undefined, polling: false }, + ])('polls unfinished work across worker handoffs: %j', ({ polling, ...state }) => { + useOrganizationSearchOverview('organization-1') + const { refetchInterval } = capturedQueryOptions() + const interval = refetchInterval({ + state: { + data: { + providers: [ + { + connectorType: 'confluence', + approved: true, + sourceCount: 1, + status: 'active', + issue: null, + ...state, + }, + ], + }, + }, + }) + if (polling) expect(interval).toBeGreaterThan(0) + else expect(interval).toBe(false) + }) +}) + describe('useConnectorList polling', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index fa507b4d24f..bdfb1fb6a0a 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -476,7 +476,7 @@ export function useOrganizationSearchOverview( enabled: Boolean(organizationId) && (options?.enabled ?? true), staleTime: CONNECTOR_LIST_STALE_TIME, refetchInterval: (query) => - query.state.data?.providers.some((provider) => provider.isSyncing) + query.state.data?.providers.some((provider) => provider.isSyncing || provider.hasPendingSync) ? SEARCH_SOURCE_SUMMARY_POLL_MS : false, }) diff --git a/apps/sim/hooks/queries/organization-accounts.test.tsx b/apps/sim/hooks/queries/organization-accounts.test.tsx index 69f33d1f871..9752436158c 100644 --- a/apps/sim/hooks/queries/organization-accounts.test.tsx +++ b/apps/sim/hooks/queries/organization-accounts.test.tsx @@ -27,6 +27,7 @@ import { import { slackSearchKeys } from '@/hooks/queries/slack-search' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' +import { selectorKeys, selectorQueryRoots } from '@/hooks/queries/utils/selector-keys' describe('personal account disconnect', () => { it.each([true, false])( @@ -163,6 +164,20 @@ describe('organization account setup updates', () => { const other = slackSearchKeys.manifest('org-2', 'Sim Search') const overview = searchSourceKeys.organizationOverview('org-1') const otherOverview = searchSourceKeys.organizationOverview('org-2') + const providerSelectors = [ + selectorKeys.scoped( + 'workspace.credentialGroupProviders', + { kind: 'workspace', workspaceId: 'workspace-1' }, + 'block-1' + ), + selectorKeys.scoped( + 'workspace.organizationMcpProviders', + { kind: 'workspace', workspaceId: 'workspace-1' }, + 'block-2' + ), + [...selectorQueryRoots.workflowSearchReplace, 'workflow-1'], + ] + for (const key of providerSelectors) client.setQueryData(key, { options: ['cached'] }) for (const key of [current, renamed, other]) client.setQueryData(key, { existingApp: 'A1' }) for (const key of [overview, otherOverview]) client.setQueryData(key, { providers: [] }) try { @@ -200,6 +215,8 @@ describe('organization account setup updates', () => { expect(client.getQueryState(other)?.isInvalidated).toBe(false) expect(client.getQueryState(overview)?.isInvalidated).toBe(success) expect(client.getQueryState(otherOverview)?.isInvalidated).toBe(false) + for (const key of providerSelectors) + expect(client.getQueryState(key)?.isInvalidated).toBe(success) } finally { await act(async () => root.unmount()) client.clear() diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 733c3f03927..4efb5095aea 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -38,9 +38,12 @@ import { updateOrganizationAccountsContract, updateOrganizationAccountWorkspaceAccessContract, } from '@/lib/api/contracts/organization-accounts' +import { personalCredentialKeys } from '@/hooks/queries/personal-credentials' import { slackSearchKeys } from '@/hooks/queries/slack-search' +import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' +import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 @@ -63,6 +66,9 @@ export function useDisconnectPersonalOrganizationAccount(organizationId: string) onSuccess: async () => { await Promise.all([ resetOrganizationSearchAccess(queryClient, organizationId), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.detail(organizationId), }), @@ -145,6 +151,9 @@ export function useConfigureOrganizationMcp() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), ]), }) } @@ -171,6 +180,9 @@ export function useUpdateOrganizationAccounts() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), queryClient.invalidateQueries({ queryKey: slackSearchKeys.organizationManifests(organizationId), }), @@ -205,9 +217,13 @@ export function useWorkspaceOrganizationAccounts(workspaceId?: string, enabled = }, }) } -export function useOrganizationAccountWorkspaceAccess(organizationId: string) { +export function useOrganizationAccountWorkspaceAccess( + organizationId: string, + options?: { enabled?: boolean } +) { return useQuery({ queryKey: organizationAccountsKeys.access(organizationId), + enabled: Boolean(organizationId) && (options?.enabled ?? true), staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, queryFn: ({ signal }) => requestJson(getOrganizationAccountWorkspaceAccessContract, { @@ -233,6 +249,9 @@ export function useUpdateOrganizationAccountWorkspaceAccess() { queryKey: organizationAccountsKeys.access(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), ]), }) } @@ -322,6 +341,9 @@ export function useRevokeOrganizationAccountEnrollment() { onSuccess: (_, { organizationId }) => Promise.all([ resetOrganizationSearchAccess(queryClient, organizationId), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.detail(organizationId), }), @@ -345,6 +367,9 @@ export function useAddOrganizationAccountMcpProvider() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), ]), }) } @@ -367,6 +392,9 @@ export function useRemoveOrganizationAccountMcpProvider() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(queryClient), ]), }) } diff --git a/apps/sim/hooks/queries/organization-activity.ts b/apps/sim/hooks/queries/organization-activity.ts new file mode 100644 index 00000000000..34da2fe06cb --- /dev/null +++ b/apps/sim/hooks/queries/organization-activity.ts @@ -0,0 +1,91 @@ +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + getOrganizationActivityBreakdownContract, + getOrganizationActivitySummaryContract, +} from '@/lib/api/contracts/organization-activity' +import type { ActivityDimension, ActivitySort } from '@/lib/billing/core/organization-activity' +import { + type OrganizationUsageWindowKey, + organizationUsageKeys, +} from '@/hooks/queries/utils/organization-usage-keys' + +export const ORGANIZATION_ACTIVITY_STALE_TIME = 60 * 1000 + +export const organizationActivityKeys = { + all: (organizationId: string) => + [...organizationUsageKeys.all(organizationId), 'activity'] as const, + summaries: (organizationId: string) => + [...organizationActivityKeys.all(organizationId), 'summary'] as const, + summary: (organizationId: string, window: OrganizationUsageWindowKey, workspaceId?: string) => + [...organizationActivityKeys.summaries(organizationId), window, workspaceId ?? ''] as const, + breakdowns: (organizationId: string) => + [...organizationActivityKeys.all(organizationId), 'breakdown'] as const, + breakdown: ( + organizationId: string, + window: OrganizationUsageWindowKey, + dimension: ActivityDimension, + sort: ActivitySort, + page: number, + workspaceId?: string + ) => + [ + ...organizationActivityKeys.breakdowns(organizationId), + window, + workspaceId ?? '', + dimension, + sort, + page, + ] as const, +} + +interface ActivityQueryOptions { + enabled?: boolean + workspaceId?: string +} + +export function useOrganizationActivitySummary( + organizationId: string, + window: OrganizationUsageWindowKey, + { enabled = true, workspaceId }: ActivityQueryOptions = {} +) { + return useQuery({ + queryKey: organizationActivityKeys.summary(organizationId, window, workspaceId), + queryFn: ({ signal }) => + requestJson(getOrganizationActivitySummaryContract, { + params: { id: organizationId }, + query: { ...window, workspaceId }, + signal, + }), + enabled: Boolean(organizationId) && enabled, + staleTime: ORGANIZATION_ACTIVITY_STALE_TIME, + }) +} + +export function useOrganizationActivityBreakdown( + organizationId: string, + window: OrganizationUsageWindowKey, + dimension: ActivityDimension, + sort: ActivitySort, + page: number, + { enabled = true, workspaceId }: ActivityQueryOptions = {} +) { + return useQuery({ + queryKey: organizationActivityKeys.breakdown( + organizationId, + window, + dimension, + sort, + page, + workspaceId + ), + queryFn: ({ signal }) => + requestJson(getOrganizationActivityBreakdownContract, { + params: { id: organizationId }, + query: { ...window, workspaceId, dimension, sort, page }, + signal, + }), + enabled: Boolean(organizationId) && enabled, + staleTime: ORGANIZATION_ACTIVITY_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/scoped-credentials-mutations.test.ts b/apps/sim/hooks/queries/scoped-credentials-mutations.test.ts new file mode 100644 index 00000000000..fd29a28f7ef --- /dev/null +++ b/apps/sim/hooks/queries/scoped-credentials-mutations.test.ts @@ -0,0 +1,104 @@ +/** @vitest-environment node */ +import { setupGlobalFetchMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: (options: { mutationFn: (input: TInput) => Promise }) => ({ + mutateAsync: options.mutationFn, + }), +})) +vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({ + oauthCredentialKeys: { lists: () => ['oauth-credentials', 'list'] }, +})) + +import { updateOrganizationCredentialBodySchema } from '@/lib/api/contracts/organization-credentials' +import { useUpdateScopedCredential } from '@/hooks/queries/scoped-credentials' + +const WORKSPACE_ID = 'workspace-1' +const ORGANIZATION_ID = 'organization-1' +const CREDENTIAL_ID = 'slack-bot-1' +const reconnectFields = { + signingSecret: 'new-signing-secret', + botToken: 'xoxb-new-bot-token', + displayName: 'Support Bot', + description: 'Reconnected Slack bot', +} as const +const credential = { + id: CREDENTIAL_ID, + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: reconnectFields.displayName, + description: reconnectFields.description, + unredacted: false, + providerId: 'slack-custom-bot', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} as const + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('scoped Slack bot reconnect requests', () => { + it.each([WORKSPACE_ID, undefined])( + 'sends workspace scope %s only in the query when reconnecting the existing credential', + async (workspaceId) => { + const fetch = setupGlobalFetchMock({ json: { credential } }) + + await expect( + useUpdateScopedCredential().mutateAsync({ + credentialId: CREDENTIAL_ID, + workspaceId, + ...reconnectFields, + }) + ).resolves.toEqual({ credential }) + + expect(fetch).toHaveBeenCalledExactlyOnceWith( + `/api/credentials/${CREDENTIAL_ID}${workspaceId ? `?workspaceId=${workspaceId}` : ''}`, + expect.objectContaining({ method: 'PUT' }) + ) + expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual(reconnectFields) + } + ) + + it('includes organization scope in the body when reconnecting the existing credential', async () => { + const organizationCredential = { + ...credential, + workspaceId: null, + organizationId: ORGANIZATION_ID, + } + const fetch = setupGlobalFetchMock({ json: { credential: organizationCredential } }) + + await expect( + useUpdateScopedCredential().mutateAsync({ + credentialId: CREDENTIAL_ID, + organizationId: ORGANIZATION_ID, + ...reconnectFields, + }) + ).resolves.toEqual({ credential: organizationCredential }) + + expect(fetch).toHaveBeenCalledExactlyOnceWith( + `/api/organization-credentials/${CREDENTIAL_ID}`, + expect.objectContaining({ method: 'PATCH' }) + ) + expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual({ + ...reconnectFields, + organizationId: ORGANIZATION_ID, + }) + }) + + it.each([ + { organizationId: ORGANIZATION_ID }, + { ...reconnectFields }, + { organizationId: ORGANIZATION_ID, ...reconnectFields, workspaceId: WORKSPACE_ID }, + { organizationId: ORGANIZATION_ID, ...reconnectFields, unexpected: true }, + ])('rejects invalid organization updates: %j', (body) => { + expect(updateOrganizationCredentialBodySchema.safeParse(body).success).toBe(false) + }) +}) diff --git a/apps/sim/hooks/queries/scoped-credentials.ts b/apps/sim/hooks/queries/scoped-credentials.ts index 93eaa1e84be..d9cf1987f42 100644 --- a/apps/sim/hooks/queries/scoped-credentials.ts +++ b/apps/sim/hooks/queries/scoped-credentials.ts @@ -96,10 +96,10 @@ export function useUpdateScopedCredential() { workspaceId?: string organizationId?: never }) - | UpdateOrganizationCredentialBody + | (UpdateOrganizationCredentialBody & { workspaceId?: never }) ) ) => { - const { credentialId, ...body } = input + const { credentialId, workspaceId, ...body } = input if ('organizationId' in body && body.organizationId) return requestJson(updateOrganizationCredentialContract, { params: { id: credentialId }, @@ -108,7 +108,7 @@ export function useUpdateScopedCredential() { return requestJson(updateWorkspaceCredentialContract, { params: { id: credentialId }, body, - query: { workspaceId: 'workspaceId' in input ? input.workspaceId : undefined }, + query: { workspaceId }, }) }, onSuccess: reconcile, diff --git a/apps/sim/hooks/queries/utils/access-request-keys.ts b/apps/sim/hooks/queries/utils/access-request-keys.ts new file mode 100644 index 00000000000..3f53588e4d4 --- /dev/null +++ b/apps/sim/hooks/queries/utils/access-request-keys.ts @@ -0,0 +1,36 @@ +import type { + AccessRequestScope, + AccessRequestStatus, + DiscoverAccessRequestsQuery, +} from '@/lib/api/contracts/access-requests' + +export const ACCESS_REQUESTS_STALE_TIME = 15_000 + +export const accessRequestKeys = { + all: ['accessRequests'] as const, + lists: () => [...accessRequestKeys.all, 'list'] as const, + mine: (scope: AccessRequestScope, offset: number, requestId?: string) => + [...accessRequestKeys.lists(), 'mine', scope, offset, requestId ?? ''] as const, + organization: (organizationId: string, offset: number, status: AccessRequestStatus | 'all') => + [...accessRequestKeys.lists(), 'organization', organizationId, offset, status] as const, + discoveries: () => [...accessRequestKeys.all, 'discovery'] as const, + discovery: (query: DiscoverAccessRequestsQuery) => + [...accessRequestKeys.discoveries(), query] as const, + details: () => [...accessRequestKeys.all, 'detail'] as const, + organizationDetails: (organizationId: string) => + [...accessRequestKeys.details(), organizationId] as const, + preview: (organizationId: string, requestId: string) => + [...accessRequestKeys.organizationDetails(organizationId), requestId] as const, + settings: (organizationId: string) => + [...accessRequestKeys.all, 'settings', organizationId] as const, +} + +export function workspaceFeatureDiscoveryQuery(workspaceId: string) { + return { + kind: 'workspace', + workspaceId, + targetKind: 'feature', + limit: 100, + offset: 0, + } as const satisfies DiscoverAccessRequestsQuery +} diff --git a/apps/sim/hooks/queries/utils/permission-group-keys.ts b/apps/sim/hooks/queries/utils/permission-group-keys.ts new file mode 100644 index 00000000000..97c0e07946b --- /dev/null +++ b/apps/sim/hooks/queries/utils/permission-group-keys.ts @@ -0,0 +1,18 @@ +export const PERMISSION_GROUP_MEMBERS_STALE_TIME = 30 * 1000 +export const PERMISSION_GROUPS_STALE_TIME = 60 * 1000 + +export const permissionGroupKeys = { + all: ['permissionGroups'] as const, + lists: () => [...permissionGroupKeys.all, 'list'] as const, + list: (organizationId?: string) => + [...permissionGroupKeys.lists(), organizationId ?? ''] as const, + details: () => [...permissionGroupKeys.all, 'detail'] as const, + detail: (organizationId?: string, id?: string) => + [...permissionGroupKeys.details(), organizationId ?? '', id ?? ''] as const, + members: (organizationId?: string, id?: string) => + [...permissionGroupKeys.detail(organizationId, id), 'members'] as const, + userConfig: (workspaceId?: string) => + [...permissionGroupKeys.all, 'userConfig', workspaceId ?? ''] as const, + orgWorkspaces: (organizationId?: string) => + [...permissionGroupKeys.all, 'orgWorkspaces', organizationId ?? ''] as const, +} diff --git a/apps/sim/hooks/use-member-enrollment.test.tsx b/apps/sim/hooks/use-member-enrollment.test.tsx index 4dbd090da1b..e0c8cb9a173 100644 --- a/apps/sim/hooks/use-member-enrollment.test.tsx +++ b/apps/sim/hooks/use-member-enrollment.test.tsx @@ -122,7 +122,7 @@ afterEach(() => { }) describe('useMemberEnrollment', () => { - it('reports an OAuth mismatch once per attempt and allows the same error on a later retry', () => { + it('reports an OAuth failure once per attempt and allows the same error on a later retry', () => { mount(new Set(), true, mocks.connectionError) for (let index = 0; index < 2; index += 1) { act(() => enrollment().connect('kb-1', 'connector-1')) @@ -132,16 +132,20 @@ describe('useMemberEnrollment', () => { }) ) act(() => - mocks.channels[index].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' })) + mocks.channels[index].onmessage?.( + new MessageEvent('message', { data: 'permissions_required' }) + ) ) act(() => - mocks.channels[index].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' })) + mocks.channels[index].onmessage?.( + new MessageEvent('message', { data: 'permissions_required' }) + ) ) expect(mocks.connectionError).toHaveBeenCalledTimes(index + 1) expect(enrollment().isAwaiting('connector-1')).toBe(false) } expect(mocks.connectionError).toHaveBeenLastCalledWith( - 'Choose the account matching your Sim email address.' + 'All requested permissions are required to connect this account.' ) act(() => vi.advanceTimersByTime(10 * 60_000)) expect(mocks.connectionError).toHaveBeenCalledTimes(2) @@ -179,7 +183,7 @@ describe('useMemberEnrollment', () => { act(() => mocks.channels[1].onmessage?.(new MessageEvent('message', { data: 'connected' }))) act(() => vi.advanceTimersByTime(10 * 60_000)) act(() => - mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' })) + mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'permissions_required' })) ) expect(mocks.connectionError).not.toHaveBeenCalled() expect(enrollment().error).toBeNull() @@ -211,10 +215,10 @@ describe('useMemberEnrollment', () => { }) it.each([ - ['existing', 'account_mismatch'], + ['existing', 'permissions_required'], ['existing', 'denied'], ['existing', 'expired'], - ['new', 'account_mismatch'], + ['new', 'permissions_required'], ['new', 'denied'], ['new', 'expired'], ] as const)( diff --git a/apps/sim/hooks/use-permission-config.test.tsx b/apps/sim/hooks/use-permission-config.test.tsx index 6215eb6b292..999dc1477f6 100644 --- a/apps/sim/hooks/use-permission-config.test.tsx +++ b/apps/sim/hooks/use-permission-config.test.tsx @@ -6,7 +6,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() })) +const { mockRequestJson, mockUseUserPermissionConfig } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), + mockUseUserPermissionConfig: vi.fn(), +})) vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) })) @@ -16,7 +19,7 @@ vi.mock('@/blocks/visibility/context', () => ({ isHiddenUnder: () => false, })) vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ - useUserPermissionConfig: () => ({ data: undefined, isLoading: false }), + useUserPermissionConfig: mockUseUserPermissionConfig, })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ useOptionalWorkspaceHostContext: () => null, @@ -28,6 +31,7 @@ vi.mock('@/lib/permission-groups/operation-access', () => ({ import type { GetAllowedIntegrationsResponse } from '@/lib/api/contracts/common' import { getAllowedIntegrationsContract } from '@/lib/api/contracts/common' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { integrationAvailabilityKeys } from '@/hooks/queries/integration-availability' import { type PermissionConfigResult, usePermissionConfig } from '@/hooks/use-permission-config' @@ -51,6 +55,7 @@ describe('usePermissionConfig deployment readiness', () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() + mockUseUserPermissionConfig.mockReturnValue({ data: undefined, isLoading: false }) ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) @@ -96,6 +101,32 @@ describe('usePermissionConfig deployment readiness', () => { expect(mockRequestJson).not.toHaveBeenCalled() }) + it('offers requests only for group restrictions within the deployment allowlist', () => { + mockUseUserPermissionConfig.mockReturnValue({ + data: { config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: [] } }, + isLoading: false, + }) + queryClient.setQueryData(integrationAvailabilityKeys.environments(), { + ...AVAILABILITY, + allowedIntegrations: ['gmail'], + }) + render() + expect(current!.isBlockAllowed('gmail')).toBe(false) + expect(current!.isBlockRequestable('gmail')).toBe(true) + expect(current!.isBlockRequestable('slack')).toBe(false) + expect(current!.isBlockRequestable('credential_group')).toBe(false) + }) + + it('does not offer a request before deployment availability resolves', () => { + mockUseUserPermissionConfig.mockReturnValue({ + data: { config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: [] } }, + isLoading: false, + }) + mockRequestJson.mockReturnValue(new Promise(() => {})) + render() + expect(current!.isBlockRequestable('gmail')).toBe(false) + }) + it('surfaces a failed availability request and recovers through the same refetch action', async () => { const error = new Error('Unable to load deployment availability') mockRequestJson.mockRejectedValueOnce(error) diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index b07b8da4369..7e6f301467d 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -32,6 +32,8 @@ export interface PermissionConfigResult { filterBlocks: (blocks: T[]) => T[] filterProviders: (providerIds: string[]) => string[] isBlockAllowed: (blockType: string) => boolean + /** Presentation-only hint; request creation revalidates the deployed public catalog. */ + isBlockRequestable: (blockType: string) => boolean /** * Whether a model is usable at all: allowed by the model denylist *and* by * the provider allowlist. Both gates apply to every model field, so this is @@ -148,6 +150,36 @@ export function usePermissionConfig(): PermissionConfigResult { } }, [hostContext?.features?.credentialGroups, integrationAvailability, allowedAccessControlTypes]) + const isBlockRequestable = useMemo(() => { + const deploymentAllowlist = intersectAccessControlAllowlists( + null, + envAllowlistData?.allowedIntegrations ?? null + ) + return (blockType: string): boolean => { + if (isLoading || !isIntegrationAvailabilityReady || isBlockAllowed(blockType)) return false + if (isBlockTypeAccessControlExempt(blockType)) return false + if (blockType === 'credential_group' && !hostContext?.features?.credentialGroups) return false + const availability = integrationAvailability.get(blockType.toLowerCase()) + if ( + isDeploymentGatedIntegrationType(blockType) && + availability && + (availability.state === 'unavailable' || availability.state === 'misconfigured') + ) + return false + return ( + deploymentAllowlist === null || + deploymentAllowlist.has(resolveAccessControlBlockType(blockType)) + ) + } + }, [ + envAllowlistData, + isLoading, + isIntegrationAvailabilityReady, + isBlockAllowed, + hostContext?.features?.credentialGroups, + integrationAvailability, + ]) + const isModelUsable = useMemo( () => createModelAccessGate({ @@ -198,6 +230,7 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, + isBlockRequestable, isModelUsable, isToolAllowed, isInvitationsDisabled, @@ -217,6 +250,7 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, + isBlockRequestable, isModelUsable, isToolAllowed, isInvitationsDisabled, diff --git a/apps/sim/hooks/use-personal-source-account.test.tsx b/apps/sim/hooks/use-personal-source-account.test.tsx index cccfe0e977f..b6a571561f9 100644 --- a/apps/sim/hooks/use-personal-source-account.test.tsx +++ b/apps/sim/hooks/use-personal-source-account.test.tsx @@ -108,10 +108,12 @@ describe('personal source account authorization', () => { act(() => root.render()) expect(current.pending).toBe(false) }) - it('shows an account mismatch as a toast and allows a fresh attempt', async () => { + it('shows missing permissions as a toast and allows a fresh attempt', async () => { await act(async () => current.connect()) - act(() => channels[0].onmessage?.({ data: 'account_mismatch' } as MessageEvent)) - expect(mocks.error).toHaveBeenCalledWith('Choose the account matching your Sim email address.') + act(() => channels[0].onmessage?.({ data: 'permissions_required' } as MessageEvent)) + expect(mocks.error).toHaveBeenCalledWith( + 'All requested permissions are required to connect this account.' + ) expect(current.pending).toBe(false) await act(async () => current.connect()) expect(mocks.authorize).toHaveBeenCalledTimes(2) diff --git a/apps/sim/lib/admin/dashboard.ts b/apps/sim/lib/admin/dashboard.ts index b66d94f6ae1..bdb62e65b72 100644 --- a/apps/sim/lib/admin/dashboard.ts +++ b/apps/sim/lib/admin/dashboard.ts @@ -1,10 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import { member, organization, - organizationColumns, organizationMemberUsageLimit, outboxEvent, permissions, @@ -12,7 +10,6 @@ import { usageLog, user, userStats, - userStatsColumns, workspace, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' @@ -654,11 +651,7 @@ export async function listDashboardUsers({ search, limit, offset }: PaginationIn async function getDashboardOrganizationSummary(organizationId: string) { const [[org], [memberCountRow], [externalCountRow], latestSubscription, provisionings] = await Promise.all([ - db - .select(organizationColumns) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1), + db.select().from(organization).where(eq(organization.id, organizationId)).limit(1), db.select({ value: count() }).from(member).where(eq(member.organizationId, organizationId)), db .select({ value: countDistinct(permissions.userId) }) @@ -1284,7 +1277,7 @@ export async function updateDashboardOrganizationLimits( const providerBacked = await db.transaction(async (tx) => { await acquireOrganizationMutationLock(tx, organizationId) const [org] = await tx - .select(organizationColumns) + .select() .from(organization) .where(eq(organization.id, organizationId)) .for('update') @@ -1418,7 +1411,7 @@ export async function grantDashboardOrganizationBalance( }), operation: async () => { const [org] = await tx - .select(organizationColumns) + .select() .from(organization) .where(eq(organization.id, organizationId)) .for('update') @@ -1526,7 +1519,7 @@ export async function grantDashboardUserBalance( ? null : getPerUserMinimumLimit(initialSubscription).toString() await tx - .insert(withInsertColumns(userStats, userStatsColumns)) + .insert(userStats) .values({ id: generateId(), userId, diff --git a/apps/sim/lib/api/contracts/access-requests.test.ts b/apps/sim/lib/api/contracts/access-requests.test.ts new file mode 100644 index 00000000000..22c27d5fedc --- /dev/null +++ b/apps/sim/lib/api/contracts/access-requests.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + accessRequestScopeSchema, + accessRequestSettingsSchema, + accessRequestTargetSchema, + createAccessRequestBodySchema, + discoverAccessRequestsQuerySchema, + resolveAccessRequestBodySchema, +} from '@/lib/api/contracts/access-requests' + +describe('access request contracts', () => { + it('requires one explicit scope and rejects mixed or empty scope IDs', () => { + expect( + accessRequestScopeSchema.safeParse({ kind: 'workspace', workspaceId: 'workspace-1' }).success + ).toBe(true) + expect(accessRequestScopeSchema.safeParse({ kind: 'workspace', workspaceId: '' }).success).toBe( + false + ) + expect( + accessRequestScopeSchema.safeParse({ + kind: 'workspace', + workspaceId: 'ws', + organizationId: 'org', + }).success + ).toBe(false) + expect(accessRequestScopeSchema.safeParse({ workspaceId: 'ws' }).success).toBe(false) + }) + + it('accepts only boolean feature keys and known authentication modes', () => { + expect( + accessRequestTargetSchema.safeParse({ kind: 'feature', configKey: 'hideTablesTab' }).success + ).toBe(true) + for (const configKey of ['allowedIntegrations', 'constructor', 'madeUp']) { + expect(accessRequestTargetSchema.safeParse({ kind: 'feature', configKey }).success).toBe( + false + ) + } + expect( + accessRequestTargetSchema.safeParse({ kind: 'file_share_auth', id: 'sso' }).success + ).toBe(true) + expect( + accessRequestTargetSchema.safeParse({ kind: 'file_share_auth', id: 'bypass' }).success + ).toBe(false) + expect( + accessRequestTargetSchema.safeParse({ kind: 'usage_limit', id: 'organization_budget' }) + .success + ).toBe(false) + }) + + it('bounds pagination and search before discovery', () => { + const scope = { kind: 'organization', organizationId: 'org' } + expect(discoverAccessRequestsQuerySchema.parse(scope)).toEqual({ + ...scope, + limit: 50, + offset: 0, + }) + expect( + discoverAccessRequestsQuerySchema.parse({ ...scope, limit: '25', offset: '50' }) + ).toMatchObject({ limit: 25, offset: 50 }) + for (const override of [ + { limit: 101 }, + { limit: 0 }, + { offset: -1 }, + { search: 'a'.repeat(201) }, + { targetKey: '' }, + { targetKey: 'a'.repeat(2049) }, + ]) { + expect(discoverAccessRequestsQuerySchema.safeParse({ ...scope, ...override }).success).toBe( + false + ) + } + }) + + it('allows an omitted reason while limiting submitted text and rejecting client policy patches', () => { + const body = { + scope: { kind: 'workspace', workspaceId: 'ws' }, + target: { kind: 'feature', configKey: 'hideTablesTab' }, + } + expect(createAccessRequestBodySchema.parse(body).reason).toBe('') + expect(createAccessRequestBodySchema.parse({ ...body, reason: ' Need tables ' }).reason).toBe( + 'Need tables' + ) + expect( + createAccessRequestBodySchema.safeParse({ ...body, reason: 'a'.repeat(1001) }).success + ).toBe(false) + expect( + createAccessRequestBodySchema.safeParse({ ...body, config: { hideTablesTab: false } }).success + ).toBe(false) + }) + + it('requires a current preview for applying and a nonblank reason for declining', () => { + expect(resolveAccessRequestBodySchema.safeParse({ action: 'apply' }).success).toBe(false) + expect( + resolveAccessRequestBodySchema.safeParse({ action: 'apply', expectedFingerprint: 'sha' }) + .success + ).toBe(true) + expect( + resolveAccessRequestBodySchema.safeParse({ action: 'decline', reason: ' ' }).success + ).toBe(false) + expect( + resolveAccessRequestBodySchema.safeParse({ + action: 'decline', + reason: 'Policy remains required', + expectedFingerprint: 'sha', + }).success + ).toBe(false) + for (const newLimitCredits of [0, -1, 100.5, Number.POSITIVE_INFINITY, Number.NaN]) { + expect( + resolveAccessRequestBodySchema.safeParse({ + action: 'apply', + expectedFingerprint: 'sha', + newLimitCredits, + }).success + ).toBe(false) + } + expect( + resolveAccessRequestBodySchema.safeParse({ + action: 'apply', + expectedFingerprint: 'sha', + newLimitCredits: 100, + }).success + ).toBe(true) + }) + + it('accepts only the explicit boolean settings field', () => { + expect(accessRequestSettingsSchema.safeParse({ allowRequests: false }).success).toBe(true) + expect(accessRequestSettingsSchema.safeParse({ allowRequests: 'false' }).success).toBe(false) + expect( + accessRequestSettingsSchema.safeParse({ allowRequests: true, enabled: true }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/access-requests.ts b/apps/sim/lib/api/contracts/access-requests.ts new file mode 100644 index 00000000000..c9294e73dea --- /dev/null +++ b/apps/sim/lib/api/contracts/access-requests.ts @@ -0,0 +1,313 @@ +import { z } from 'zod' +import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + ACCESS_REQUEST_MAX_ID_LENGTH, + ACCESS_REQUEST_MAX_OFFSET, + ACCESS_REQUEST_MAX_SEARCH_LENGTH, +} from '@/lib/permission-access-requests/constants' +import { + storedAccessRequestDecisionSchema, + storedAccessRequestPolicyChangeSchema, + storedAccessRequestPolicyValueSchema, + storedAccessRequestTargetSchema, +} from '@/lib/permission-access-requests/schemas' +import { + ACCESS_REQUEST_TARGET_KINDS, + type AccessRequestScope as DomainAccessRequestScope, +} from '@/lib/permission-groups/access-requests/targets' +import { PERMISSION_GROUP_FIELDS } from '@/lib/permission-groups/fields' + +export const ACCESS_REQUEST_STATUSES = [ + 'pending', + 'fulfilled', + 'declined', + 'cancelled', + 'closed', +] as const + +export const ACCESS_REQUEST_PAGE_SIZE = 50 +export const ACCESS_REQUEST_MAX_PAGE_SIZE = 100 + +const requestIdSchema = z + .string() + .min(1, 'Request ID cannot be empty') + .max(ACCESS_REQUEST_MAX_ID_LENGTH) +const reasonSchema = z.string().trim().max(1000, 'Reason must be at most 1000 characters') +const fingerprintSchema = z.string().min(1, 'A current preview is required').max(128) +const usageLimitSchema = z + .number() + .int('Credit limit must be a whole number') + .positive() + .max(Number.MAX_SAFE_INTEGER) + +export const accessRequestTargetSchema = storedAccessRequestTargetSchema +export type AccessRequestTarget = z.output + +export const accessRequestScopeSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('workspace'), workspaceId: workspaceIdSchema }).strict(), + z.object({ kind: z.literal('organization'), organizationId: organizationIdSchema }).strict(), +]) satisfies z.ZodType +export type AccessRequestScope = z.output + +const paginationShape = { + limit: z.coerce + .number() + .int() + .min(1) + .max(ACCESS_REQUEST_MAX_PAGE_SIZE) + .default(ACCESS_REQUEST_PAGE_SIZE), + offset: z.coerce.number().int().min(0).max(ACCESS_REQUEST_MAX_OFFSET).default(0), +} + +const discoveryShape = { + ...paginationShape, + search: z.string().trim().max(ACCESS_REQUEST_MAX_SEARCH_LENGTH).optional(), + targetKind: z.enum(ACCESS_REQUEST_TARGET_KINDS).optional(), + targetKey: z.string().min(1, 'Target key cannot be empty').max(2048).optional(), + state: z.enum(['allowed', 'requestable', 'unavailable']).optional(), +} + +export const discoverAccessRequestsQuerySchema = z.discriminatedUnion('kind', [ + z + .object({ kind: z.literal('workspace'), workspaceId: workspaceIdSchema, ...discoveryShape }) + .strict(), + z + .object({ + kind: z.literal('organization'), + organizationId: organizationIdSchema, + ...discoveryShape, + }) + .strict(), +]) +export type DiscoverAccessRequestsQuery = z.input +export type ParsedDiscoverAccessRequestsQuery = z.output + +export const listMyAccessRequestsQuerySchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('workspace'), + workspaceId: workspaceIdSchema, + ...paginationShape, + requestId: requestIdSchema.optional(), + }) + .strict(), + z + .object({ + kind: z.literal('organization'), + organizationId: organizationIdSchema, + ...paginationShape, + requestId: requestIdSchema.optional(), + }) + .strict(), +]) +export type ListMyAccessRequestsQuery = z.input + +export const createAccessRequestBodySchema = z + .object({ + scope: accessRequestScopeSchema, + target: accessRequestTargetSchema, + reason: reasonSchema.default(''), + }) + .strict() +export type CreateAccessRequestBody = z.input + +export const accessRequestParamsSchema = z.object({ requestId: requestIdSchema }) +export type AccessRequestParams = z.input + +export const cancelAccessRequestBodySchema = z.object({ scope: accessRequestScopeSchema }).strict() +export type CancelAccessRequestBody = z.input + +export const organizationAccessRequestParamsSchema = z.object({ + id: organizationIdSchema, +}) +export type OrganizationAccessRequestParams = z.input + +export const organizationAccessRequestDetailParamsSchema = + organizationAccessRequestParamsSchema.extend({ + requestId: requestIdSchema, + }) +export type OrganizationAccessRequestDetailParams = z.input< + typeof organizationAccessRequestDetailParamsSchema +> + +export const listOrganizationAccessRequestsQuerySchema = z + .object({ + ...paginationShape, + status: z.enum(ACCESS_REQUEST_STATUSES).optional(), + }) + .strict() +export type ListOrganizationAccessRequestsQuery = z.input< + typeof listOrganizationAccessRequestsQuerySchema +> + +export const resolveAccessRequestBodySchema = z.discriminatedUnion('action', [ + z + .object({ + action: z.literal('apply'), + expectedFingerprint: fingerprintSchema, + newLimitCredits: usageLimitSchema.optional(), + }) + .strict(), + z + .object({ + action: z.literal('decline'), + reason: reasonSchema.min(1, 'Explain why this request was declined'), + }) + .strict(), +]) +export type ResolveAccessRequestBody = z.input + +export const accessRequestSettingsSchema = z.object({ allowRequests: z.boolean() }).strict() +export type AccessRequestSettings = z.output +export type UpdateAccessRequestSettingsBody = z.input + +export const accessRequestRecordSchema = z.object({ + id: requestIdSchema, + organizationId: organizationIdSchema, + workspaceId: workspaceIdSchema.nullable(), + target: accessRequestTargetSchema, + targetLabel: z.string().min(1).max(512), + reason: reasonSchema, + status: z.enum(ACCESS_REQUEST_STATUSES), + decisionReason: reasonSchema.nullable(), + createdAt: z.iso.datetime(), + decidedAt: z.iso.datetime().nullable(), + groupName: z.string().nullable(), + requester: z.object({ + id: z.string().min(1).max(128), + name: z.string().nullable(), + email: z.string().max(320), + }), +}) +export type AccessRequestRecord = z.output +export type AccessRequestStatus = AccessRequestRecord['status'] + +export const accessRequestDiscoveryEntrySchema = z.object({ + target: accessRequestTargetSchema, + label: z.string().min(1).max(512), + state: z.enum(['allowed', 'requestable', 'unavailable']), + reason: z.string().max(1000).nullable(), + pendingRequestId: requestIdSchema.nullable(), +}) +export type AccessRequestDiscoveryEntry = z.output + +export const discoverAccessRequestsResponseSchema = z.object({ + enabled: z.boolean(), + organizationId: organizationIdSchema.nullable(), + entries: z.array(accessRequestDiscoveryEntrySchema).max(ACCESS_REQUEST_MAX_PAGE_SIZE), + total: z.number().int().nonnegative(), + hasMore: z.boolean(), +}) +export type DiscoverAccessRequestsResponse = z.output + +export const accessRequestListResponseSchema = z.object({ + requests: z.array(accessRequestRecordSchema).max(ACCESS_REQUEST_MAX_PAGE_SIZE), + total: z.number().int().nonnegative(), + hasMore: z.boolean(), +}) +export type AccessRequestListResponse = z.output + +export const accessRequestResponseSchema = z.object({ request: accessRequestRecordSchema }) +export type AccessRequestResponse = z.output + +export const accessRequestPolicyValueSchema = storedAccessRequestPolicyValueSchema +export const accessRequestPolicyChangeSchema = storedAccessRequestPolicyChangeSchema +export type AccessRequestPolicyChange = z.output +export const accessRequestDecisionSchema = storedAccessRequestDecisionSchema +export type AccessRequestDecision = z.output + +const previewShape = { + newLimitCredits: z.number().finite().nonnegative().nullable(), + request: accessRequestRecordSchema, + changes: z + .array(accessRequestPolicyChangeSchema) + .max(Object.keys(PERMISSION_GROUP_FIELDS).length), + impact: storedAccessRequestDecisionSchema.shape.impact, + fingerprint: fingerprintSchema, + canApply: z.boolean(), + unavailableReason: z.string().max(1000).nullable(), +} + +export const accessRequestPreviewResponseSchema = z.discriminatedUnion('resolutionKind', [ + z.object({ + ...previewShape, + resolutionKind: z.literal('permission'), + group: storedAccessRequestDecisionSchema.shape.group, + currentLimitCredits: z.null(), + }), + z.object({ + ...previewShape, + resolutionKind: z.literal('usage_limit'), + group: z.null(), + currentLimitCredits: z.number().finite().nonnegative().max(Number.MAX_SAFE_INTEGER).nullable(), + }), +]) +export type AccessRequestPreviewResponse = z.output + +export const discoverAccessRequestsContract = defineRouteContract({ + method: 'GET', + path: '/api/access-requests/discovery', + query: discoverAccessRequestsQuerySchema, + response: { mode: 'json', schema: discoverAccessRequestsResponseSchema }, +}) + +export const listMyAccessRequestsContract = defineRouteContract({ + method: 'GET', + path: '/api/access-requests', + query: listMyAccessRequestsQuerySchema, + response: { mode: 'json', schema: accessRequestListResponseSchema }, +}) + +export const createAccessRequestContract = defineRouteContract({ + method: 'POST', + path: '/api/access-requests', + body: createAccessRequestBodySchema, + response: { mode: 'json', schema: accessRequestResponseSchema }, +}) + +export const cancelAccessRequestContract = defineRouteContract({ + method: 'POST', + path: '/api/access-requests/[requestId]/cancel', + params: accessRequestParamsSchema, + body: cancelAccessRequestBodySchema, + response: { mode: 'json', schema: accessRequestResponseSchema }, +}) + +export const listOrganizationAccessRequestsContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/access-requests', + params: organizationAccessRequestParamsSchema, + query: listOrganizationAccessRequestsQuerySchema, + response: { mode: 'json', schema: accessRequestListResponseSchema }, +}) + +export const previewAccessRequestContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/access-requests/[requestId]/preview', + params: organizationAccessRequestDetailParamsSchema, + response: { mode: 'json', schema: accessRequestPreviewResponseSchema }, +}) + +export const resolveAccessRequestContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/access-requests/[requestId]/resolve', + params: organizationAccessRequestDetailParamsSchema, + body: resolveAccessRequestBodySchema, + response: { mode: 'json', schema: accessRequestResponseSchema }, +}) + +export const getAccessRequestSettingsContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/access-requests/settings', + params: organizationAccessRequestParamsSchema, + response: { mode: 'json', schema: accessRequestSettingsSchema }, +}) + +export const updateAccessRequestSettingsContract = defineRouteContract({ + method: 'PATCH', + path: '/api/organizations/[id]/access-requests/settings', + params: organizationAccessRequestParamsSchema, + body: accessRequestSettingsSchema, + response: { mode: 'json', schema: accessRequestSettingsSchema }, +}) diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 5b57bfb830b..62a6c0c2882 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -7,11 +7,15 @@ import { type AsyncConfirmationStatus, } from '@/lib/copilot/async-runs/lifecycle' import { + BILLING_ACCOUNT_DECISION_HEADER, + BILLING_ACCOUNT_DECISION_HEADER_MAX_BYTES, BILLING_ATTRIBUTION_HEADER, BILLING_ATTRIBUTION_HEADER_MAX_BYTES, BILLING_REQUEST_ID_HEADER, COPILOT_BILLING_PROTOCOL_HEADER, COPILOT_BILLING_PROTOCOL_VALUES, + COPILOT_VALIDATION_PURPOSE, + COPILOT_VALIDATION_PURPOSE_VALUES, } from '@/lib/copilot/generated/billing-protocol-v1' import { PERSISTED_RESOURCE_TYPES } from '@/lib/copilot/resources/types' @@ -255,6 +259,10 @@ export const deleteCopilotChatBodySchema = z.object({ export type DeleteCopilotChatBody = z.input export const validateCopilotApiKeyHeadersSchema = z.object({ + [BILLING_ACCOUNT_DECISION_HEADER]: z + .string() + .max(BILLING_ACCOUNT_DECISION_HEADER_MAX_BYTES) + .optional(), [COPILOT_BILLING_PROTOCOL_HEADER]: z.enum(COPILOT_BILLING_PROTOCOL_VALUES).optional(), [BILLING_REQUEST_ID_HEADER]: z.string().uuid().optional(), [BILLING_ATTRIBUTION_HEADER]: z.string().max(BILLING_ATTRIBUTION_HEADER_MAX_BYTES).optional(), @@ -271,6 +279,8 @@ export type ValidateCopilotApiKeyError = z.output new Set(ids).size === ids.length, 'Workspace IDs must be unique'), + grants: organizationAccountWorkspaceGrantsSchema, }) export const getOrganizationAccountWorkspaceAccessContract = defineRouteContract({ method: 'GET', @@ -143,6 +145,11 @@ export const getOrganizationAccountWorkspaceAccessContract = defineRouteContract response: { mode: 'json', schema: organizationAccountWorkspaceAccessSchema.extend({ + credentialTypes: z + .array( + z.object({ id: organizationCredentialTypeSchema, label: z.string().min(1).max(256) }) + ) + .max(ORGANIZATION_CREDENTIAL_TYPES.length), workspaces: z .array(z.object({ id: workspaceIdSchema, name: z.string().max(256) })) .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT), diff --git a/apps/sim/lib/api/contracts/organization-activity.test.ts b/apps/sim/lib/api/contracts/organization-activity.test.ts new file mode 100644 index 00000000000..65f9998ac4e --- /dev/null +++ b/apps/sim/lib/api/contracts/organization-activity.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { organizationActivityBreakdownQuerySchema } from '@/lib/api/contracts/organization-activity' + +describe('activity query boundaries', () => { + it.each([ + { dimension: 'transcripts' }, + { sort: 'arbitrary sql' }, + { page: -1 }, + { page: 1001 }, + { page: 1.5 }, + { timezone: 'invalid' }, + { startDate: '2026-02-30' }, + { endDate: '2026-08-01T23:59:59Z' }, + ])('rejects unsupported dimensions, orders, pages and dates: %j', (query) => { + expect(organizationActivityBreakdownQuerySchema.safeParse(query).success).toBe(false) + }) + + it('parses bounded URL parameters into the shared query', () => { + expect( + organizationActivityBreakdownQuerySchema.parse({ page: '2', dimension: 'workflow' }) + ).toMatchObject({ + page: 2, + dimension: 'workflow', + sort: 'runs', + preset: 'current-period', + timezone: 'UTC', + }) + }) +}) diff --git a/apps/sim/lib/api/contracts/organization-activity.ts b/apps/sim/lib/api/contracts/organization-activity.ts new file mode 100644 index 00000000000..23a63b6a2b3 --- /dev/null +++ b/apps/sim/lib/api/contracts/organization-activity.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import { organizationUsageSummaryQuerySchema } from '@/lib/api/contracts/organization-usage' +import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + ACTIVITY_DIMENSIONS, + ACTIVITY_MAX_PAGE, + ACTIVITY_PAGE_SIZE, + ACTIVITY_SORTS, +} from '@/lib/billing/core/organization-activity' + +export const organizationActivityBreakdownQuerySchema = organizationUsageSummaryQuerySchema.extend({ + dimension: z.enum(ACTIVITY_DIMENSIONS).default('workspace'), + sort: z.enum(ACTIVITY_SORTS).default('runs'), + page: z.coerce.number().int().min(0).max(ACTIVITY_MAX_PAGE).default(0), +}) +export type OrganizationActivityBreakdownQuery = z.input< + typeof organizationActivityBreakdownQuerySchema +> + +export const organizationActivityMetricsSchema = z.object({ + workflowRuns: z.number().int().nonnegative(), + completed: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + chatRuns: z.number().int().nonnegative(), + chatMembers: z.number().int().nonnegative(), + failureRate: z.number().min(0).max(1).nullable(), + averageDurationMs: z.number().nonnegative().nullable(), +}) + +export const organizationActivitySummarySchema = z.object({ + workspace: z.object({ id: workspaceIdSchema, name: z.string() }).nullable(), + totals: organizationActivityMetricsSchema, + series: z + .array( + z.object({ + timestamp: z.string(), + workflowRuns: z.number().int().nonnegative(), + chatRuns: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + }) + ) + .max(1000), +}) +export type OrganizationActivitySummary = z.output + +export const organizationActivityBreakdownSchema = z.object({ + rows: z + .array( + organizationActivityMetricsSchema.extend({ + id: z.string(), + label: z.string(), + workspaceId: workspaceIdSchema.nullable(), + workspaceName: z.string().nullable(), + }) + ) + .max(ACTIVITY_PAGE_SIZE), + hasMore: z.boolean(), +}) +export type OrganizationActivityBreakdown = z.output + +export const getOrganizationActivitySummaryContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/usage/activity/summary', + params: z.object({ id: organizationIdSchema }), + query: organizationUsageSummaryQuerySchema, + response: { mode: 'json', schema: organizationActivitySummarySchema }, +}) + +export const getOrganizationActivityBreakdownContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/usage/activity/breakdown', + params: z.object({ id: organizationIdSchema }), + query: organizationActivityBreakdownQuerySchema, + response: { mode: 'json', schema: organizationActivityBreakdownSchema }, +}) diff --git a/apps/sim/lib/api/contracts/organization-credentials.ts b/apps/sim/lib/api/contracts/organization-credentials.ts index 31fe71c5528..a84dae50f6b 100644 --- a/apps/sim/lib/api/contracts/organization-credentials.ts +++ b/apps/sim/lib/api/contracts/organization-credentials.ts @@ -90,10 +90,9 @@ export const createOrganizationCredentialDraftContract = defineRouteContract({ }, }) -export const updateOrganizationCredentialBodySchema = z.intersection( - updateCredentialByIdBodySchema, - z.object({ organizationId: organizationIdSchema }) -) +export const updateOrganizationCredentialBodySchema = updateCredentialByIdBodySchema.safeExtend({ + organizationId: organizationIdSchema, +}) export type UpdateOrganizationCredentialBody = z.input< typeof updateOrganizationCredentialBodySchema > diff --git a/apps/sim/lib/api/contracts/organization-usage.test.ts b/apps/sim/lib/api/contracts/organization-usage.test.ts index 44e7c1903cd..0b61a6f4559 100644 --- a/apps/sim/lib/api/contracts/organization-usage.test.ts +++ b/apps/sim/lib/api/contracts/organization-usage.test.ts @@ -23,6 +23,23 @@ describe('organization usage window contract', () => { expect(parseWindow({ startDate: '2026-02-30' }).success).toBe(false) }) + /** + * Out of calendar range but well-formed enough to reach the old hand-rolled refinement, which + * called `toISOString` on an Invalid Date. Zod does not wrap refinements, so the RangeError + * escaped `safeParse` itself and every usage route answered a malformed query string with a 500. + */ + it.each(['2026-13-01', '2026-00-01', '2026-01-32', '2026-01-00', '9999-99-99'])( + 'refuses %s without throwing out of safeParse', + (startDate) => { + expect(parseWindow({ startDate }).success).toBe(false) + } + ) + + it('refuses February 29 in a non-leap year', () => { + expect(parseWindow({ startDate: '2026-02-29' }).success).toBe(false) + expect(parseWindow({ startDate: '2024-02-29' }).success).toBe(true) + }) + it('refuses a parseable non-date such as a bare month', () => { // `new Date('2026-08')` is August 1. Accepting it returned a window the caller // never asked for, with nothing to indicate the value had been reinterpreted. diff --git a/apps/sim/lib/api/contracts/organization-usage.ts b/apps/sim/lib/api/contracts/organization-usage.ts index 7c6d60b0d24..dbfeea8ecda 100644 --- a/apps/sim/lib/api/contracts/organization-usage.ts +++ b/apps/sim/lib/api/contracts/organization-usage.ts @@ -46,43 +46,21 @@ export const ORGANIZATION_USAGE_BREAKDOWN_MAX_LIMIT = 100 /** * A bare `YYYY-MM-DD` calendar date, and nothing else. * - * Strict on purpose. The picker sends only bare dates — it has no time component — - * and every looser rule tried here has been wrong in a different way: + * `z.iso.date()` is a calendar check rather than a format one — it refuses `2026-02-30` and a + * non-leap `2026-02-29`, so February is never answered about March — and it is pure pattern + * matching, so no input can make it throw. Both matter: a hand-rolled round trip through + * `toISOString` threw on an out-of-range month, and inside a refinement that escapes validation + * entirely and answers a malformed query string with a 500. * - * - `Date.parse` alone accepts `2026-02-30` and rolls it forward, so February was - * answered about March. The round-trip below is what makes this a *calendar* - * check: a day that does not survive re-serialization never existed. - * - Validating only a `YYYY-MM-DD` prefix let `2026-08` through as August 1, and - * `2026-08-01Tgarbage` through as an `Invalid Date` that made the window resolver - * throw from `toISOString` — a 500 for a malformed query string. - * - A datetime with an offset would validate on its date part while the resolver - * read a different UTC day off the full value, so the range shown and the range - * queried could disagree. - * - * Accepting only the one form the client actually sends removes all three at once. + * Absent is allowed and empty is not. A missing bound is a real state — the picker clears the + * param rather than blanking it, and the resolver falls back to the current period — while an + * explicit `?start-date=` is a malformed request that must not silently answer about a different + * window. Deliberately unlike `usageLimitSchema`, which coerces `''` to its declared default; + * these bounds have none, so omitting one changes which period you get. */ -const isoDateSchema = z - .string() +const isoDateSchema = z.iso + .date({ error: 'Expected a calendar date in YYYY-MM-DD form, such as 2026-08-01' }) .optional() - .refine( - (value) => { - /* - Absent is allowed; empty is not. A missing bound is a real state — the picker - clears the param rather than blanking it — and the resolver falls back to the - current period for it. An explicit `?start-date=` is a malformed request, and - treating it as absent silently answered about a different window than the one - asked for. - - Deliberately unlike `usageLimitSchema`, which does coerce `''` to its default: - that field declares a default, so omission has a documented meaning. These - bounds have none — omitting one changes which period you get. - */ - if (value === undefined) return true - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false - return new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value - }, - { message: 'Expected a calendar date in YYYY-MM-DD form, such as 2026-08-01' } - ) /** * A page size that treats an empty or absent parameter as omitted. diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index c86bdf21da4..cc395c4e5fc 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -157,6 +157,28 @@ export const organizationSessionPolicyResponseSchema = z.object({ data: organizationSessionPolicyDataSchema, }) +export const updateOrganizationSsoPolicyBodySchema = z.object({ + requireSso: z.boolean(), +}) + +export type UpdateOrganizationSsoPolicyBody = z.input + +const organizationSsoPolicyDataSchema = z.object({ + /** The stored setting. */ + requireSso: z.boolean(), + /** Whether an identity provider could satisfy the requirement today. */ + hasVerifiedProvider: z.boolean(), + /** Whether sign-in actually enforces it — false once the organization cannot satisfy it. */ + isEnforced: z.boolean(), +}) + +export type OrganizationSsoPolicy = z.output + +export const organizationSsoPolicyResponseSchema = z.object({ + success: z.boolean(), + data: organizationSsoPolicyDataSchema, +}) + export const MAX_ORGANIZATION_DOMAINS = 25 export const organizationDomainParamsSchema = z.object({ @@ -558,6 +580,27 @@ export const updateOrganizationSessionPolicyContract = defineRouteContract({ }, }) +export const getOrganizationSsoPolicyContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/sso-policy', + params: organizationParamsSchema, + response: { + mode: 'json', + schema: organizationSsoPolicyResponseSchema, + }, +}) + +export const updateOrganizationSsoPolicyContract = defineRouteContract({ + method: 'PUT', + path: '/api/organizations/[id]/sso-policy', + params: organizationParamsSchema, + body: updateOrganizationSsoPolicyBodySchema, + response: { + mode: 'json', + schema: organizationSsoPolicyResponseSchema, + }, +}) + export const revokeOrganizationSessionsContract = defineRouteContract({ method: 'POST', path: '/api/organizations/[id]/sessions/revoke', diff --git a/apps/sim/lib/auth/anonymous.ts b/apps/sim/lib/auth/anonymous.ts index 465992f6ddd..c4be061bea0 100644 --- a/apps/sim/lib/auth/anonymous.ts +++ b/apps/sim/lib/auth/anonymous.ts @@ -1,5 +1,4 @@ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' @@ -38,7 +37,7 @@ export async function ensureAnonymousUserExists(): Promise { }) if (!existingStats) { - await db.insert(withInsertColumns(schema.userStats, schema.userStatsColumns)).values({ + await db.insert(schema.userStats).values({ id: generateId(), userId: ANONYMOUS_USER_ID, currentUsageLimit: '10000000000', diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index db651381b50..7d724bed1ec 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1191,7 +1191,17 @@ export const auth = betterAuth({ : []), admin(), oneTimeToken({ - expiresIn: 24 * 60, // 24 hours in minutes (better-auth's expiresIn unit) + /** + * Minutes, and deliberately close to zero. A one-time token redeems through + * `/one-time-token/verify`, which answers with a session cookie for the session the + * token points at — so an unredeemed token is a bearer credential for that session + * until it expires, and its lifetime is the only thing bounding that. Nothing here + * needs a long one: the socket handshake mints a fresh token inside the Socket.IO + * `auth` callback and sends it in that same attempt, and the desktop handoff writes its + * own row with its own expiry, which `/one-time-token/verify` reads off the row rather + * than from this option (see lib/auth/desktop-handoff.ts). + */ + expiresIn: 2, }), customSession(async ({ user, session }) => ({ user, diff --git a/apps/sim/lib/auth/better-auth-error.test.ts b/apps/sim/lib/auth/better-auth-error.test.ts new file mode 100644 index 00000000000..92984909a65 --- /dev/null +++ b/apps/sim/lib/auth/better-auth-error.test.ts @@ -0,0 +1,31 @@ +/** + * @vitest-environment node + */ +import { APIError } from 'better-auth/api' +import { describe, expect, it } from 'vitest' +import { getBetterAuthClientErrorStatus } from '@/lib/auth/better-auth-error' + +describe('getBetterAuthClientErrorStatus', () => { + /** + * Built from real `APIError`s rather than stand-ins: the helper exists because the shape belongs + * to a dependency, so a fixture agreeing with our guess would prove nothing about what the + * routes actually catch. + */ + it.each([ + ['BAD_REQUEST' as const, 400], + ['UNAUTHORIZED' as const, 401], + ['FORBIDDEN' as const, 403], + ])('reads %s as %i', (status, expected) => { + expect(getBetterAuthClientErrorStatus(new APIError(status, { message: 'refused' }))).toBe( + expected + ) + }) + + it('says nothing about a server fault, an ordinary error, or a thrown non-error', () => { + expect( + getBetterAuthClientErrorStatus(new APIError('INTERNAL_SERVER_ERROR', { message: 'boom' })) + ).toBeUndefined() + expect(getBetterAuthClientErrorStatus(new Error('connection reset'))).toBeUndefined() + expect(getBetterAuthClientErrorStatus('invalid token')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/auth/better-auth-error.ts b/apps/sim/lib/auth/better-auth-error.ts new file mode 100644 index 00000000000..7e097754ec5 --- /dev/null +++ b/apps/sim/lib/auth/better-auth-error.ts @@ -0,0 +1,18 @@ +/** + * The 4xx status a Better Auth refusal carries, or `undefined` when the failure is the server's. + * + * Better Auth throws `APIError` for ordinary caller mistakes — an invalid or already-consumed + * reset token, an expired session, a password outside the configured length. A route that catches + * one without reading the status reports a 400-class refusal as a 500: it pages on a routine user + * action and tells the caller the server broke. + * + * Read off the instance rather than with `instanceof`, because `APIError` belongs to a transitive + * dependency and a duplicated copy in the tree would silently defeat the check. + */ +export function getBetterAuthClientErrorStatus(error: unknown): number | undefined { + if (!(error instanceof Error)) return undefined + const statusCode = (error as { statusCode?: unknown }).statusCode + return typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500 + ? statusCode + : undefined +} diff --git a/apps/sim/lib/auth/connectors/managed-oauth.test.ts b/apps/sim/lib/auth/connectors/managed-oauth.test.ts index 8e37e850478..df7f69473fb 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.test.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.test.ts @@ -45,7 +45,6 @@ describe('Atlassian managed OAuth connector', () => { requiresRefreshToken: true, pkce: false, nonceVerification: 'state_only', - includeLoginHint: false, authorizationUrlParams: { audience: 'api.atlassian.com' }, }) expect(fetchMock).toHaveBeenCalledWith( @@ -490,7 +489,6 @@ describe('Microsoft managed OAuth connector', () => { requiresRefreshToken: true, pkce: true, nonceVerification: 'id_token', - includeLoginHint: true, prompt: 'select_account', }) return policy.getAuthorizationAppId(CLIENT_ID) diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts index f32fff1e376..bcd6eacdb14 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -62,14 +62,12 @@ export interface ManagedOAuthConnectorConfig { */ scopeless?: boolean nonceVerification: 'id_token' | 'state_only' - includeLoginHint: boolean prompt?: string authorizationUrlParams?: Record getAuthorizationAppId(clientId: string): string verifyIdentity(params: { tokens: OAuth2Tokens clientId: string - expectedEmail?: string }): Promise hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean isTerminalRefreshError(errorCode: string | undefined): boolean @@ -120,7 +118,6 @@ export function createGoogleManagedOAuthConnector(providerId: string): ManagedOA requiresRefreshToken: true, pkce: true, nonceVerification: 'id_token', - includeLoginHint: true, prompt: 'consent select_account', authorizationUrlParams: { include_granted_scopes: 'false' }, getAuthorizationAppId(clientId) { @@ -199,7 +196,6 @@ export function createAtlassianManagedOAuthConnector( requiresRefreshToken: true, pkce: false, nonceVerification: 'state_only', - includeLoginHint: false, prompt: 'consent', authorizationUrlParams: { audience: 'api.atlassian.com' }, getAuthorizationAppId(clientId) { @@ -350,7 +346,6 @@ export function createMicrosoftManagedOAuthConnector( requiresRefreshToken: true, pkce: true, nonceVerification: 'id_token', - includeLoginHint: true, prompt: 'select_account', getAuthorizationAppId(clientId) { return `microsoft:${createHash('sha256').update(clientId).digest('hex')}` @@ -521,7 +516,6 @@ export function createUserInfoManagedOAuthConnector( requiresRefreshToken: options.requiresRefreshToken, pkce: options.pkce ?? false, nonceVerification: 'state_only', - includeLoginHint: false, ...(options.scopeless ? { scopeless: true } : {}), ...(options.prompt ? { prompt: options.prompt } : {}), ...(options.authorizationUrlParams @@ -659,7 +653,6 @@ function createAttioManagedOAuthConnector(): ManagedOAuthConnectorConfig { requiresRefreshToken: false, pkce: false, nonceVerification: 'state_only', - includeLoginHint: false, getAuthorizationAppId(clientId) { return `attio:${createHash('sha256').update(clientId).digest('hex')}` }, @@ -745,7 +738,6 @@ function createBitbucketManagedOAuthConnector(): ManagedOAuthConnectorConfig { requiresRefreshToken: true, pkce: false, nonceVerification: 'state_only', - includeLoginHint: false, getAuthorizationAppId(clientId) { return `bitbucket:${createHash('sha256').update(clientId).digest('hex')}` }, @@ -985,12 +977,11 @@ const USER_INFO_MANAGED_OAUTH_CONNECTORS = new Map ManagedOAuthCon pkce: true, scopeless: true, nonceVerification: 'state_only', - includeLoginHint: false, getAuthorizationAppId(clientId) { return `github-repositories:${createHash('sha256').update(clientId).digest('hex')}` }, - verifyIdentity({ tokens, expectedEmail }) { - return verifyGitHubRepositoriesIdentity(tokens.accessToken ?? '', expectedEmail) + verifyIdentity({ tokens }) { + return verifyGitHubRepositoriesIdentity(tokens.accessToken ?? '') }, hasRequiredScopes(_grantedScopes, requiredScopes) { return requiredScopes.length === 0 diff --git a/apps/sim/lib/auth/constants.ts b/apps/sim/lib/auth/constants.ts index 6b78258a4bc..6e056669329 100644 --- a/apps/sim/lib/auth/constants.ts +++ b/apps/sim/lib/auth/constants.ts @@ -106,3 +106,13 @@ export function applyRegistrationGate /** The spread widens past what TypeScript can prove; the keys are unchanged. */ return gated as T } + +/** + * How a sign-in refused by an organization's single sign-on requirement identifies itself. Here + * rather than beside the policy so the sign-in and verification screens can recognize the refusal + * without pulling the policy module — and its database dependencies — into the browser bundle. + */ +export const SSO_REQUIRED_ERROR_CODE = 'SSO_REQUIRED' + +export const SSO_REQUIRED_MESSAGE = + 'Your organization requires single sign-on. Sign in through your identity provider.' diff --git a/apps/sim/lib/auth/desktop-handoff.ts b/apps/sim/lib/auth/desktop-handoff.ts index 059013bf0cc..40a321a0243 100644 --- a/apps/sim/lib/auth/desktop-handoff.ts +++ b/apps/sim/lib/auth/desktop-handoff.ts @@ -20,10 +20,10 @@ const HANDOFF_TOKEN_LENGTH = 32 /** * The browser navigates straight to the desktop app's loopback listener once - * the token is minted, so a redeem lands within seconds. Deliberately far - * shorter than the plugin-wide 24h `expiresIn`: this token is a bearer - * credential that grants a session, and `/one-time-token/verify` enforces the - * expiry stored on the row, not the plugin option. + * the token is minted, so a redeem lands within seconds. Kept short because this + * token is a bearer credential that grants a session, and set here rather than + * inherited: `/one-time-token/verify` enforces the expiry stored on the row, so + * this TTL holds whatever the plugin-wide `expiresIn` happens to be. */ const HANDOFF_TOKEN_TTL_MS = 3 * 60 * 1000 diff --git a/apps/sim/lib/auth/oauth-access-token.test.ts b/apps/sim/lib/auth/oauth-access-token.test.ts index 48d7bdb668c..7af7ad3b809 100644 --- a/apps/sim/lib/auth/oauth-access-token.test.ts +++ b/apps/sim/lib/auth/oauth-access-token.test.ts @@ -18,6 +18,7 @@ function row(overrides: Record = {}) { id: 'token-1', userId: 'user-1', clientId: 'sim-cli', + clientName: 'Sim CLI', scopes: ['offline_access', 'api:read'], resource: null, expiresAt: new Date(Date.now() + 60_000), @@ -80,6 +81,7 @@ describe('verifyOAuthAccessToken', () => { kind: 'oauth_access_token', userId: 'user-1', clientId: 'sim-cli', + clientName: 'Sim CLI', tokenId: 'token-1', scopes: ['offline_access', 'api:read'], expiresAt: expect.any(Date), @@ -92,6 +94,13 @@ describe('verifyOAuthAccessToken', () => { ) }) + it('does not invent a display name for an unnamed OAuth client', async () => { + queueTableRows(schemaMock.oauthAccessToken, [row({ clientName: null })]) + const principal = await verifyOAuthAccessToken('sim_oat_secret') + expect(principal).not.toHaveProperty('clientName') + expect(principal.clientId).toBe('sim-cli') + }) + it('refuses a credential that is not one of ours without a database read', async () => { expect(await reason('sim_abc')).toBe('malformed') expect(await reason('sim_oat_')).toBe('malformed') diff --git a/apps/sim/lib/auth/oauth-access-token.ts b/apps/sim/lib/auth/oauth-access-token.ts index 87441af091d..4204d4fb80e 100644 --- a/apps/sim/lib/auth/oauth-access-token.ts +++ b/apps/sim/lib/auth/oauth-access-token.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { oauthAccessToken, oauthClient, user } from '@sim/db/schema' import { createLogger, setRequestAuth } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' -import { eq } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { isAccountBlocked } from '@/lib/auth/ban' import { OAUTH_ACCESS_TOKEN_PREFIX, @@ -99,6 +99,7 @@ export async function verifyOAuthAccessToken( id: oauthAccessToken.id, userId: oauthAccessToken.userId, clientId: oauthAccessToken.clientId, + clientName: sql`left(${oauthClient.name}, 256)`, scopes: oauthAccessToken.scopes, resource: oauthAccessToken.resource, expiresAt: oauthAccessToken.expiresAt, @@ -142,6 +143,7 @@ export async function verifyOAuthAccessToken( kind: 'oauth_access_token', userId: row.userId, clientId: row.clientId, + ...(row.clientName ? { clientName: row.clientName } : {}), tokenId: row.id, scopes: row.scopes, expiresAt: row.expiresAt, diff --git a/apps/sim/lib/auth/oauth-principal.test.ts b/apps/sim/lib/auth/oauth-principal.test.ts index de3ef929e68..bca0bd6af09 100644 --- a/apps/sim/lib/auth/oauth-principal.test.ts +++ b/apps/sim/lib/auth/oauth-principal.test.ts @@ -66,4 +66,12 @@ describe('oauth_access_token principal', () => { parsePrincipal({ ...serialized, principal: { ...serialized.principal, expiresAt: 'soon' } }) ).toThrow('expiresAt must be an ISO timestamp') }) + + it('keeps display metadata out of persisted workflow authority', () => { + const named = { ...principal, clientName: 'Registered app' } + const serialized = serializePrincipal(named) + expect(serialized.principal).not.toHaveProperty('clientName') + expect(parsePrincipal(serialized)).toEqual(principal) + expect(toPrincipalActor(named)).toEqual(toPrincipalActor(principal)) + }) }) diff --git a/apps/sim/lib/auth/session-hooks.test.ts b/apps/sim/lib/auth/session-hooks.test.ts index b7a671c5618..837b92f4226 100644 --- a/apps/sim/lib/auth/session-hooks.test.ts +++ b/apps/sim/lib/auth/session-hooks.test.ts @@ -13,6 +13,7 @@ vi.mock('@/lib/auth/access-control', () => ({ isEmailBlockedByAccessControl: isBlocked, })) +import { SSO_REQUIRED_MESSAGE } from '@/lib/auth/constants' import { runWithAuthDatabase } from '@/lib/auth/database-context' import { prepareSessionForCreation } from '@/lib/auth/session-hooks' import { invalidateSessionPolicyCache } from '@/lib/auth/session-policy' @@ -33,7 +34,13 @@ function transactionExecutor() { limit, executor: { ...db, - select: vi.fn().mockReturnValue({ from: () => ({ where: () => ({ limit }) }) }), + select: vi.fn().mockReturnValue({ + from: () => ({ + where: () => ({ limit }), + innerJoin: () => ({ where: () => ({ limit }) }), + leftJoin: () => ({ where: () => ({ limit }) }), + }), + }), }, } } @@ -70,7 +77,6 @@ describe('prepareSessionForCreation', () => { limit.mockResolvedValueOnce([{ email: 'member@example.com', suspendedAt: null }]) limit.mockResolvedValueOnce([{ organizationId: 'org-1' }]) limit.mockResolvedValueOnce([{ settings: { maxSessionHours: 24 } }]) - limit.mockResolvedValueOnce([{ userId: 'owner-1' }]) limit.mockResolvedValueOnce([{ billingBlocked: false, billingBlockedReason: null }]) limit.mockResolvedValueOnce([{ plan: 'enterprise', status: 'active' }]) @@ -83,10 +89,82 @@ describe('prepareSessionForCreation', () => { expiresAt: new Date('2026-09-09T00:00:00Z'), }, }) - expect(limit).toHaveBeenCalledTimes(6) + expect(limit).toHaveBeenCalledTimes(5) expect(db.select).not.toHaveBeenCalled() }) + it('refuses a member signing in with a password when the organization requires SSO', async () => { + setEnvFlags({ isBillingEnabled: false, isSsoEnabled: true }) + const { executor, limit } = transactionExecutor() + limit.mockResolvedValueOnce([{ email: 'member@example.com', suspendedAt: null }]) + limit.mockResolvedValueOnce([{ organizationId: 'org-1', role: 'member' }]) + limit.mockResolvedValueOnce([{ requireSso: true }]) + limit.mockResolvedValueOnce([{ id: 'provider-1' }]) + + await expect( + runWithAuthDatabase(executor, () => + prepareSessionForCreation(session, { path: '/sign-in/email' }) + ) + ).rejects.toThrow(SSO_REQUIRED_MESSAGE) + }) + + it('still signs in through the identity provider when the membership read fails', async () => { + setEnvFlags({ isBillingEnabled: false, isSsoEnabled: true }) + const { executor, limit } = transactionExecutor() + limit.mockResolvedValueOnce([{ email: 'member@example.com', suspendedAt: null }]) + limit.mockRejectedValueOnce(new Error('connection reset')) + + /** A database blip must not cost a sign-in the requirement would have allowed anyway. */ + await expect( + runWithAuthDatabase(executor, () => + prepareSessionForCreation(session, { path: '/sso/callback/okta' }) + ) + ).resolves.toEqual({ data: session }) + }) + + it('refuses a password sign-in when the membership itself cannot be read', async () => { + setEnvFlags({ isBillingEnabled: false, isSsoEnabled: true }) + const { executor, limit } = transactionExecutor() + limit.mockResolvedValueOnce([{ email: 'member@example.com', suspendedAt: null }]) + limit.mockRejectedValueOnce(new Error('connection reset')) + + /** An unknown membership cannot be read as "no organization requires SSO of this person". */ + await expect( + runWithAuthDatabase(executor, () => + prepareSessionForCreation(session, { path: '/sign-in/email' }) + ) + ).rejects.toThrow('connection reset') + }) + + it('refuses the sign-in when the requirement itself cannot be read', async () => { + setEnvFlags({ isBillingEnabled: false, isSsoEnabled: true }) + const { executor, limit } = transactionExecutor() + limit.mockResolvedValueOnce([{ email: 'member@example.com', suspendedAt: null }]) + limit.mockResolvedValueOnce([{ organizationId: 'org-1', role: 'member' }]) + limit.mockRejectedValueOnce(new Error('connection reset')) + + /** Failing open here would make a transient database error a way around the requirement. */ + await expect( + runWithAuthDatabase(executor, () => + prepareSessionForCreation(session, { path: '/sign-in/email' }) + ) + ).rejects.toThrow('connection reset') + }) + + it('admits the same member through the identity provider', async () => { + setEnvFlags({ isBillingEnabled: false, isSsoEnabled: true }) + const { executor, limit } = transactionExecutor() + limit.mockResolvedValueOnce([{ email: 'member@example.com', suspendedAt: null }]) + limit.mockResolvedValueOnce([{ organizationId: 'org-1', role: 'member' }]) + limit.mockResolvedValueOnce([{ settings: null }]) + + await expect( + runWithAuthDatabase(executor, () => + prepareSessionForCreation(session, { path: '/sso/callback/okta' }) + ) + ).resolves.toMatchObject({ data: { activeOrganizationId: 'org-1' } }) + }) + it('refuses a suspended account before it can receive a session', async () => { const { executor, limit } = transactionExecutor() limit.mockResolvedValueOnce([{ email: 'member@example.com', suspendedAt: createdAt }]) diff --git a/apps/sim/lib/auth/session-hooks.ts b/apps/sim/lib/auth/session-hooks.ts index 7637da5b238..451288ef44f 100644 --- a/apps/sim/lib/auth/session-hooks.ts +++ b/apps/sim/lib/auth/session-hooks.ts @@ -6,11 +6,19 @@ import { eq } from 'drizzle-orm' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { getAuthDatabase } from '@/lib/auth/database-context' import { clampExpiryForSession } from '@/lib/auth/session-policy' +import { assertSsoRequirementSatisfied, satisfiesSsoRequirement } from '@/lib/auth/sso-policy' const logger = createLogger('SessionHooks') -/** Rejects blocked accounts and applies membership policy using the adapter's current transaction. */ -export async function prepareSessionForCreation(session: T) { +/** + * Rejects blocked accounts and applies membership policy using the adapter's current transaction. + * `context` is the endpoint creating the session; its path is what tells an organization's sign-in + * requirement whether this session came from the identity provider. + */ +export async function prepareSessionForCreation( + session: T, + context?: { path?: string } | null +) { const executor = getAuthDatabase() const accessControl = await getAccessControlConfig() const [sessionUser] = await executor @@ -33,21 +41,47 @@ export async function prepareSessionForCreation(session: T) { }) } + /** + * A membership that cannot be read is not a membership that does not exist, so a failed lookup + * refuses the sign-in methods an organization could be requiring against — and only those. Every + * other path keeps the old behavior of continuing without an organization, so a database blip + * does not cost a sign-in to people this setting has nothing to say about. + */ + let membership: { organizationId: string; role: string } | undefined try { - const [membership] = await executor - .select({ organizationId: member.organizationId }) + /** Users belong to at most one organization, the same assumption the expiry clamp makes. */ + ;[membership] = await executor + .select({ organizationId: member.organizationId, role: member.role }) .from(member) .where(eq(member.userId, session.userId)) .limit(1) + } catch (error) { + if (!satisfiesSsoRequirement(context?.path)) throw error + logger.error('Error reading organization membership', { error, userId: session.userId }) + return { data: session } + } - if (!membership) return { data: session } + if (!membership) return { data: session } + /** + * Outside the fallback below on purpose: a requirement that cannot be read is not a requirement + * that does not apply, and admitting a password sign-in because a lookup failed is exactly the + * bypass the setting exists to prevent. + */ + await assertSsoRequirementSatisfied( + { userId: session.userId, ...membership }, + context?.path, + executor + ) + + try { const expiresAt = await clampExpiryForSession(session, membership.organizationId, executor) return { data: { ...session, expiresAt, activeOrganizationId: membership.organizationId }, } } catch (error) { - logger.error('Error setting active organization', { error, userId: session.userId }) - return { data: session } + /** Session policy is an expiry clamp; failing to read it must not cost a valid sign-in. */ + logger.error('Error clamping session expiry', { error, userId: session.userId }) + return { data: { ...session, activeOrganizationId: membership.organizationId } } } } diff --git a/apps/sim/lib/auth/sim-auth-adapter.postgres.test.ts b/apps/sim/lib/auth/sim-auth-adapter.postgres.test.ts index e0c71ca48d9..07389be1950 100644 --- a/apps/sim/lib/auth/sim-auth-adapter.postgres.test.ts +++ b/apps/sim/lib/auth/sim-auth-adapter.postgres.test.ts @@ -5,7 +5,6 @@ import * as schema from '@sim/db/schema' import { withUtcTimestamps } from '@sim/db/timestamps' import { generateId } from '@sim/utils/id' import type { BetterAuthOptions } from 'better-auth' -import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { organization } from 'better-auth/plugins' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' @@ -62,14 +61,10 @@ describe.skipIf(!databaseUrl)('Better Auth across the organization column drop', ? adapter.transaction((tx) => exerciseOrganization(tx)) : exerciseOrganization(adapter) + await client`ALTER TABLE pg_temp.organization ADD COLUMN departed_member_usage numeric NOT NULL DEFAULT 0` await exercise() await client`ALTER TABLE pg_temp.organization DROP COLUMN departed_member_usage` - const unprojected = drizzleAdapter(database, { provider: 'pg', schema })(OPTIONS) - await expect( - unprojected.findOne({ model: 'organization', where: [{ field: 'id', value: 'missing' }] }) - ).rejects.toMatchObject({ cause: { code: '42703' } }) - await exercise() } finally { await client.end() diff --git a/apps/sim/lib/auth/sim-auth-adapter.sql.test.ts b/apps/sim/lib/auth/sim-auth-adapter.sql.test.ts index a4e71fc7337..fbc6610331d 100644 --- a/apps/sim/lib/auth/sim-auth-adapter.sql.test.ts +++ b/apps/sim/lib/auth/sim-auth-adapter.sql.test.ts @@ -1,9 +1,7 @@ /** * @vitest-environment node */ -import * as schema from '@sim/db/schema' import type { BetterAuthOptions } from 'better-auth' -import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { organization } from 'better-auth/plugins' import { drizzle } from 'drizzle-orm/pg-proxy' import { describe, expect, it, vi } from 'vitest' @@ -70,13 +68,4 @@ describe('Better Auth organization SQL', () => { expect(query, operation.name).not.toContain('"departed_member_usage"') } }) - - it('retains the full migration schema while the unprojected adapter remains incompatible', async () => { - const execute = vi.fn(async (_query: string) => ({ rows: [] })) - const adapter = drizzleAdapter(drizzle(execute), { provider: 'pg', schema })(OPTIONS) - - await adapter.findOne({ model: 'organization', where: WHERE }) - - expect(execute.mock.calls[0][0]).toContain('"departed_member_usage"') - }) }) diff --git a/apps/sim/lib/auth/sim-auth-adapter.ts b/apps/sim/lib/auth/sim-auth-adapter.ts index 6ea6be9d05c..b98fe240bbf 100644 --- a/apps/sim/lib/auth/sim-auth-adapter.ts +++ b/apps/sim/lib/auth/sim-auth-adapter.ts @@ -1,5 +1,4 @@ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import * as schema from '@sim/db/schema' import type { BetterAuthOptions } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' @@ -12,12 +11,6 @@ import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' type BetterAuthAdapter = ReturnType> -/** Better Auth's implicit reads, INSERT defaults, and RETURNING must use live columns. */ -const AUTH_SCHEMA = { - ...schema, - organization: withInsertColumns(schema.organization, schema.organizationColumns), -} - /** * Builds every Better Auth adapter surface, including transactional callbacks, * with Sim's write invariants applied to the actual Drizzle connection in use. @@ -29,7 +22,7 @@ export function createSimAuthAdapter( ): BetterAuthAdapter { const base = drizzleAdapter(database, { provider: 'pg', - schema: AUTH_SCHEMA, + schema, transaction: false, })(options) const guarded = guardSubscriptionPlanWrites(guardOAuthProviderWrites(base, database)) diff --git a/apps/sim/lib/auth/sso-policy.test.ts b/apps/sim/lib/auth/sso-policy.test.ts new file mode 100644 index 00000000000..bdb9b5fcadd --- /dev/null +++ b/apps/sim/lib/auth/sso-policy.test.ts @@ -0,0 +1,155 @@ +/** + * @vitest-environment node + */ +import { organization } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsEntitled, mockHasProvider } = vi.hoisted(() => ({ + mockIsEntitled: vi.fn(), + mockHasProvider: vi.fn(), +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationFeatureEntitled: mockIsEntitled, +})) + +vi.mock('@/lib/auth/sso/verified-provider', () => ({ + hasSignInCapableSsoProvider: mockHasProvider, +})) + +import { SSO_REQUIRED_MESSAGE } from '@/lib/auth/constants' +import { + assertSsoRequirementSatisfied, + invalidateSsoPolicyCache, + isSsoRequiredForOrganization, + satisfiesSsoRequirement, +} from '@/lib/auth/sso-policy' + +const ORG_ID = 'org-1' + +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + invalidateSsoPolicyCache(ORG_ID) + invalidateSsoPolicyCache('org-owned') + mockIsEntitled.mockResolvedValue(true) + mockHasProvider.mockResolvedValue(true) +}) + +describe('satisfiesSsoRequirement', () => { + /** + * Better Auth dispatches the hook with the declared endpoint path, so each SSO callback is + * covered in both the declared and the concrete form. The SAML ACS endpoint is the one an + * identity provider posts to when the admin configured Sim from its SP metadata. + */ + it.each([ + '/sso/callback', + '/sso/callback/okta', + '/sso/callback/:providerId', + '/sso/saml2/callback/okta', + '/sso/saml2/callback/:providerId', + '/sso/saml2/sp/acs/okta', + '/sso/saml2/sp/acs/:providerId', + ])('accepts the identity-provider callback %s', (path) => { + expect(satisfiesSsoRequirement(path)).toBe(true) + }) + + it.each(['/one-time-token/verify', '/admin/impersonate-user', '/change-password'])( + 'accepts the derived session path %s', + (path) => { + expect(satisfiesSsoRequirement(path)).toBe(true) + } + ) + + it('accepts a session created outside any endpoint', () => { + expect(satisfiesSsoRequirement(undefined)).toBe(true) + }) + + it.each([ + '/sign-in/email', + '/sign-up/email', + '/verify-email', + '/callback/google', + '/email-otp/verify-email', + '/sign-in/email-otp', + ])('refuses the credential path %s', (path) => { + expect(satisfiesSsoRequirement(path)).toBe(false) + }) + + it('refuses an endpoint it has never seen', () => { + expect(satisfiesSsoRequirement('/sign-in/passkey')).toBe(false) + }) +}) + +describe('isSsoRequiredForOrganization', () => { + it('is false without an organization', async () => { + await expect(isSsoRequiredForOrganization(null)).resolves.toBe(false) + }) + + it('is false while the organization is not entitled to SSO', async () => { + queueTableRows(organization, [{ requireSso: true }]) + mockIsEntitled.mockResolvedValue(false) + await expect(isSsoRequiredForOrganization(ORG_ID)).resolves.toBe(false) + }) + + it('stops enforcing once no provider is left to sign anyone in', async () => { + queueTableRows(organization, [{ requireSso: true }]) + mockHasProvider.mockResolvedValue(false) + await expect(isSsoRequiredForOrganization(ORG_ID)).resolves.toBe(false) + }) + + it('is true for an entitled organization that set the requirement', async () => { + queueTableRows(organization, [{ requireSso: true }]) + await expect(isSsoRequiredForOrganization(ORG_ID)).resolves.toBe(true) + }) + + it('serves a repeat read from cache until it is invalidated', async () => { + queueTableRows(organization, [{ requireSso: true }]) + await expect(isSsoRequiredForOrganization(ORG_ID)).resolves.toBe(true) + await expect(isSsoRequiredForOrganization(ORG_ID)).resolves.toBe(true) + expect(mockIsEntitled).toHaveBeenCalledTimes(1) + + invalidateSsoPolicyCache(ORG_ID) + queueTableRows(organization, [{ requireSso: false }]) + await expect(isSsoRequiredForOrganization(ORG_ID)).resolves.toBe(false) + }) +}) + +describe('assertSsoRequirementSatisfied', () => { + const membership = { userId: 'user-1', organizationId: ORG_ID, role: 'member' } + + it('refuses a password sign-in when the organization requires SSO', async () => { + queueTableRows(organization, [{ requireSso: true }]) + await expect(assertSsoRequirementSatisfied(membership, '/sign-in/email')).rejects.toThrow( + SSO_REQUIRED_MESSAGE + ) + }) + + it('allows a SAML sign-in that lands on the ACS endpoint', async () => { + queueTableRows(organization, [{ requireSso: true }]) + await expect( + assertSsoRequirementSatisfied(membership, '/sso/saml2/sp/acs/:providerId') + ).resolves.toBeUndefined() + }) + + it('leaves owners a password sign-in as a break-glass path', async () => { + queueTableRows(organization, [{ requireSso: true }]) + await expect( + assertSsoRequirementSatisfied({ ...membership, role: 'owner' }, '/sign-in/email') + ).resolves.toBeUndefined() + }) + + it('allows every method while the requirement is off', async () => { + queueTableRows(organization, [{ requireSso: false }]) + await expect( + assertSsoRequirementSatisfied(membership, '/sign-in/email') + ).resolves.toBeUndefined() + }) +}) diff --git a/apps/sim/lib/auth/sso-policy.ts b/apps/sim/lib/auth/sso-policy.ts new file mode 100644 index 00000000000..d2db3ace8d8 --- /dev/null +++ b/apps/sim/lib/auth/sso-policy.ts @@ -0,0 +1,126 @@ +import { db } from '@sim/db' +import { organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { APIError } from 'better-auth/api' +import { eq } from 'drizzle-orm' +import { LRUCache } from 'lru-cache' +import { SSO_REQUIRED_ERROR_CODE, SSO_REQUIRED_MESSAGE } from '@/lib/auth/constants' +import { isSsoCallbackPath } from '@/lib/auth/sso/callback-provider' +import { hasSignInCapableSsoProvider } from '@/lib/auth/sso/verified-provider' +import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' +import { isSsoEnabled } from '@/lib/core/config/env-flags' +import type { DbOrTx } from '@/lib/db/types' + +const logger = createLogger('SsoPolicy') + +/** How long an organization's sign-in requirement is served from process memory. */ +export const SSO_POLICY_CACHE_TTL_MS = 60 * 1000 + +/** + * Serves the settings surface, which reads on the shared connection. Session creation runs inside + * the auth transaction and deliberately bypasses this (see below), so it pays one indexed + * single-row read instead. The ceiling is a memory backstop rather than an operating limit: + * exceeding it only costs that lookup again. + */ +const requirementCache = new LRUCache({ + max: 20_000, + ttl: SSO_POLICY_CACHE_TTL_MS, +}) + +/** + * Whether an organization requires its members to sign in through its identity provider. + * + * The stored setting only enforces while the organization could satisfy it: it stops when the + * organization loses its SSO entitlement, and when no identity provider on a verified domain is + * left to sign anyone in. Both are the same rule — never hold people to a requirement the + * organization can no longer meet — and they make a deleted provider self-healing rather than a + * lockout an owner has to notice and undo. + */ +export async function isSsoRequiredForOrganization( + organizationId: string | null | undefined, + executor: DbOrTx = db +): Promise { + if (!organizationId) return false + + /** Uncommitted reads must neither consume nor populate the shared cache. */ + const cached = executor === db ? requirementCache.get(organizationId) : undefined + if (cached !== undefined) return cached + + const [row] = await executor + .select({ requireSso: organization.requireSso }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + /** + * `onError: 'throw'` because a swallowed read is a permissive answer here: an outage on the + * subscription read would otherwise return "not entitled", drop the requirement, and — worse — + * cache that for the whole TTL. A throw leaves the cache untouched and refuses the sign-in. + */ + /** Stored, entitled, and a provider that could satisfy it — the three terms, cheapest first. */ + const required = + row?.requireSso === true && + (await isOrganizationFeatureEntitled(organizationId, isSsoEnabled, executor, { + onError: 'throw', + })) && + (await hasSignInCapableSsoProvider(organizationId, executor)) + if (executor === db) requirementCache.set(organizationId, required) + return required +} + +/** + * Drops this process's copy. Other instances keep theirs until the TTL expires, so the TTL — not + * this call — is what bounds how long a change takes to reach the whole fleet. + */ +export function invalidateSsoPolicyCache(organizationId: string): void { + requirementCache.delete(organizationId) +} + +/** + * Sessions that carry an earlier sign-in's authority rather than proving identity themselves: the + * desktop handoff, platform-admin impersonation, which support needs while an identity provider is + * broken, and a password change, which re-issues the session of whoever is already signed in. + */ +const DERIVED_SESSION_PATHS = new Set([ + '/one-time-token/verify', + '/admin/impersonate-user', + '/change-password', +]) + +/** + * Whether the endpoint that is creating a session satisfies an SSO requirement. Unknown paths do + * not: a credential endpoint added later should be refused until it is considered, rather than + * quietly becoming a way around the requirement. + * + * A session created outside any endpoint has no path at all, because Better Auth passes the hook + * the request context and there is none. The only such caller is the desktop handoff, which mints + * its session for a browser that is already signed in. + */ +export function satisfiesSsoRequirement(path: string | undefined): boolean { + if (!path) return true + return DERIVED_SESSION_PATHS.has(path) || isSsoCallbackPath(path) +} + +/** + * Refuses a session that an organization's sign-in requirement does not allow. Owners keep every + * sign-in method as a break-glass path, so a broken identity provider cannot lock an organization + * out of its own settings. + */ +export async function assertSsoRequirementSatisfied( + membership: { userId: string; organizationId: string; role: string }, + path: string | undefined, + executor: DbOrTx = db +): Promise { + if (membership.role === 'owner' || satisfiesSsoRequirement(path)) return + if (!(await isSsoRequiredForOrganization(membership.organizationId, executor))) return + + logger.warn('Blocking session creation for an organization that requires SSO', { + userId: membership.userId, + organizationId: membership.organizationId, + path, + }) + throw new APIError('FORBIDDEN', { + code: SSO_REQUIRED_ERROR_CODE, + message: SSO_REQUIRED_MESSAGE, + }) +} diff --git a/apps/sim/lib/auth/sso/application/sso-requirement.test.ts b/apps/sim/lib/auth/sso/application/sso-requirement.test.ts new file mode 100644 index 00000000000..38d576c5742 --- /dev/null +++ b/apps/sim/lib/auth/sso/application/sso-requirement.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthorize, + mockRecordAudit, + mockIsEntitled, + mockHasProvider, + mockIsRequired, + mockInvalidate, +} = vi.hoisted(() => ({ + mockAuthorize: vi.fn(), + mockRecordAudit: vi.fn(), + mockIsEntitled: vi.fn(), + mockHasProvider: vi.fn(), + mockIsRequired: vi.fn(), + mockInvalidate: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) +vi.mock('@/lib/core/application/organization-authorization', () => ({ + authorizeOrganizationOperation: mockAuthorize, +})) +vi.mock('@/lib/core/application/authorized-workspace-use-case', () => ({ + recordProjectedUseCaseAuditEntries: mockRecordAudit, +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationFeatureEntitled: mockIsEntitled, +})) +vi.mock('@/lib/auth/sso/verified-provider', () => ({ + hasSignInCapableSsoProvider: mockHasProvider, +})) +vi.mock('@/lib/auth/sso-policy', () => ({ + invalidateSsoPolicyCache: mockInvalidate, + isSsoRequiredForOrganization: mockIsRequired, +})) + +import { readSsoRequirement, setSsoRequirement } from '@/lib/auth/sso/application/sso-requirement' + +const principal = { kind: 'session', userId: 'u1', sessionId: 's1' } as const +const ORG_ID = 'org1' + +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockAuthorize.mockResolvedValue({ organizationId: ORG_ID, userId: 'u1', role: 'owner' }) + mockIsEntitled.mockResolvedValue(true) + mockHasProvider.mockResolvedValue(true) + mockIsRequired.mockResolvedValue(true) + dbChainMockFns.returning.mockResolvedValue([{ name: 'Acme' }]) +}) + +describe('readSsoRequirement', () => { + it('reports the stored setting alongside whether it is actually enforced', async () => { + queueTableRows(schemaMock.organization, [{ requireSso: true }]) + + await expect( + readSsoRequirement.execute({ principal, input: { organizationId: ORG_ID } }) + ).resolves.toEqual({ requireSso: true, hasVerifiedProvider: true, isEnforced: true }) + expect(mockAuthorize).toHaveBeenCalledWith(principal, readSsoRequirement.operation, { + organizationId: ORG_ID, + }) + }) + + it('reports a stored requirement that nothing can satisfy as not enforced', async () => { + queueTableRows(schemaMock.organization, [{ requireSso: true }]) + mockHasProvider.mockResolvedValue(false) + mockIsRequired.mockResolvedValue(false) + + await expect( + readSsoRequirement.execute({ principal, input: { organizationId: ORG_ID } }) + ).resolves.toMatchObject({ requireSso: true, isEnforced: false }) + }) + + it('reads an unknown organization as not found', async () => { + queueTableRows(schemaMock.organization, []) + await expect( + readSsoRequirement.execute({ principal, input: { organizationId: ORG_ID } }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) +}) + +describe('setSsoRequirement', () => { + const run = (requireSso: boolean) => + setSsoRequirement.execute({ principal, input: { organizationId: ORG_ID, requireSso } }) + + it('turns the requirement on, invalidates the cache, and records the change', async () => { + await expect(run(true)).resolves.toEqual({ + requireSso: true, + hasVerifiedProvider: true, + isEnforced: true, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ requireSso: true })) + expect(mockInvalidate).toHaveBeenCalledWith(ORG_ID) + expect(mockRecordAudit).toHaveBeenCalledTimes(1) + }) + + it('refuses to require SSO with no provider that could satisfy it', async () => { + mockHasProvider.mockResolvedValue(false) + await expect(run(true)).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('refuses to require SSO without the entitlement', async () => { + mockIsEntitled.mockResolvedValue(false) + await expect(run(true)).rejects.toMatchObject({ code: 'forbidden' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('can always be turned off, even with no entitlement and no provider left', async () => { + mockIsEntitled.mockResolvedValue(false) + mockHasProvider.mockResolvedValue(false) + + await expect(run(false)).resolves.toMatchObject({ requireSso: false, isEnforced: false }) + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ requireSso: false })) + expect(mockInvalidate).toHaveBeenCalledWith(ORG_ID) + }) + + it('changes nothing when authorization refuses', async () => { + mockAuthorize.mockRejectedValue(new Error('forbidden')) + await expect(run(true)).rejects.toThrow('forbidden') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/auth/sso/application/sso-requirement.ts b/apps/sim/lib/auth/sso/application/sso-requirement.ts new file mode 100644 index 00000000000..19c8666a8f4 --- /dev/null +++ b/apps/sim/lib/auth/sso/application/sso-requirement.ts @@ -0,0 +1,157 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { organization } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { hasSignInCapableSsoProvider } from '@/lib/auth/sso/verified-provider' +import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' +import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' +import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { isBillingEnabled, isSsoEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** + * permission-group-exempt: The sign-in requirement is managed by organization owners and administrators, the same gate as the rest of SSO settings. + */ +export const readSsoRequirementOperation = defineOrganizationOperation({ + id: 'organization.sso.read_requirement', + minimumRole: 'member', + principalKinds: ['session'], + capability: 'none', +}) + +/** + * permission-group-exempt: The sign-in requirement is managed by organization owners and administrators, the same gate as the rest of SSO settings. + */ +export const setSsoRequirementOperation = defineOrganizationOperation({ + id: 'organization.sso.set_requirement', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'none', +}) + +export interface SsoRequirement { + /** The stored setting. */ + requireSso: boolean + /** Whether an identity provider could satisfy it today. */ + hasVerifiedProvider: boolean + /** Whether sign-in actually enforces it. */ + isEnforced: boolean +} + +async function loadRequirement(organizationId: string): Promise { + const [org] = await db + .select({ requireSso: organization.requireSso }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + if (!org) throw new OrchestrationError('not_found', 'Organization not found') + + /** Read fresh rather than through the sign-in cache: an admin is waiting on this answer. */ + const [hasVerifiedProvider, entitled] = await Promise.all([ + hasSignInCapableSsoProvider(organizationId), + isOrganizationFeatureEntitled(organizationId, isSsoEnabled), + ]) + /** The same three terms sign-in checks, so the surface reports what is actually enforced. */ + return { + requireSso: org.requireSso, + hasVerifiedProvider, + isEnforced: org.requireSso && entitled && hasVerifiedProvider, + } +} + +export const readSsoRequirement: OperationUseCase< + typeof readSsoRequirementOperation, + { organizationId: string }, + SsoRequirement +> = { + operation: readSsoRequirementOperation, + async execute({ principal, input }) { + const { organizationId } = await authorizeOrganizationOperation( + principal, + readSsoRequirementOperation, + input + ) + return loadRequirement(organizationId) + }, +} + +export interface SetSsoRequirementInput { + organizationId: string + requireSso: boolean +} + +/** + * Turns the sign-in requirement on or off. It is read when a session is created, so a change ends + * no session that already exists — signing everyone out stays the separate revoke action. + */ +export const setSsoRequirement: OperationUseCase< + typeof setSsoRequirementOperation, + SetSsoRequirementInput, + SsoRequirement +> = { + operation: setSsoRequirementOperation, + async execute({ principal, input, request }) { + const { organizationId } = await authorizeOrganizationOperation( + principal, + setSsoRequirementOperation, + input + ) + + /** + * Only turning the requirement on needs the entitlement. Turning it off must stay possible + * after an organization loses SSO, or the stored setting would resume enforcing the moment + * the entitlement came back, with no administrator action behind it. + */ + if (input.requireSso && !(await isOrganizationFeatureEntitled(organizationId, isSsoEnabled))) { + throw new OrchestrationError( + 'forbidden', + isBillingEnabled + ? 'Single Sign-On is available on Enterprise plans only' + : 'Single Sign-On is disabled. Set ENTERPRISE_ENABLED or SSO_ENABLED to enable it.' + ) + } + + const hasVerifiedProvider = await hasSignInCapableSsoProvider(organizationId) + if (input.requireSso && !hasVerifiedProvider) { + throw new OrchestrationError( + 'conflict', + 'Add an identity provider on a verified domain before requiring single sign-on' + ) + } + + const [updated] = await db + .update(organization) + .set({ requireSso: input.requireSso, updatedAt: new Date() }) + .where(eq(organization.id, organizationId)) + .returning({ name: organization.name }) + if (!updated) throw new OrchestrationError('not_found', 'Organization not found') + + invalidateSsoPolicyCache(organizationId) + + recordProjectedUseCaseAuditEntries( + setSsoRequirementOperation, + null, + principal, + request, + [ + { + action: AuditAction.ORGANIZATION_SSO_POLICY_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + resourceName: updated.name, + description: input.requireSso + ? 'Required single sign-on' + : 'Stopped requiring single sign-on', + metadata: { requireSso: input.requireSso }, + }, + ], + organizationId + ) + + /** Everything the requirement needs was just checked, so storing it is enforcing it. */ + return { requireSso: input.requireSso, hasVerifiedProvider, isEnforced: input.requireSso } + }, +} diff --git a/apps/sim/lib/auth/sso/callback-provider.ts b/apps/sim/lib/auth/sso/callback-provider.ts index 7fb46d9663d..733fd6cd297 100644 --- a/apps/sim/lib/auth/sso/callback-provider.ts +++ b/apps/sim/lib/auth/sso/callback-provider.ts @@ -6,6 +6,19 @@ const DYNAMIC_SSO_CALLBACK_PATHS = new Set([ const CONCRETE_SSO_CALLBACK_PATH = /^\/sso\/(?:callback|saml2\/callback|saml2\/sp\/acs)\/([^/]+)$/ +/** + * Whether the endpoint is one the SSO plugin mints sessions from. Better Auth + * dispatches with the declared path (`/sso/callback/:providerId`), while other + * callers see the concrete one, so both forms are recognized. + */ +export function isSsoCallbackPath(path: string): boolean { + return ( + DYNAMIC_SSO_CALLBACK_PATHS.has(path) || + path === '/sso/callback' || + CONCRETE_SSO_CALLBACK_PATH.test(path) + ) +} + export interface SsoCallbackProviderContext { path: string routeProviderId?: string diff --git a/apps/sim/lib/auth/sso/verified-provider.ts b/apps/sim/lib/auth/sso/verified-provider.ts new file mode 100644 index 00000000000..5125fe09a4e --- /dev/null +++ b/apps/sim/lib/auth/sso/verified-provider.ts @@ -0,0 +1,23 @@ +import { db } from '@sim/db' +import { ssoProvider } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' + +/** + * Whether the organization has an identity provider that can sign someone in. Domain verification + * is what sign-in resolution requires of a provider, so this asks exactly that and no more — a + * stricter test here would stop enforcing a requirement that sign-in can still satisfy. + */ +export async function hasSignInCapableSsoProvider( + organizationId: string, + executor: DbOrTx = db +): Promise { + const [row] = await executor + .select({ id: ssoProvider.id }) + .from(ssoProvider) + .where( + and(eq(ssoProvider.organizationId, organizationId), eq(ssoProvider.domainVerified, true)) + ) + .limit(1) + return row !== undefined +} diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-activity.test.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-activity.test.ts new file mode 100644 index 00000000000..59aaaa23e16 --- /dev/null +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-activity.test.ts @@ -0,0 +1,121 @@ +/** @vitest-environment node */ +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authority: vi.fn(), + entitlement: vi.fn(), + subscription: vi.fn(), + summary: vi.fn(), + breakdown: vi.fn(), + workspace: vi.fn(), +})) + +vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({ + canUserManageBillingEntity: mocks.authority, +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationFeatureEntitled: mocks.entitlement, +})) +vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: mocks.subscription })) +vi.mock('@/lib/billing/core/organization-activity-queries', () => ({ + readActivitySummary: mocks.summary, + readActivityBreakdown: mocks.breakdown, + readActivityWorkspace: mocks.workspace, +})) + +import { + getOrganizationActivityBreakdown, + getOrganizationActivitySummary, +} from '@/lib/billing/application/organization-usage/get-organization-activity' +import { activityMetrics } from '@/lib/billing/core/organization-activity' + +const principal: SessionPrincipal = { kind: 'session', userId: 'admin', sessionId: 'session' } +const input = { + organizationId: 'org', + preset: 'custom' as const, + timezone: 'America/Los_Angeles', + startDate: new Date('2026-03-08'), + endDate: new Date('2026-03-09'), + workspaceId: 'workspace', +} +const breakdownInput = { + ...input, + dimension: 'workflow' as const, + sort: 'failures' as const, + page: 2, +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.authority.mockResolvedValue(true) + mocks.entitlement.mockResolvedValue(true) + mocks.subscription.mockResolvedValue(null) + mocks.workspace.mockResolvedValue({ id: 'workspace', name: 'Support' }) + mocks.summary.mockResolvedValue({ totals: activityMetrics(), series: [] }) + mocks.breakdown.mockResolvedValue({ rows: [], hasMore: false }) +}) + +describe.each([ + [ + 'summary', + (actor: Principal) => getOrganizationActivitySummary.execute({ principal: actor, input }), + ], + [ + 'breakdown', + (actor: Principal) => + getOrganizationActivityBreakdown.execute({ principal: actor, input: breakdownInput }), + ], +] as const)('organization activity %s authorization', (_name, run) => { + it('rejects API keys before loading organization data', async () => { + await expect( + run({ kind: 'personal_api_key', userId: 'admin', keyId: 'key' }) + ).rejects.toMatchObject({ detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED' }) + expect(mocks.authority).not.toHaveBeenCalled() + expect(mocks.workspace).not.toHaveBeenCalled() + }) + + it('requires current organization admin authority before checking entitlement or reading activity', async () => { + mocks.authority.mockResolvedValue(false) + await expect(run(principal)).rejects.toMatchObject({ + detailCode: 'ORGANIZATION_ADMIN_REQUIRED', + }) + expect(mocks.authority).toHaveBeenCalledWith({ type: 'organization', id: 'org' }, 'admin') + expect(mocks.entitlement).not.toHaveBeenCalled() + expect(mocks.workspace).not.toHaveBeenCalled() + expect(mocks.summary).not.toHaveBeenCalled() + expect(mocks.breakdown).not.toHaveBeenCalled() + }) + + it('enforces the enterprise or self-hosted entitlement', async () => { + mocks.entitlement.mockResolvedValue(false) + await expect(run(principal)).rejects.toMatchObject({ detailCode: 'ENTERPRISE_PLAN_REQUIRED' }) + expect(mocks.workspace).not.toHaveBeenCalled() + }) + + it('rejects a foreign or deleted workspace before any activity aggregation', async () => { + mocks.workspace.mockResolvedValue(null) + await expect(run(principal)).rejects.toThrow('Workspace not found') + expect(mocks.workspace).toHaveBeenCalledWith('org', 'workspace') + expect(mocks.summary).not.toHaveBeenCalled() + expect(mocks.breakdown).not.toHaveBeenCalled() + }) +}) + +it('uses the same authorized scope and timezone for summaries and paginated breakdowns', async () => { + const summary = await getOrganizationActivitySummary.execute({ principal, input }) + await getOrganizationActivityBreakdown.execute({ principal, input: breakdownInput }) + const scope = { + organizationId: 'org', + workspaceId: 'workspace', + start: new Date('2026-03-08T08:00:00Z'), + end: new Date('2026-03-10T07:00:00Z'), + } + expect(mocks.summary).toHaveBeenCalledWith(scope, 'day', 'America/Los_Angeles') + expect(mocks.breakdown).toHaveBeenCalledWith(scope, 'workflow', 'failures', 2) + expect(summary.workspace).toEqual({ id: 'workspace', name: 'Support' }) + expect(summary.series).toEqual([ + { timestamp: '2026-03-08T00:00:00', workflowRuns: 0, chatRuns: 0, failed: 0 }, + { timestamp: '2026-03-09T00:00:00', workflowRuns: 0, chatRuns: 0, failed: 0 }, + ]) +}) diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-activity.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-activity.ts new file mode 100644 index 00000000000..730caf17697 --- /dev/null +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-activity.ts @@ -0,0 +1,81 @@ +import { + type AuthorizedOrganizationUsageContext, + defineAuthorizedOrganizationUsageUseCase, +} from '@/lib/billing/application/organization-usage/authorized-organization-usage-use-case' +import type { OrganizationUsageSummaryInput } from '@/lib/billing/application/organization-usage/get-organization-usage-summary' +import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' +import type { ActivityDimension, ActivitySort } from '@/lib/billing/core/organization-activity' +import { + readActivityBreakdown, + readActivitySummary, + readActivityWorkspace, +} from '@/lib/billing/core/organization-activity-queries' +import { + resolveUsageAnalyticsWindow, + resolveUsageBucket, + usageBucketTimestamps, + usageWindowBounds, +} from '@/lib/billing/core/usage-analytics' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +interface OrganizationActivityBreakdownInput extends OrganizationUsageSummaryInput { + dimension: ActivityDimension + sort: ActivitySort + page: number +} + +async function resolveActivityScope( + input: OrganizationUsageSummaryInput, + context: AuthorizedOrganizationUsageContext +) { + const workspace = input.workspaceId + ? await readActivityWorkspace(context.organizationId, input.workspaceId) + : null + if (input.workspaceId && !workspace) { + throw new OrchestrationError('not_found', 'Workspace not found') + } + const window = resolveUsageAnalyticsWindow({ + preset: input.preset, + period: context.period, + customStart: input.startDate, + customEnd: input.endDate, + timezone: input.timezone, + }) + return { + window, + workspace, + scope: { + organizationId: context.organizationId, + workspaceId: workspace?.id, + ...usageWindowBounds(window), + }, + } +} + +export const getOrganizationActivitySummary = defineAuthorizedOrganizationUsageUseCase({ + operation: organizationUsageOperations.readActivitySummary, + organizationId: (input: OrganizationUsageSummaryInput) => input.organizationId, + async execute({ input, context }) { + const { window, workspace, scope } = await resolveActivityScope(input, context) + const bucket = resolveUsageBucket(window) + const result = await readActivitySummary(scope, bucket, input.timezone) + const byTimestamp = new Map(result.series.map((point) => [point.timestamp, point])) + return { + workspace, + totals: result.totals, + series: usageBucketTimestamps(window, bucket, input.timezone).map( + (timestamp) => + byTimestamp.get(timestamp) ?? { timestamp, workflowRuns: 0, chatRuns: 0, failed: 0 } + ), + } + }, +}) + +export const getOrganizationActivityBreakdown = defineAuthorizedOrganizationUsageUseCase({ + operation: organizationUsageOperations.readActivityBreakdown, + organizationId: (input: OrganizationActivityBreakdownInput) => input.organizationId, + async execute({ input, context }) { + const { scope } = await resolveActivityScope(input, context) + return readActivityBreakdown(scope, input.dimension, input.sort, input.page) + }, +}) diff --git a/apps/sim/lib/billing/application/organization-usage/operations.ts b/apps/sim/lib/billing/application/organization-usage/operations.ts index 75a234a5358..8038552f325 100644 --- a/apps/sim/lib/billing/application/organization-usage/operations.ts +++ b/apps/sim/lib/billing/application/organization-usage/operations.ts @@ -48,6 +48,18 @@ const BASE = { * nothing outside the type system ever sees. */ export const organizationUsageOperations = { + // permission-group-exempt: aggregate organization activity is governed by organization admin authority, not a workspace permission group + readActivitySummary: defineOrganizationUsageOperation({ + id: 'organization_usage.activity.summary.read', + capability: 'none', + ...BASE, + }), + // permission-group-exempt: organization activity breakdowns require the same organization admin authority as the summary + readActivityBreakdown: defineOrganizationUsageOperation({ + id: 'organization_usage.activity.breakdown.read', + capability: 'none', + ...BASE, + }), // permission-group-exempt: the organization's pooled ledger is authorized by organization billing-admin authority, which no workspace-shaped group key names readSummary: defineOrganizationUsageOperation({ id: 'organization_usage.summary.read', diff --git a/apps/sim/lib/billing/calculations/usage-reservation.test.ts b/apps/sim/lib/billing/calculations/usage-reservation.test.ts index 8a020ce48e7..fbfd597f996 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation.test.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation.test.ts @@ -2,7 +2,9 @@ * @vitest-environment node */ import { redisConfigMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { generateId } from '@sim/utils/id' +import Redis from 'ioredis' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { refreshExecutionSlotExpiry, releaseExecutionSlot, @@ -528,3 +530,99 @@ describe('usage-reservation', () => { }) }) }) + +const redisUrl = process.env.BILLING_USAGE_TEST_REDIS_URL +if (redisUrl) { + const target = new URL(redisUrl) + if ( + target.protocol !== 'redis:' || + !['localhost', '127.0.0.1', '[::1]'].includes(target.hostname) + ) { + throw new Error('Usage reservation integration tests require a disposable local Redis') + } +} + +describe.runIf(Boolean(redisUrl))('pooled usage reservations with Redis', () => { + let redis: Redis + const reservations: string[] = [] + const payer = { type: 'organization' as const, id: generateId() } + + beforeAll(async () => { + redis = new Redis(redisUrl!, { lazyConnect: true, maxRetriesPerRequest: 0 }) + await redis.connect() + }) + + beforeEach(() => { + setEnvFlags({ isHosted: true, isBillingEnabled: true }) + redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis) + }) + + afterEach(async () => { + await Promise.all(reservations.splice(0).map(releaseExecutionSlot)) + }) + + afterAll(async () => { + await redis?.quit() + }) + + function params(actorUserId = generateId()) { + const reservationId = generateId() + reservations.push(reservationId) + return { + billingEntity: payer, + reservationId, + plan: 'enterprise' as const, + currentUsage: 0, + limit: 0.05, + member: { organizationId: payer.id, actorUserId, currentUsage: 0, limit: 0.01 }, + } + } + + it('shares payer headroom across 100 concurrent requests from different members', async () => { + const requests = Array.from({ length: 100 }, () => params()) + const results = await Promise.all(requests.map(reserveExecutionSlot)) + expect(results.filter((result) => result.reserved)).toHaveLength(10) + expect(results.filter((result) => !result.reserved)).toEqual( + Array.from({ length: 90 }, () => ({ reserved: false, reason: 'payer_headroom' })) + ) + expect( + await reserveExecutionSlot({ + ...params(), + billingEntity: { type: 'organization', id: generateId() }, + member: undefined, + }) + ).toEqual({ reserved: true, created: true }) + }) + + it('isolates member caps inside the shared payer without consuming rejected slots', async () => { + const memberA = generateId() + const memberB = generateId() + const results = await Promise.all( + Array.from({ length: 40 }, (_, index) => + reserveExecutionSlot(params(index < 20 ? memberA : memberB)) + ) + ) + expect(results.slice(0, 20).filter((result) => result.reserved)).toHaveLength(2) + expect(results.slice(20).filter((result) => result.reserved)).toHaveLength(2) + expect(results.filter((result) => !result.reserved)).toEqual( + Array.from({ length: 36 }, () => ({ reserved: false, reason: 'member_headroom' })) + ) + expect(await reserveExecutionSlot(params())).toEqual({ reserved: true, created: true }) + }) + + it('preserves duplicate ownership and queued-worker refresh without new admission', async () => { + const request = params() + const results = await Promise.all( + Array.from({ length: 20 }, () => reserveExecutionSlot(request)) + ) + expect(results.filter((result) => result.reserved && result.created)).toHaveLength(1) + expect(results.every((result) => result.reserved)).toBe(true) + expect(await refreshExecutionSlotExpiry(request.reservationId, Date.now() + 60_000)).toBe(true) + await releaseExecutionSlot(request.reservationId) + expect(await refreshExecutionSlotExpiry(request.reservationId, Date.now() + 60_000)).toBe(false) + expect(await reserveExecutionSlot({ ...params(), currentUsage: 0.05 })).toEqual({ + reserved: false, + reason: 'payer_headroom', + }) + }) +}) diff --git a/apps/sim/lib/billing/core/access.test.ts b/apps/sim/lib/billing/core/access.test.ts index 9ae1dee42c9..42acfbd867f 100644 --- a/apps/sim/lib/billing/core/access.test.ts +++ b/apps/sim/lib/billing/core/access.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { getBillingEntityBlockStatus, getEffectiveBillingStatus } from '@/lib/billing/core/access' @@ -119,8 +119,9 @@ describe('getBillingEntityBlockStatus', () => { * block this organization's workspaces. */ it("reads the owner's own row and stops there", async () => { - queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) - queueTableRows(schemaMock.userStats, [{ billingBlocked: false, billingBlockedReason: null }]) + queueTableRows(schemaMock.member, [ + { userId: 'owner-1', billingBlocked: false, billingBlockedReason: null }, + ]) queueTableRows(schemaMock.member, [{ organizationId: 'unrelated-org' }]) queueTableRows(schemaMock.userStats, [{ blocked: true, blockedReason: 'dispute' }]) @@ -130,22 +131,31 @@ describe('getBillingEntityBlockStatus', () => { billingBlocked: false, billingBlockedReason: null, }) - }) - - it("blocks when the owner's own row is blocked", async () => { - queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) - queueTableRows(schemaMock.userStats, [ - { billingBlocked: true, billingBlockedReason: 'payment_failed' }, - ]) - - await expect( - getBillingEntityBlockStatus({ type: 'organization', id: 'org-1' }) - ).resolves.toEqual({ - billingBlocked: true, - billingBlockedReason: 'payment_failed', + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.leftJoin).toHaveBeenCalledWith(schemaMock.userStats, { + type: 'eq', + left: schemaMock.userStats.userId, + right: schemaMock.member.userId, }) }) + it.each(['payment_failed', 'dispute'])( + "blocks when the owner's own row is blocked for %s", + async (reason) => { + queueTableRows(schemaMock.member, [ + { userId: 'owner-1', billingBlocked: true, billingBlockedReason: reason }, + ]) + + await expect( + getBillingEntityBlockStatus({ type: 'organization', id: 'org-1' }) + ).resolves.toEqual({ + billingBlocked: true, + billingBlockedReason: reason, + }) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + } + ) + it('is not blocked when the organization has no owner row', async () => { queueTableRows(schemaMock.member, []) @@ -155,6 +165,41 @@ describe('getBillingEntityBlockStatus', () => { billingBlocked: false, billingBlockedReason: null, }) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + + it('preserves the unblocked result when the owner has no stats row', async () => { + queueTableRows(schemaMock.member, [ + { userId: 'owner-1', billingBlocked: null, billingBlockedReason: null }, + ]) + await expect( + getBillingEntityBlockStatus({ type: 'organization', id: 'org-1' }) + ).resolves.toEqual({ billingBlocked: false, billingBlockedReason: null }) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + + it('reads a changed payer block on the next call', async () => { + queueTableRows(schemaMock.member, [ + { userId: 'owner-1', billingBlocked: false, billingBlockedReason: 'dispute' }, + ]) + queueTableRows(schemaMock.member, [ + { userId: 'owner-1', billingBlocked: true, billingBlockedReason: 'dispute' }, + ]) + await expect( + getBillingEntityBlockStatus({ type: 'organization', id: 'org-1' }) + ).resolves.toEqual({ billingBlocked: false, billingBlockedReason: null }) + await expect( + getBillingEntityBlockStatus({ type: 'organization', id: 'org-1' }) + ).resolves.toEqual({ billingBlocked: true, billingBlockedReason: 'dispute' }) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + }) + + it('propagates a failed payer-status read', async () => { + const failure = new Error('database unavailable') + dbChainMockFns.limit.mockRejectedValueOnce(failure) + await expect(getBillingEntityBlockStatus({ type: 'organization', id: 'org-1' })).rejects.toBe( + failure + ) }) }) }) diff --git a/apps/sim/lib/billing/core/access.ts b/apps/sim/lib/billing/core/access.ts index c9917d69991..07832f99ecc 100644 --- a/apps/sim/lib/billing/core/access.ts +++ b/apps/sim/lib/billing/core/access.ts @@ -17,35 +17,6 @@ export interface BillingEntityBlockStatus { billingBlockedReason: 'payment_failed' | 'dispute' | null } -/** - * Reads one user's own `user_stats` row, without re-deriving the block that - * their organization membership would imply. - * - * Only the organization branch of {@link getBillingEntityBlockStatus} wants this - * narrow read: an organization's debt is its owner's own debt, and some other - * org the owner merely belongs to is not this organization's problem. Callers - * asking whether a *user* is blocked want {@link getEffectiveBillingStatus}. - */ -async function getUserStatsBlockStatus( - userId: string, - executor: DbOrTx -): Promise { - const [stats] = await executor - .select({ - billingBlocked: userStats.billingBlocked, - billingBlockedReason: userStats.billingBlockedReason, - }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - const billingBlocked = Boolean(stats?.billingBlocked) - return { - billingBlocked, - billingBlockedReason: billingBlocked ? (stats?.billingBlockedReason ?? null) : null, - } -} - /** * Reads the effective block state of one payer, personal or organization. * @@ -72,8 +43,12 @@ export async function getBillingEntityBlockStatus( } const [owner] = await executor - .select({ userId: member.userId }) + .select({ + billingBlocked: userStats.billingBlocked, + billingBlockedReason: userStats.billingBlockedReason, + }) .from(member) + .leftJoin(userStats, eq(userStats.userId, member.userId)) .where(and(eq(member.organizationId, billingEntity.id), eq(member.role, 'owner'))) .limit(1) @@ -85,7 +60,11 @@ export async function getBillingEntityBlockStatus( return { billingBlocked: false, billingBlockedReason: null } } - return getUserStatsBlockStatus(owner.userId, executor) + const billingBlocked = Boolean(owner.billingBlocked) + return { + billingBlocked, + billingBlockedReason: billingBlocked ? (owner.billingBlockedReason ?? null) : null, + } } /** diff --git a/apps/sim/lib/billing/core/organization-activity-queries.ts b/apps/sim/lib/billing/core/organization-activity-queries.ts new file mode 100644 index 00000000000..2f8df406a3e --- /dev/null +++ b/apps/sim/lib/billing/core/organization-activity-queries.ts @@ -0,0 +1,199 @@ +import { dbReplica } from '@sim/db' +import { + copilotChats, + copilotRuns, + user, + workflow, + workflowExecutionLogs, + workspace, +} from '@sim/db/schema' +import { and, eq, type SQL, sql } from 'drizzle-orm' +import { + ACTIVITY_PAGE_SIZE, + type ActivityAggregate, + type ActivityDimension, + type ActivityScope, + type ActivitySort, + activityMetrics, +} from '@/lib/billing/core/organization-activity' +import type { UsageBucket } from '@/lib/billing/core/usage-analytics' + +export async function readActivityWorkspace(organizationId: string, workspaceId: string) { + const [row] = await dbReplica + .select({ id: workspace.id, name: workspace.name }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), eq(workspace.organizationId, organizationId))) + .limit(1) + return row ?? null +} + +/** + * Scope by the owning workspace or organization chat, never the user's memberships. + * Only lightweight execution columns are read; transcripts and trace payloads stay private. + * Chat continuations share an execution id and belong to their first retained start. + */ +function activityGroups( + scope: ActivityScope, + keys: SQL, + grouping: SQL, + dimension?: ActivityDimension +) { + const start = sql`(${scope.start.toISOString()}::timestamptz AT TIME ZONE 'UTC')` + const end = sql`(${scope.end.toISOString()}::timestamptz AT TIME ZONE 'UTC')` + const workflows = sql` + SELECT l.workspace_id, l.workflow_id, + l.trigger, l.started_at, l.status, + CASE WHEN l.status IN ('completed', 'failed') AND l.total_duration_ms >= 0 + THEN l.total_duration_ms END AS duration_ms + FROM ${workflowExecutionLogs} l + JOIN ${workspace} w ON w.id = l.workspace_id + WHERE w.organization_id = ${scope.organizationId} + AND l.started_at >= ${start} AND l.started_at < ${end} + ${scope.workspaceId ? sql`AND l.workspace_id = ${scope.workspaceId}` : sql``} + ` + /** Separate ownership branches let Postgres use the workspace and organization chat indexes. */ + const workspaceChats = sql` + SELECT c.id, c.workspace_id FROM ${copilotChats} c + JOIN ${workspace} w ON w.id = c.workspace_id + WHERE w.organization_id = ${scope.organizationId} + ${scope.workspaceId ? sql`AND c.workspace_id = ${scope.workspaceId}` : sql``} + ` + const scopedChats = scope.workspaceId + ? workspaceChats + : sql`${workspaceChats} UNION ALL + SELECT c.id, c.workspace_id FROM ${copilotChats} c + WHERE c.organization_id = ${scope.organizationId}` + const chats = sql` + SELECT DISTINCT ON (r.execution_id) + c.workspace_id, r.user_id AS member_id, r.started_at + FROM ${copilotRuns} r + JOIN (${scopedChats}) c ON c.id = r.chat_id + WHERE r.started_at >= ${start} AND r.started_at < ${end} + AND NOT EXISTS ( + SELECT 1 FROM ${copilotRuns} earlier + WHERE earlier.execution_id = r.execution_id AND earlier.started_at < ${start} + ) + ORDER BY r.execution_id, r.started_at, r.id + ` + const workflowGroups = sql` + SELECT ${keys}, count(*) AS "workflowRuns", + count(*) FILTER (WHERE a.status = 'completed') AS completed, + count(*) FILTER (WHERE a.status = 'failed') AS failed, + 0::bigint AS "chatRuns", 0::bigint AS "chatMembers", + avg(a.duration_ms) AS "averageDurationMs" + FROM (${workflows}) a ${grouping} + ` + const chatGroups = sql` + SELECT ${keys}, 0::bigint AS "workflowRuns", 0::bigint AS completed, 0::bigint AS failed, + count(*) AS "chatRuns", count(DISTINCT a.member_id) AS "chatMembers", + NULL::numeric AS "averageDurationMs" + FROM (${chats}) a ${grouping} + ` + if (dimension === 'member') return chatGroups + if (dimension === 'workflow' || dimension === 'trigger') return workflowGroups + return sql`(${workflowGroups}) UNION ALL (${chatGroups})` +} + +/** Each group has at most one workflow average and one exact chat-member count. */ +const aggregates = sql` + sum(a."workflowRuns") AS "workflowRuns", + sum(a.completed) AS completed, + sum(a.failed) AS failed, + sum(a."chatRuns") AS "chatRuns", + sum(a."chatMembers") AS "chatMembers", + max(a."averageDurationMs") AS "averageDurationMs" +` + +export async function readActivitySummary( + scope: ActivityScope, + bucket: UsageBucket, + timezone: string +) { + const keys = sql`date_trunc(${bucket}, (a.started_at AT TIME ZONE 'UTC') AT TIME ZONE ${timezone}) AS bucket` + const rows = await dbReplica.execute(sql` + WITH activity AS (${activityGroups(scope, keys, sql`GROUP BY GROUPING SETS ((1), ())`)}) + SELECT to_char(a.bucket, 'YYYY-MM-DD') AS bucket, ${aggregates} + FROM activity a GROUP BY a.bucket + `) + return { + totals: activityMetrics(rows.find((row) => row.bucket === null)), + series: rows.flatMap((row) => + row.bucket === null + ? [] + : [ + { + timestamp: `${row.bucket}T00:00:00`, + workflowRuns: Number(row.workflowRuns), + chatRuns: Number(row.chatRuns), + failed: Number(row.failed), + }, + ] + ), + } +} + +/** Aggregation and pagination happen in Postgres; no run history is materialized in the app. */ +export async function readActivityBreakdown( + scope: ActivityScope, + dimension: ActivityDimension, + sort: ActivitySort, + page: number +) { + const id = { + workspace: sql`coalesce(a.workspace_id, 'organization')`, + workflow: sql`coalesce(a.workflow_id, 'deleted:' || a.workspace_id)`, + member: sql`a.member_id`, + trigger: sql`a.trigger`, + }[dimension] + const label = { + workspace: sql`coalesce(w.name, 'Organization chats')`, + workflow: sql`coalesce(f.name, 'Deleted workflows')`, + member: sql`coalesce(u.name, 'Deleted member')`, + trigger: sql`a.id`, + }[dimension] + const hasWorkspace = dimension === 'workspace' || dimension === 'workflow' + const workspaceId = hasWorkspace ? sql`a.workspace_id` : sql`NULL::text` + const workspaceName = hasWorkspace ? sql`w.name` : sql`NULL::text` + const order = { + runs: sql`("workflowRuns" + "chatRuns") DESC`, + failures: sql`failed DESC`, + duration: sql`"averageDurationMs" DESC NULLS LAST`, + }[sort] + const rows = await dbReplica.execute< + ActivityAggregate & { + id: string + label: string + workspaceId: string | null + workspaceName: string | null + } + >(sql` + WITH activity AS (${activityGroups( + scope, + sql`${id} AS id, ${workspaceId} AS "workspaceId"`, + sql`GROUP BY 1, 2`, + dimension + )}), grouped AS ( + SELECT a.id, a."workspaceId", ${aggregates} + FROM activity a + GROUP BY 1, 2 + ), named AS ( + SELECT a.*, ${label} AS label, ${workspaceName} AS "workspaceName" + FROM grouped a + ${hasWorkspace ? sql`LEFT JOIN ${workspace} w ON w.id = a."workspaceId"` : sql``} + ${dimension === 'workflow' ? sql`LEFT JOIN ${workflow} f ON f.id = a.id` : sql``} + ${dimension === 'member' ? sql`LEFT JOIN ${user} u ON u.id = a.id` : sql``} + ) + SELECT * FROM named ORDER BY ${order}, label, id + LIMIT ${ACTIVITY_PAGE_SIZE + 1} OFFSET ${page * ACTIVITY_PAGE_SIZE} + `) + return { + rows: rows.slice(0, ACTIVITY_PAGE_SIZE).map((row) => ({ + id: row.id, + label: row.label, + workspaceId: row.workspaceId, + workspaceName: row.workspaceName, + ...activityMetrics(row), + })), + hasMore: rows.length > ACTIVITY_PAGE_SIZE, + } +} diff --git a/apps/sim/lib/billing/core/organization-activity.postgres.test.ts b/apps/sim/lib/billing/core/organization-activity.postgres.test.ts new file mode 100644 index 00000000000..088c768a64d --- /dev/null +++ b/apps/sim/lib/billing/core/organization-activity.postgres.test.ts @@ -0,0 +1,248 @@ +/** @vitest-environment node */ +import { generateId } from '@sim/utils/id' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const { databaseUrl, execute, select } = vi.hoisted(() => { + const databaseUrl = process.env.BILLING_USAGE_TEST_DATABASE_URL + if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) { + throw new Error('Activity integration tests require a disposable local database') + } + return { databaseUrl, execute: vi.fn(), select: vi.fn() } +}) + +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') +vi.mock('@sim/db', () => ({ dbReplica: { execute, select } })) + +import { + readActivityBreakdown, + readActivitySummary, + readActivityWorkspace, +} from '@/lib/billing/core/organization-activity-queries' +import { + resolveUsageAnalyticsWindow, + usageBucketTimestamps, +} from '@/lib/billing/core/usage-analytics' + +const schemaName = `activity_${generateId().replaceAll('-', '')}` +const connection = databaseUrl + ? postgres(databaseUrl, { + max: 1, + prepare: false, + fetch_types: false, + connection: { search_path: schemaName, timezone: 'Pacific/Auckland' }, + onnotice: () => undefined, + }) + : undefined +const database = connection ? drizzle(connection) : undefined +const scope = { + organizationId: 'org', + start: new Date('2026-03-08T08:00:00Z'), + end: new Date('2026-03-10T07:00:00Z'), +} + +beforeAll(async () => { + if (!connection || !database) return + await connection.unsafe(`CREATE SCHEMA "${schemaName}"`) + await connection.unsafe(` + CREATE TABLE workspace (id text PRIMARY KEY, name text, organization_id text); + CREATE TABLE workflow (id text PRIMARY KEY, name text); + CREATE TABLE "user" (id text PRIMARY KEY, name text); + CREATE TABLE workflow_execution_logs (id text PRIMARY KEY, workspace_id text, workflow_id text, + trigger text, started_at timestamp, status text, total_duration_ms integer); + CREATE TABLE copilot_chats (id text PRIMARY KEY, workspace_id text, organization_id text); + CREATE TABLE copilot_runs (id text PRIMARY KEY, chat_id text, execution_id text, user_id text, started_at timestamp); + INSERT INTO workspace VALUES ('w1', 'Support', 'org'), ('w2', 'Sales', 'org'), ('foreign', 'Private', 'other'); + INSERT INTO workflow VALUES ('f1', 'Triage'), ('f2', 'Follow up'); + INSERT INTO "user" VALUES ('m1', 'Alex'), ('m2', 'Sam'); + INSERT INTO workflow_execution_logs VALUES + ('l1', 'w1', 'f1', 'manual', '2026-03-08 08:00:00', 'completed', 1000), + ('l2', 'w1', 'f1', 'api', '2026-03-09 06:59:59', 'failed', 3000), + ('l3', 'w1', 'f1', 'schedule', '2026-03-09 07:00:00', 'paused', 999999), + ('l4', 'w2', 'f2', 'api', '2026-03-09 10:00:00', 'cancelled', 999999), + ('l5', 'w2', NULL, 'webhook', '2026-03-09 11:00:00', 'running', NULL), + ('before', 'w1', 'f1', 'manual', '2026-03-08 07:59:59', 'failed', 999999), + ('end', 'w1', 'f1', 'manual', '2026-03-10 07:00:00', 'failed', 999999), + ('private', 'foreign', 'f1', 'manual', '2026-03-09 10:00:00', 'failed', 999999); + INSERT INTO copilot_chats VALUES ('c1', 'w1', NULL), ('c2', NULL, 'org'), + ('c3', 'foreign', NULL), ('personal', NULL, NULL); + INSERT INTO copilot_runs VALUES + ('r1', 'c1', 'e1', 'm1', '2026-03-08 08:00:00'), + ('r2', 'c1', 'e1', 'm1', '2026-03-09 10:00:00'), + ('r3', 'c2', 'e2', 'm1', '2026-03-09 10:00:00'), + ('r4', 'c2', 'e3', 'm2', '2026-03-09 10:00:00'), + ('r5', 'c1', 'old', 'm2', '2026-03-01 08:00:00'), + ('r6', 'c1', 'old', 'm2', '2026-03-09 10:00:00'), + ('r7', 'c3', 'foreign', 'm1', '2026-03-09 10:00:00'), + ('r8', 'personal', 'personal', 'm1', '2026-03-09 10:00:00'); + INSERT INTO workspace VALUES ('edge1', 'First', 'edge'), ('edge2', 'Second', 'edge'); + INSERT INTO workflow_execution_logs VALUES + ('edge0', 'edge1', 'f1', 'api', '2026-05-01 00:00:00', 'completed', 0), + ('edge100', 'edge1', 'f1', 'api', '2026-05-02 00:00:00', 'completed', 100), + ('edge300', 'edge2', 'f2', 'manual', '2026-05-02 00:00:00', 'completed', 300), + ('negative', 'edge2', 'f2', 'manual', '2026-05-03 00:00:00', 'failed', -1), + ('missing', 'edge2', 'f2', 'manual', '2026-05-03 00:00:00', 'completed', NULL); + INSERT INTO copilot_chats VALUES ('edge-chat1', 'edge1', NULL), + ('edge-chat2', 'edge2', NULL), ('edge-org-chat', NULL, 'edge'); + INSERT INTO copilot_runs VALUES + ('edge-r1', 'edge-chat1', 'edge-e1', 'm1', '2026-05-01 00:00:00'), + ('edge-r2', 'edge-chat2', 'edge-e2', 'm1', '2026-05-02 00:00:00'), + ('edge-r3', 'edge-org-chat', 'edge-e3', 'm1', '2026-05-03 00:00:00'); + `) + execute.mockImplementation((query) => database.execute(query)) + select.mockImplementation((fields) => database.select(fields)) +}) + +afterAll(async () => { + if (!connection) return + await connection.unsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`) + await connection.end() +}) + +describe.skipIf(!databaseUrl)('organization activity SQL', () => { + it('isolates tenants and personal chats, deduplicates continuations, and excludes unfinished durations', async () => { + const result = await readActivitySummary(scope, 'day', 'America/Los_Angeles') + expect(result.totals).toEqual({ + workflowRuns: 5, + completed: 1, + failed: 1, + chatRuns: 3, + chatMembers: 2, + failureRate: 0.5, + averageDurationMs: 2000, + }) + expect(result.series.toSorted((a, b) => a.timestamp.localeCompare(b.timestamp))).toEqual([ + { timestamp: '2026-03-08T00:00:00', workflowRuns: 2, chatRuns: 1, failed: 1 }, + { timestamp: '2026-03-09T00:00:00', workflowRuns: 3, chatRuns: 2, failed: 0 }, + ]) + }) + + it('uses a half-open local calendar window across daylight saving time', () => { + const window = resolveUsageAnalyticsWindow({ + preset: 'custom', + customStart: new Date('2026-03-08'), + customEnd: new Date('2026-03-09'), + timezone: 'America/Los_Angeles', + period: { start: scope.start, end: scope.end, source: 'stripe' }, + }) + expect(window).toEqual({ kind: 'range', from: scope.start, to: scope.end }) + expect(usageBucketTimestamps(window, 'day', 'America/Los_Angeles')).toEqual([ + '2026-03-08T00:00:00', + '2026-03-09T00:00:00', + ]) + }) + + it('scopes workspace summaries and rejects foreign workspace lookups', async () => { + const result = await readActivitySummary({ ...scope, workspaceId: 'w1' }, 'day', 'UTC') + expect(result.totals).toMatchObject({ workflowRuns: 3, chatRuns: 1, chatMembers: 1 }) + expect(await readActivityWorkspace('org', 'foreign')).toBeNull() + expect(await readActivityWorkspace('org', 'w1')).toEqual({ id: 'w1', name: 'Support' }) + }) + + it('keeps missing terminal outcomes distinct from zero failure and ranks the complete population', async () => { + const result = await readActivityBreakdown(scope, 'workspace', 'failures', 0) + expect(result.rows[0]).toMatchObject({ id: 'w1', failed: 1, failureRate: 0.5 }) + expect(result.rows.find((row) => row.id === 'w2')).toMatchObject({ + failureRate: null, + averageDurationMs: null, + }) + expect(result.rows.find((row) => row.id === 'organization')).toMatchObject({ chatRuns: 2 }) + expect(result.rows.reduce((sum, row) => sum + row.workflowRuns, 0)).toBe(5) + expect(result.rows.reduce((sum, row) => sum + row.chatRuns, 0)).toBe(3) + }) + + it('supports all grouping dimensions without attributing workflows to billed members', async () => { + const members = await readActivityBreakdown(scope, 'member', 'runs', 0) + expect(members.rows.map((row) => [row.id, row.chatRuns, row.workflowRuns])).toEqual([ + ['m1', 2, 0], + ['m2', 1, 0], + ]) + const workflows = await readActivityBreakdown(scope, 'workflow', 'duration', 0) + expect(workflows.rows[0]).toMatchObject({ id: 'f1', averageDurationMs: 2000, workflowRuns: 3 }) + expect(workflows.rows.find((row) => row.id === 'deleted:w2')).toMatchObject({ + label: 'Deleted workflows', + }) + const triggers = await readActivityBreakdown(scope, 'trigger', 'runs', 0) + expect(triggers.rows[0]).toMatchObject({ id: 'api', workflowRuns: 2 }) + }) + + it('paginates aggregated rows deterministically without losing tied rows', async () => { + if (!connection) throw new Error('Missing fixture') + await connection`INSERT INTO workflow_execution_logs + SELECT 'page-' || i, 'w1', 'f1', 'trigger-' || lpad(i::text, 2, '0'), + '2026-04-01'::timestamp, 'completed', 0 FROM generate_series(1, 27) i` + const pageScope = { ...scope, start: new Date('2026-04-01'), end: new Date('2026-04-02') } + const first = await readActivityBreakdown(pageScope, 'trigger', 'runs', 0) + const second = await readActivityBreakdown(pageScope, 'trigger', 'runs', 1) + expect(first.rows).toHaveLength(25) + expect(first.hasMore).toBe(true) + expect(second.rows).toHaveLength(2) + expect(second.hasMore).toBe(false) + expect(new Set([...first.rows, ...second.rows].map((row) => row.id)).size).toBe(27) + expect(first.rows[0]).toMatchObject({ averageDurationMs: 0, failureRate: 0 }) + }) + + it('returns true zeros and null rates for an empty organization', async () => { + expect( + (await readActivitySummary({ ...scope, organizationId: 'empty' }, 'day', 'UTC')).totals + ).toEqual({ + workflowRuns: 0, + completed: 0, + failed: 0, + chatRuns: 0, + chatMembers: 0, + failureRate: null, + averageDurationMs: null, + }) + }) + + it('counts members across the whole period and weights durations by eligible runs', async () => { + const edgeScope = { + organizationId: 'edge', + start: new Date('2026-05-01'), + end: new Date('2026-05-04'), + } + const result = await readActivitySummary(edgeScope, 'day', 'UTC') + expect(result.totals).toMatchObject({ + workflowRuns: 5, + completed: 4, + failed: 1, + chatRuns: 3, + chatMembers: 1, + failureRate: 0.2, + }) + expect(result.totals.averageDurationMs).toBeCloseTo(400 / 3) + const breakdown = await readActivityBreakdown(edgeScope, 'workspace', 'duration', 0) + expect(breakdown.rows.map((row) => [row.id, row.averageDurationMs, row.chatMembers])).toEqual([ + ['edge2', 300, 1], + ['edge1', 50, 1], + ['organization', null, 1], + ]) + }) + + it.each(['day', 'week', 'month'] as const)('preserves totals with %s buckets', async (bucket) => { + const result = await readActivitySummary(scope, bucket, 'Pacific/Auckland') + expect(result.totals).toMatchObject({ workflowRuns: 5, chatRuns: 3, chatMembers: 2 }) + expect(result.series.reduce((sum, point) => sum + point.workflowRuns, 0)).toBe(5) + expect(result.series.reduce((sum, point) => sum + point.chatRuns, 0)).toBe(3) + }) + + it('returns workflow-only and chat-only periods without dropping either source', async () => { + const workflowOnly = await readActivitySummary({ ...scope, workspaceId: 'w2' }, 'day', 'UTC') + expect(workflowOnly.totals).toMatchObject({ workflowRuns: 2, chatRuns: 0, chatMembers: 0 }) + const chatOnly = await readActivitySummary( + { ...scope, start: new Date('2026-03-01'), end: new Date('2026-03-02') }, + 'day', + 'UTC' + ) + expect(chatOnly.totals).toMatchObject({ + workflowRuns: 0, + chatRuns: 1, + chatMembers: 1, + failureRate: null, + averageDurationMs: null, + }) + }) +}) diff --git a/apps/sim/lib/billing/core/organization-activity.ts b/apps/sim/lib/billing/core/organization-activity.ts new file mode 100644 index 00000000000..c93a10dee57 --- /dev/null +++ b/apps/sim/lib/billing/core/organization-activity.ts @@ -0,0 +1,48 @@ +export const ACTIVITY_DIMENSIONS = ['workspace', 'workflow', 'member', 'trigger'] as const +export type ActivityDimension = (typeof ACTIVITY_DIMENSIONS)[number] + +export const ACTIVITY_SORTS = ['runs', 'failures', 'duration'] as const +export type ActivitySort = (typeof ACTIVITY_SORTS)[number] +export const ACTIVITY_PAGE_SIZE = 25 +export const ACTIVITY_MAX_PAGE = 1000 + +export interface ActivityMetrics { + workflowRuns: number + completed: number + failed: number + chatRuns: number + chatMembers: number + failureRate: number | null + averageDurationMs: number | null +} + +export interface ActivityScope { + organizationId: string + workspaceId?: string + start: Date + end: Date +} + +export type ActivityAggregate = { + workflowRuns: string | number + completed: string | number + failed: string | number + chatRuns: string | number + chatMembers: string | number + averageDurationMs: string | number | null +} + +/** Terminal failures exclude cancelled, paused, and still-running executions. */ +export function activityMetrics(row?: ActivityAggregate): ActivityMetrics { + const completed = Number(row?.completed ?? 0) + const failed = Number(row?.failed ?? 0) + return { + workflowRuns: Number(row?.workflowRuns ?? 0), + completed, + failed, + chatRuns: Number(row?.chatRuns ?? 0), + chatMembers: Number(row?.chatMembers ?? 0), + failureRate: completed + failed > 0 ? failed / (completed + failed) : null, + averageDurationMs: row?.averageDurationMs == null ? null : Number(row.averageDurationMs), + } +} diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index 6281dfa4bb6..d33afcd4753 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -1,12 +1,5 @@ import { db } from '@sim/db' -import { - member, - organization, - organizationColumns, - usageLog, - user, - userStats, -} from '@sim/db/schema' +import { member, organization, usageLog, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, gte, lt, sql } from 'drizzle-orm' import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' @@ -196,7 +189,7 @@ export async function getOrganizationBillingData( try { // Get organization info const orgRecord = await executor - .select(organizationColumns) + .select() .from(organization) .where(eq(organization.id, organizationId)) .limit(1) @@ -375,7 +368,7 @@ export async function updateOrganizationUsageLimit( try { // Validate the organization exists const orgRecord = await db - .select(organizationColumns) + .select() .from(organization) .where(eq(organization.id, organizationId)) .limit(1) diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index dbf656a9fef..f9637992983 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -9,6 +9,7 @@ import { schemaMock, setEnvFlags, } from '@sim/testing' +import { inArray } from 'drizzle-orm' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -77,6 +78,7 @@ import { hasWorkspaceLiveSyncAccess, hasWorkspaceSandboxAccess, hasWorkspaceSandboxRetentionAccess, + isOrganizationGovernanceActive, isOrganizationOnEnterprisePlan, isWorkspaceOnEnterprisePlan, resolveOrganizationPlan, @@ -586,6 +588,76 @@ describe('resolveOrganizationPlan', () => { }) }) +describe('isOrganizationGovernanceActive', () => { + const ORGANIZATION_ID = 'org-governed' + + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isBillingEnabled: true, isHosted: true }) + mockIsOrganizationBillingBlocked.mockResolvedValue(false) + mockCheckEnterprisePlan.mockReturnValue(true) + }) + + it('governs an organization holding an active enterprise plan', async () => { + dbChainMockFns.limit.mockResolvedValue([{ plan: 'enterprise', status: 'active' }]) + + await expect(isOrganizationGovernanceActive(ORGANIZATION_ID)).resolves.toBe(true) + }) + + /** + * The bug this exists for: an unentitled organization resolves to `config: null`, which denies + * nothing, so treating a failing card as a lapsed plan lifted every restriction the organization + * had configured — silently, for the whole dunning window. + */ + it('keeps governing through a past-due subscription', async () => { + dbChainMockFns.limit.mockResolvedValue([{ plan: 'enterprise', status: 'past_due' }]) + + await expect(isOrganizationGovernanceActive(ORGANIZATION_ID)).resolves.toBe(true) + /** + * Asserted on the filter, not the returned row: the chain mock answers whatever is queued + * regardless of the where clause, so only the status set proves a past-due subscription is + * actually read. The feature gate below deliberately narrows to `active`. + */ + expect(vi.mocked(inArray)).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining(['active', 'past_due']) + ) + }) + + it('reads a narrower status set than the feature gate does', async () => { + dbChainMockFns.limit.mockResolvedValue([{ plan: 'enterprise', status: 'active' }]) + + await isOrganizationOnEnterprisePlan('org-feature-gate') + expect(vi.mocked(inArray)).not.toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining(['past_due']) + ) + }) + + /** A suspension is a billing state, not a decision to stop governing. */ + it('keeps governing a billing-blocked organization', async () => { + mockIsOrganizationBillingBlocked.mockResolvedValue(true) + dbChainMockFns.limit.mockResolvedValue([{ plan: 'enterprise', status: 'past_due' }]) + + await expect(isOrganizationGovernanceActive(ORGANIZATION_ID)).resolves.toBe(true) + }) + + it('stops governing an organization with no subscription at all', async () => { + dbChainMockFns.limit.mockResolvedValue([]) + + await expect(isOrganizationGovernanceActive(ORGANIZATION_ID)).resolves.toBe(false) + }) + + /** A read failure must never read as "no restrictions". */ + it('propagates a failed subscription read rather than answering false', async () => { + dbChainMockFns.limit.mockRejectedValue(new Error('billing database unavailable')) + + await expect(isOrganizationGovernanceActive(ORGANIZATION_ID)).rejects.toThrow( + 'billing database unavailable' + ) + }) +}) + describe('isOrganizationOnEnterprisePlan', () => { const ORGANIZATION_ID = 'org-1' diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 9b5c3717a92..10b709b54bd 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -157,9 +157,9 @@ export async function syncSubscriptionPlan( } /** - * Get the organization's subscription row when its status is one of - * `USABLE_SUBSCRIPTION_STATUSES` (product access — stricter than - * `ENTITLED_SUBSCRIPTION_STATUSES` which also includes `past_due`). + * Get the organization's subscription row when its status is one of `statuses`, which defaults to + * `USABLE_SUBSCRIPTION_STATUSES` (product access — stricter than `ENTITLED_SUBSCRIPTION_STATUSES`, + * which also includes `past_due`). * Use this for feature-gating ("can this org use the product right * now"). Use `getOrganizationSubscription` (from `core/billing.ts`) * when you need the billing-side entitlement row that includes @@ -168,13 +168,22 @@ export async function syncSubscriptionPlan( interface GetOrganizationSubscriptionUsableOptions { onError?: 'return-null' | 'throw' executor?: DbOrTx + /** + * Which statuses count. Defaults to the usable set; a caller that governs behavior rather than + * granting a feature passes the entitled set, so a dunning window does not read as no plan. + */ + statuses?: readonly string[] } export async function getOrganizationSubscriptionUsable( organizationId: string, options: GetOrganizationSubscriptionUsableOptions = {} ) { - const { onError = 'return-null', executor = db } = options + const { + onError = 'return-null', + executor = db, + statuses = USABLE_SUBSCRIPTION_STATUSES, + } = options try { const [orgSub] = await executor .select() @@ -182,7 +191,7 @@ export async function getOrganizationSubscriptionUsable( .where( and( eq(subscription.referenceId, organizationId), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) + inArray(subscription.status, [...statuses]) ) ) .limit(1) @@ -437,12 +446,13 @@ export function isSubscriptionBackedEntitlement(): boolean { * `'return-false'` (the default) fails closed for a *feature* gate: the feature * is hidden, and the worst outcome is a button that is briefly missing. * + * Whether a permission-group regime *applies* is a different axis and is not asked here — see + * {@link isOrganizationGovernanceActive}, where a swallowed failure would lift restrictions. + * * `'throw'` is for callers where "no Enterprise plan" is not a smaller answer - * but a different regime. Access Control resolves to `config: null` when the - * organization is not entitled, and `null` means *every* capability allowed and - * every allowlist off — so a swallowed subscription-read failure would silently - * disable the whole permission-group regime for the request instead of - * surfacing an error. Those callers must pass `'throw'`. + * but a different regime — SCIM deprovisioning and knowledge availability, where answering + * "not entitled" on a failed read would silently widen access rather than narrow it. Those + * callers must pass `'throw'`. * * A primitive rather than an options object on purpose: `cache()` keys on the * argument list, and a fresh object literal per call would miss the memo every @@ -569,6 +579,35 @@ export async function resolveOrganizationPlan( */ export const isOrganizationOnEnterprisePlan = cache(resolveOrganizationEnterprisePlan) +/** + * Whether an organization's permission-group regime governs its members. + * + * Deliberately not {@link isOrganizationOnEnterprisePlan}. That answers "may this organization use + * an Enterprise feature", where withholding the feature during a payment failure is the safe + * direction. Governance is the opposite: an organization that is not entitled resolves to + * `config: null`, and `null` denies nothing — so reading a past-due card as a lapsed plan would + * *lift* every restriction the organization configured, silently, for the whole dunning window. + * + * So this accepts every entitled status rather than only the usable ones, and does not consult the + * billing block: neither an unpaid invoice nor a suspension is a decision to stop governing. Read + * failures always throw for the same reason — a swallowed error would read as "no restrictions". + */ +async function resolveOrganizationGovernancePlan( + organizationId: string, + executor: DbOrTx = db +): Promise { + if (!isSubscriptionBackedEntitlement()) return true + + const orgSub = await getOrganizationSubscriptionUsable(organizationId, { + executor, + onError: 'throw', + statuses: ENTITLED_SUBSCRIPTION_STATUSES, + }) + return !!orgSub && checkEnterprisePlan(orgSub) +} + +export const isOrganizationGovernanceActive = cache(resolveOrganizationGovernancePlan) + /** * Entitlement for a single org-scoped enterprise feature. * @@ -589,10 +628,11 @@ export const isOrganizationOnEnterprisePlan = cache(resolveOrganizationEnterpris export async function isOrganizationFeatureEntitled( organizationId: string, selfHostEntitlement: boolean, - executor: DbOrTx = db + executor: DbOrTx = db, + options: { onError?: EnterprisePlanErrorPolicy } = {} ): Promise { if (!isBillingEnabled) return selfHostEntitlement - return isOrganizationOnEnterprisePlan(organizationId, 'return-false', executor) + return isOrganizationOnEnterprisePlan(organizationId, options.onError ?? 'return-false', executor) } /** diff --git a/apps/sim/lib/billing/core/usage-analytics-queries.postgres.test.ts b/apps/sim/lib/billing/core/usage-analytics-queries.postgres.test.ts new file mode 100644 index 00000000000..ca3d743268d --- /dev/null +++ b/apps/sim/lib/billing/core/usage-analytics-queries.postgres.test.ts @@ -0,0 +1,77 @@ +/** @vitest-environment node */ +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const { databaseUrl, select } = vi.hoisted(() => { + const databaseUrl = process.env.BILLING_USAGE_TEST_DATABASE_URL + if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) { + throw new Error('Usage integration tests require a disposable local database') + } + return { databaseUrl, select: vi.fn() } +}) + +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') +vi.mock('@sim/db', () => ({ dbReplica: { select } })) + +import { usageLog } from '@sim/db/schema' +import { readUsageTimeSeries } from '@/lib/billing/core/usage-analytics-queries' + +const schemaName = `usage_series_${generateId().replaceAll('-', '')}` +const connection = databaseUrl + ? postgres(databaseUrl, { + max: 1, + prepare: false, + connection: { search_path: schemaName, timezone: 'Pacific/Auckland' }, + onnotice: () => undefined, + }) + : undefined + +beforeAll(async () => { + if (!connection) return + await connection.unsafe(`CREATE SCHEMA "${schemaName}"`) + await connection.unsafe(` + CREATE TABLE usage_log (billing_entity_id text, created_at timestamp, cost numeric); + INSERT INTO usage_log VALUES + ('org', '2026-03-08 08:00:00+00', 0.1), + ('org', '2026-03-09 06:59:59+00', 0.2), + ('org', '2026-03-09 07:00:00+00', 0.4), + ('other', '2026-03-09 07:00:00+00', 999); + `) + const database = drizzle(connection) + select.mockImplementation((fields) => database.select(fields)) +}) + +afterAll(async () => { + if (!connection) return + await connection.unsafe(`DROP SCHEMA "${schemaName}" CASCADE`) + await connection.end() +}) + +describe.skipIf(!databaseUrl)('usage series SQL', () => { + it('groups the viewer calendar across DST and preserves numeric event counts', async () => { + const rows = await readUsageTimeSeries( + [eq(usageLog.billingEntityId, 'org')], + 'day', + 'America/Los_Angeles' + ) + expect( + rows.toSorted((a, b) => String(a.bucketStart).localeCompare(String(b.bucketStart))) + ).toEqual([ + { bucketStart: '2026-03-08T00:00:00', cost: '0.3', events: 2 }, + { bucketStart: '2026-03-09T00:00:00', cost: '0.4', events: 1 }, + ]) + }) + + it('formats one monthly aggregate and returns no buckets for an empty scope', async () => { + expect( + await readUsageTimeSeries([eq(usageLog.billingEntityId, 'org')], 'month', 'UTC') + ).toEqual([{ bucketStart: '2026-03-01T00:00:00', cost: '0.7', events: 3 }]) + expect( + await readUsageTimeSeries([eq(usageLog.billingEntityId, 'empty')], 'day', 'UTC') + ).toEqual([]) + }) +}) diff --git a/apps/sim/lib/billing/core/usage-analytics-queries.ts b/apps/sim/lib/billing/core/usage-analytics-queries.ts index f804a83ab66..7b734ec8f42 100644 --- a/apps/sim/lib/billing/core/usage-analytics-queries.ts +++ b/apps/sim/lib/billing/core/usage-analytics-queries.ts @@ -32,27 +32,28 @@ export async function readUsageTimeSeries( executor: DbClient = dbReplica ): Promise { assertValidTimezone(timezone) - const bucketStart = sql`to_char( - date_trunc(${bucket}, ${usageLog.createdAt} AT TIME ZONE ${timezone}), - 'YYYY-MM-DD"T"HH24:MI:SS' - )` - - return ( - executor - .select({ - bucketStart: bucketStart.as('bucket_start'), - cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, - events: sql`COUNT(*)`.mapWith(Number), - }) - .from(usageLog) - .where(and(...scope)) - // Group by the output alias, not the expression. Re-rendering the fragment here - // emits a *textually different* one — the select list qualifies the column as - // `created_at`, the group-by as `usage_log.created_at` — and Postgres matches - // group-by expressions syntactically, so it rejects the query outright. It also - // duplicates the bound parameters. - .groupBy(sql`bucket_start`) - ) + const buckets = executor + .select({ + bucketStart: + sql`date_trunc(${bucket}, (${usageLog.createdAt} AT TIME ZONE 'UTC') AT TIME ZONE ${timezone})`.as( + 'bucket_start' + ), + cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`.as('cost'), + events: sql`COUNT(*)`.mapWith(Number).as('events'), + }) + .from(usageLog) + .where(and(...scope)) + .groupBy(sql`bucket_start`) + .as('buckets') + + /** Format the aggregated buckets rather than every ledger entry. */ + return executor + .select({ + bucketStart: sql`to_char(${buckets.bucketStart}, 'YYYY-MM-DD"T"HH24:MI:SS')`, + cost: buckets.cost, + events: buckets.events, + }) + .from(buckets) } export interface UsageTotals { diff --git a/apps/sim/lib/billing/core/usage-analytics.ts b/apps/sim/lib/billing/core/usage-analytics.ts index d50a71dbafa..736a01bdedc 100644 --- a/apps/sim/lib/billing/core/usage-analytics.ts +++ b/apps/sim/lib/billing/core/usage-analytics.ts @@ -407,12 +407,28 @@ export function densifyUsageSeries( if (row.bucketStart) byBucket.set(row.bucketStart.slice(0, 10), row) } + return usageBucketTimestamps(window, bucket, timezone).map((timestamp) => { + const row = byBucket.get(timestamp.slice(0, 10)) + return { + timestamp, + cost: toNumber(row?.cost), + events: Math.round(toNumber(row?.events)), + } + }) +} + +/** Calendar-aligned buckets shared by credit and activity series, including empty days. */ +export function usageBucketTimestamps( + window: UsageAnalyticsWindow, + bucket: UsageBucket, + timezone: string +): string[] { const { start, end } = usageWindowBounds(window) const first = truncateToBucket(localCalendarDate(start, timezone), bucket) // The window is half-open, so the last bucket is the one holding its final instant. const last = truncateToBucket(localCalendarDate(new Date(end.getTime() - 1), timezone), bucket) - const points: UsageSeriesPoint[] = [] + const points: string[] = [] const cursor = civilDate(first) let guard = 0 @@ -420,12 +436,7 @@ export function densifyUsageSeries( while (civilKey(cursor) <= last && guard < 1000) { guard += 1 const key = civilKey(cursor) - const row = byBucket.get(key) - points.push({ - timestamp: `${key}T00:00:00`, - cost: toNumber(row?.cost), - events: Math.round(toNumber(row?.events)), - }) + points.push(`${key}T00:00:00`) if (bucket === 'day') cursor.setUTCDate(cursor.getUTCDate() + 1) else if (bucket === 'week') cursor.setUTCDate(cursor.getUTCDate() + 7) else cursor.setUTCMonth(cursor.getUTCMonth() + 1) diff --git a/apps/sim/lib/billing/core/usage-log.postgres.test.ts b/apps/sim/lib/billing/core/usage-log.postgres.test.ts index 590ece0a4b8..01a61c3d062 100644 --- a/apps/sim/lib/billing/core/usage-log.postgres.test.ts +++ b/apps/sim/lib/billing/core/usage-log.postgres.test.ts @@ -29,6 +29,8 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ isOrgScopedSubscription: v import { CumulativeUsageContextMismatchError, + getBillingPeriodUsageCost, + getBillingPeriodUsageCostByUser, type RecordCumulativeUsageParams, recordCumulativeUsage, } from '@/lib/billing/core/usage-log' @@ -258,6 +260,54 @@ describe.skipIf(!databaseUrl)('Cumulative billing with PostgreSQL', () => { expect(await recordCumulativeUsage(usage(0.8))).toEqual({ billed: false, delta: 0, total: 0.8 }) }) + it('reads committed pooled and member charges freshly after concurrent executions', async () => { + if (!database) throw new Error('PostgreSQL fixture is unavailable') + const { billingEntity, billingPeriod } = usage(0) + if (!billingEntity || !billingPeriod) throw new Error('Billing fixture scope is missing') + const readPool = () => + getBillingPeriodUsageCost(billingEntity, billingPeriod, undefined, database) + expect(await readPool()).toBe(0) + await Promise.all( + Array.from({ length: 64 }, (_, index) => + recordCumulativeUsage({ + ...usage(0.005, `concurrent:${index}`), + userId: `member-${index % 4}`, + }) + ) + ) + expect(await readPool()).toBeCloseTo(0.32, 9) + const members = await getBillingPeriodUsageCostByUser( + billingEntity, + billingPeriod, + undefined, + database + ) + expect(members).toEqual( + new Map(Array.from({ length: 4 }, (_, index) => [`member-${index}`, 0.08])) + ) + await recordCumulativeUsage({ ...usage(0.105, 'concurrent:0'), userId: 'member-0' }) + expect(await readPool()).toBeCloseTo(0.42, 9) + expect( + await getBillingPeriodUsageCost( + { type: 'organization', id: 'other-payer' }, + billingPeriod, + undefined, + database + ) + ).toBe(0) + expect( + await getBillingPeriodUsageCost( + billingEntity, + { + start: billingPeriod.end, + end: new Date('2026-11-01T00:00:00.000Z'), + }, + undefined, + database + ) + ).toBe(0) + }) + it.each([0.2, 0.8])( 'rejects an actor mismatch even for a non-increasing callback (%s)', async (cost) => { diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index 7ce58ed8214..7ced9be6ab4 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -106,6 +106,7 @@ vi.mock('@/lib/messaging/email/unsubscribe', () => ({ vi.mock('@sim/platform-authz/workspace', () => ({ isOrgAdminRole: mockIsOrgAdminRole })) import { + getOrgUsageLimit, getUserUsageLimit, maybeSendUsageThresholdEmail, syncUsageLimitsFromSubscription, @@ -200,6 +201,101 @@ describe('getUserUsageLimit', () => { await expect(getUserUsageLimit('user-1', null)).resolves.toBe(10) }) + + it.each([ + { plan: 'enterprise', configured: '12.005', seats: 3, expected: 12.005 }, + { plan: 'enterprise', configured: '0', seats: 3, expected: 0 }, + { plan: 'enterprise', configured: null, seats: 3, expected: 0 }, + { plan: 'team', configured: '10', seats: 3, expected: 60 }, + { plan: 'team', configured: '80', seats: 3, expected: 80 }, + { plan: 'team', configured: null, seats: 0, expected: 20 }, + ])( + 'reads the $plan organization limit once for configured=$configured and seats=$seats', + async ({ plan, configured, seats, expected }) => { + mockIsOrgScopedSubscription.mockReturnValue(true) + queueTableRows(schemaMock.organization, [{ orgUsageLimit: configured }]) + await expect( + getUserUsageLimit('user-1', { + referenceId: 'org-1', + plan, + seats, + status: 'active', + periodStart: null, + periodEnd: null, + }) + ).resolves.toBe(expected) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + } + ) + + it('still rejects a missing organization without adopting the display fallback', async () => { + mockIsOrgScopedSubscription.mockReturnValue(true) + queueTableRows(schemaMock.organization, []) + await expect( + getUserUsageLimit('user-1', { + referenceId: 'org-missing', + plan: 'team', + seats: 3, + status: 'active', + periodStart: null, + periodEnd: null, + }) + ).rejects.toThrow('Organization not found: org-missing for user: user-1') + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + + it('does not retain an organization cap between calls', async () => { + mockIsOrgScopedSubscription.mockReturnValue(true) + const subscription = { + referenceId: 'org-1', + plan: 'enterprise', + seats: 1, + status: 'active', + periodStart: null, + periodEnd: null, + } + queueTableRows(schemaMock.organization, [{ orgUsageLimit: '50' }]) + queueTableRows(schemaMock.organization, [{ orgUsageLimit: '20' }]) + await expect(getUserUsageLimit('user-1', subscription)).resolves.toBe(50) + await expect(getUserUsageLimit('user-1', subscription)).resolves.toBe(20) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + }) + + it('propagates a failed organization limit read', async () => { + mockIsOrgScopedSubscription.mockReturnValue(true) + const failure = new Error('database unavailable') + dbChainMockFns.limit.mockRejectedValueOnce(failure) + await expect( + getUserUsageLimit('user-1', { + referenceId: 'org-1', + plan: 'team', + seats: 3, + status: 'active', + periodStart: null, + periodEnd: null, + }) + ).rejects.toBe(failure) + }) +}) + +describe('getOrgUsageLimit', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it.each([ + { plan: 'team', expected: { limit: 60, minimum: 60 } }, + { plan: 'enterprise', expected: { limit: 0, minimum: 0 } }, + ])( + 'preserves the public $plan fallback for a missing organization', + async ({ plan, expected }) => { + queueTableRows(schemaMock.organization, []) + await expect(getOrgUsageLimit('org-missing', plan, 3)).resolves.toEqual(expected) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + } + ) }) describe('syncUsageLimitsFromSubscription', () => { diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 1a9ad247645..b62fe0c8bd9 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -1,6 +1,5 @@ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' -import { member, organization, settings, user, userStats, userStatsColumns } from '@sim/db/schema' +import { member, organization, settings, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' @@ -112,17 +111,40 @@ export async function getOrgUsageLimit( seats: number | null, executor: DbClient = db ): Promise { - const orgData = await executor + return ( + (await findOrgUsageLimit(organizationId, plan, seats, executor)) ?? + calculateOrgUsageLimit(organizationId, plan, seats, null) + ) +} + +async function findOrgUsageLimit( + organizationId: string, + plan: string, + seats: number | null, + executor: DbClient = db +): Promise { + const [orgData] = await executor .select({ orgUsageLimit: organization.orgUsageLimit }) .from(organization) .where(eq(organization.id, organizationId)) .limit(1) - const configured = - orgData.length > 0 && orgData[0].orgUsageLimit - ? toNumber(toDecimal(orgData[0].orgUsageLimit)) - : null + if (!orgData) return null + + return calculateOrgUsageLimit( + organizationId, + plan, + seats, + orgData.orgUsageLimit ? toNumber(toDecimal(orgData.orgUsageLimit)) : null + ) +} +function calculateOrgUsageLimit( + organizationId: string, + plan: string, + seats: number | null, + configured: number | null +): OrgUsageLimitResult { if (isEnterprise(plan)) { // Enterprise: Use configured limit directly (no per-seat minimum) if (configured !== null) { @@ -156,7 +178,7 @@ export async function getOrgUsageLimit( */ export async function handleNewUser(userId: string): Promise { try { - await db.insert(withInsertColumns(userStats, userStatsColumns)).values({ + await db.insert(userStats).values({ id: generateId(), userId: userId, currentUsageLimit: getFreeTierLimit().toString(), @@ -183,7 +205,7 @@ export async function handleNewUser(userId: string): Promise { */ export async function ensureUserStatsExists(userId: string): Promise { await db - .insert(withInsertColumns(userStats, userStatsColumns)) + .insert(userStats) .values({ id: generateId(), userId: userId, @@ -214,7 +236,7 @@ export async function getResolvedUserUsageData( // inserted, which a lagging replica can miss (this path throws on a // missing row). Stays on the primary deliberately. db - .select(userStatsColumns) + .select() .from(userStats) .where(eq(userStats.userId, userId)) .limit(1), @@ -331,7 +353,7 @@ export async function getUserUsageLimitInfo(userId: string): Promise { const [subscription, currentUserStats] = await Promise.all([ getHighestPriorityPersonalSubscription(userId, { onError: 'throw' }), - db.select(userStatsColumns).from(userStats).where(eq(userStats.userId, userId)).limit(1), + db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1), ]) if (currentUserStats.length === 0) { diff --git a/apps/sim/lib/billing/enterprise-provisioning.ts b/apps/sim/lib/billing/enterprise-provisioning.ts index 50918ed50d6..6a5f1152037 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.ts @@ -1,12 +1,10 @@ import { AuditAction, AuditResourceType, recordAudit, recordAuditOnce } from '@sim/audit' import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import { invitation, invitationWorkspaceGrant, member, organization, - organizationColumns, outboxEvent, permissions, subscription, @@ -1640,7 +1638,7 @@ export async function issueEnterpriseProvisioning( if (organizationToCreate) { const now = new Date() - await tx.insert(withInsertColumns(organization, organizationColumns)).values({ + await tx.insert(organization).values({ id: organizationToCreate.id, name: organizationToCreate.name, slug: slugifyOrganizationName(organizationToCreate.name, organizationToCreate.id), diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index 68cfa2fea66..1e43f2a866b 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -13,6 +13,7 @@ export { hasSSOAccess, isEnterpriseOrgAdminOrOwner, isEnterprisePlan as hasEnterprisePlan, + isOrganizationGovernanceActive, isOrganizationOnEnterprisePlan, isProPlan as hasProPlan, isTeamPlan as hasTeamPlan, diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts index e1e3e3d2951..fc6219b250c 100644 --- a/apps/sim/lib/billing/organization.ts +++ b/apps/sim/lib/billing/organization.ts @@ -1,12 +1,5 @@ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' -import { - member, - organization, - organizationColumns, - subscription as subscriptionTable, - user, -} from '@sim/db/schema' +import { member, organization, subscription as subscriptionTable, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' @@ -449,7 +442,7 @@ export async function ensureOrganizationForTeamSubscriptionTx( organizationId = `org_${generateId()}` const now = new Date() - await tx.insert(withInsertColumns(organization, organizationColumns)).values({ + await tx.insert(organization).values({ id: organizationId, name: userData.name || `${userData.email || 'User'}'s Team`, slug: `${userId}-team-${generateId()}` diff --git a/apps/sim/lib/billing/organizations/create-organization.ts b/apps/sim/lib/billing/organizations/create-organization.ts index 9fb27bffb74..1947a333745 100644 --- a/apps/sim/lib/billing/organizations/create-organization.ts +++ b/apps/sim/lib/billing/organizations/create-organization.ts @@ -1,6 +1,5 @@ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' -import { member, organization, organizationColumns } from '@sim/db/schema' +import { member, organization } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, eq, ne } from 'drizzle-orm' import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock' @@ -101,7 +100,7 @@ export async function createOrganizationWithOwnerTx( throw new OrganizationSlugTakenError(slug) } - await tx.insert(withInsertColumns(organization, organizationColumns)).values({ + await tx.insert(organization).values({ id: organizationId, name, slug, diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 7687d32db23..d4bd323b3cb 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -6,7 +6,6 @@ */ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import { account, credential, @@ -19,7 +18,6 @@ import { subscription as subscriptionTable, user, userStats, - userStatsColumns, workspace, workspaceFiles, } from '@sim/db/schema' @@ -1839,7 +1837,7 @@ export async function transferOrganizationOwnership( if (oldStats) { await tx - .insert(withInsertColumns(userStats, userStatsColumns)) + .insert(userStats) .values({ id: generateId(), userId: newOwnerUserId, diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 7f416fe0180..7ae702a94c3 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -17,8 +17,7 @@ * writes any of them or deletes a locked row. */ -import { withInsertColumns } from '@sim/db/insert-columns' -import { organization, userStats, userStatsColumns, workspace } from '@sim/db/schema' +import { organization, userStats, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' @@ -632,7 +631,7 @@ export async function checkAndIncrementStorageUsageInTx( if (!orgScoped) { await tx - .insert(withInsertColumns(userStats, userStatsColumns)) + .insert(userStats) .values({ id: generateId(), userId, diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 6ac67ebce27..3758e692805 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -1,13 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { - member, - organization, - organizationColumns, - subscription, - userStats, - userStatsColumns, -} from '@sim/db/schema' +import { member, organization, subscription, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, sql } from 'drizzle-orm' @@ -355,7 +348,7 @@ export async function checkAndBillOverageThreshold( await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) const statsRecords = await tx - .select(userStatsColumns) + .select() .from(userStats) .where(eq(userStats.userId, userId)) .for('update') @@ -710,7 +703,7 @@ async function checkAndBillOrganizationOverageThreshold( } const ownerStatsLock = await tx - .select(userStatsColumns) + .select() .from(userStats) .where(eq(userStats.userId, lockedOwnerId)) .for('update') @@ -737,7 +730,7 @@ async function checkAndBillOrganizationOverageThreshold( } const orgLock = await tx - .select(organizationColumns) + .select() .from(organization) .where(eq(organization.id, organizationId)) .for('update') diff --git a/apps/sim/lib/block-metadata/names.generated.ts b/apps/sim/lib/block-metadata/names.generated.ts new file mode 100644 index 00000000000..6b50dd9f5f3 --- /dev/null +++ b/apps/sim/lib/block-metadata/names.generated.ts @@ -0,0 +1,315 @@ +/** + * Generated by `bun run generate:block-successors` from the block registry. + * Display-only metadata; keeps block implementations out of permission previews. + */ +export const BLOCK_NAMES: Readonly> = { + a2a: 'A2A', + affinity: 'Affinity', + agent: 'Agent', + agentmail: 'AgentMail', + agentphone: 'AgentPhone', + agiloft: 'Agiloft', + ahrefs: 'Ahrefs', + airtable: 'Airtable', + airweave: 'Airweave', + algolia: 'Algolia', + amplitude: 'Amplitude', + api: 'API', + apify: 'Apify', + apollo: 'Apollo', + appconfig: 'AWS AppConfig', + arxiv: 'ArXiv', + asana: 'Asana', + ashby: 'Ashby', + athena: 'Athena', + attio: 'Attio', + azure_data_explorer: 'Azure Data Explorer', + azure_devops: 'Azure DevOps', + bitbucket: 'Bitbucket', + box_v2: 'Box', + brandfetch: 'Brandfetch', + brex: 'Brex', + brightdata: 'Bright Data', + browser_use: 'Browser Use', + buffer: 'Buffer', + calcom: 'Cal.com', + calendly: 'Calendly', + cbinsights: 'CB Insights', + circleback: 'Circleback', + clay: 'Clay', + clerk: 'Clerk', + clickhouse: 'ClickHouse', + clickup: 'ClickUp', + cloudflare: 'Cloudflare', + cloudformation: 'CloudFormation', + cloudtrail: 'CloudTrail', + cloudwatch: 'CloudWatch', + coda: 'Coda', + codepipeline: 'CodePipeline', + condition: 'Condition', + confluence_v2: 'Confluence', + context_dev: 'Context.dev', + convex: 'Convex', + credential: 'Credential', + credential_group: 'Connected Accounts (Legacy)', + crowdstrike: 'CrowdStrike', + crunchbase: 'Crunchbase', + cursor_v2: 'Cursor', + dagster: 'Dagster', + databricks: 'Databricks', + datadog: 'Datadog', + datagma: 'Datagma', + daytona: 'Daytona', + deployments: 'Deployments', + devin: 'Devin', + discord: 'Discord', + docusign: 'DocuSign', + downdetector: 'Downdetector', + dropbox_v2: 'Dropbox', + dropcontact: 'Dropcontact', + dspy: 'DSPy', + dub_v2: 'Dub', + duckduckgo: 'DuckDuckGo', + dynamodb: 'Amazon DynamoDB', + dynatrace: 'Dynatrace', + elasticsearch: 'Elasticsearch', + elevenlabs: 'ElevenLabs', + emailbison: 'Email Bison', + embeddings: 'Embeddings', + enrich: 'Enrich', + enrichment: 'Data Enrichment', + enrow: 'Enrow', + evaluator: 'Evaluator', + exa: 'Exa', + extend_v2: 'Extend', + fathom: 'Fathom', + file_v5: 'File', + findymail: 'Findymail', + firecrawl: 'Firecrawl', + fireflies_v2: 'Fireflies', + flint: 'Flint', + function: 'Function', + gamma: 'Gamma', + generic_webhook: 'Webhook Trigger', + github_v2: 'GitHub', + gitlab: 'GitLab', + gmail_v2: 'Gmail', + gong: 'Gong', + google_ads: 'Google Ads', + google_appsheet: 'Google AppSheet', + google_bigquery: 'Google BigQuery', + google_books: 'Google Books', + google_calendar_v2: 'Google Calendar', + google_contacts: 'Google Contacts', + google_docs: 'Google Docs', + google_drive: 'Google Drive', + google_forms: 'Google Forms', + google_groups: 'Google Groups', + google_maps: 'Google Maps', + google_meet: 'Google Meet', + google_pagespeed: 'Google PageSpeed', + google_search: 'Google Search', + google_sheets_v2: 'Google Sheets', + google_slides_v2: 'Google Slides', + google_tasks: 'Google Tasks', + google_translate: 'Google Translate', + google_vault: 'Google Vault', + grafana: 'Grafana', + grain_v2: 'Grain', + granola: 'Granola', + greenhouse: 'Greenhouse', + greptile: 'Greptile', + guardrails: 'Guardrails', + harmonic: 'Harmonic', + hex: 'Hex', + hubspot: 'HubSpot', + huggingface: 'Hugging Face', + human_in_the_loop_v2: 'Human', + hunter: 'Hunter.io', + iam: 'AWS IAM', + icypeas: 'Icypeas', + identity_center: 'AWS Identity Center', + image_generator_v2: 'Image Generator', + imap: 'IMAP Email', + incidentio: 'incident.io', + infisical: 'Infisical', + instagram: 'Instagram', + instantly: 'Instantly', + intercom_v2: 'Intercom', + jina: 'Jina', + jira: 'Jira', + jira_service_management: 'Jira Service Management', + jotform: 'Jotform', + jupyter_v2: 'Jupyter', + kalshi_v2: 'Kalshi', + ketch: 'Ketch', + knowledge: 'Knowledge', + lambda: 'Lambda', + langsmith: 'LangSmith', + latex: 'LaTeX', + launchdarkly: 'LaunchDarkly', + leadmagic: 'LeadMagic', + lemlist: 'Lemlist', + linear_v2: 'Linear', + linkedin: 'LinkedIn', + linkup: 'Linkup', + linq: 'Linq', + logfire: 'Logfire', + logrocket: 'LogRocket', + logs_v2: 'Logs', + loop: 'Loop', + loops: 'Loops', + luma: 'Luma', + mailchimp: 'Mailchimp', + mailgun: 'Mailgun', + managed_agent: 'Claude Managed Agents', + manageengine_sdp: 'ManageEngine ServiceDesk Plus', + mcp: 'MCP', + mem0: 'Mem0', + memory: 'Memory', + microsoft_ad: 'Azure AD', + microsoft_dataverse_v2: 'Microsoft Dataverse', + microsoft_dynamics_365: 'Microsoft Dynamics 365 CRM', + microsoft_excel_v2: 'Microsoft Excel', + microsoft_planner: 'Microsoft Planner', + microsoft_teams: 'Microsoft Teams', + microsoft_word: 'Microsoft Word', + millionverifier: 'MillionVerifier', + mintlify: 'Mintlify', + mistral_parse_v3: 'Mistral Parser', + modal: 'Modal', + monday: 'Monday', + mongodb: 'MongoDB', + mothership: 'Sim Chat', + mssql: 'Microsoft SQL Server', + mysql: 'MySQL', + neo4j: 'Neo4j', + netsuite: 'Oracle NetSuite', + neverbounce: 'NeverBounce', + new_relic: 'New Relic', + note: 'Note', + notion_v2: 'Notion', + obsidian: 'Obsidian', + okta: 'Okta', + onedrive: 'OneDrive', + onepassword: '1Password', + outlook: 'Outlook', + pagerduty: 'PagerDuty', + parallel: 'Parallel', + parallel_ai: 'Parallel AI', + peopledatalabs: 'People Data Labs', + perplexity: 'Perplexity', + persona: 'Persona', + pi: 'Pi Coding Agent', + pinecone: 'Pinecone', + pipedrive: 'Pipedrive', + pitchbook: 'PitchBook', + polymarket: 'Polymarket', + postgresql: 'PostgreSQL', + posthog: 'PostHog', + profound: 'Profound', + prospeo: 'Prospeo', + pulse_v2: 'Pulse', + qdrant: 'Qdrant', + quartr: 'Quartr', + quickbooks: 'QuickBooks', + quiver_v2: 'Quiver', + rabbitmq: 'RabbitMQ', + railway: 'Railway', + rb2b: 'RB2B', + rds: 'Amazon RDS', + reddit: 'Reddit', + redis: 'Redis', + reducto_v2: 'Reducto', + resend: 'Resend', + response: 'Response', + revenuecat: 'RevenueCat', + rippling: 'Rippling', + rocketlane: 'Rocketlane', + rootly: 'Rootly', + router_v2: 'Router', + rss: 'RSS Feed', + s3: 'S3', + sailpoint: 'SailPoint', + salesforce: 'Salesforce', + sap_concur: 'SAP Concur', + sap_s4hana: 'SAP S4HANA', + schedule: 'Schedule', + search: 'Search', + secrets_manager: 'AWS Secrets Manager', + semrush: 'Semrush', + sendblue: 'Sendblue', + sendgrid: 'SendGrid', + sentry: 'Sentry', + serper: 'Serper', + servicenow_v2: 'ServiceNow', + ses: 'AWS SES', + sftp_v2: 'SFTP', + sharepoint_v2: 'SharePoint', + shopify: 'Shopify', + sim_workspace_event: 'Sim Workspace Events', + similarweb: 'Similarweb', + sixtyfour: 'Sixtyfour AI', + slack_v2: 'Slack', + smartlead: 'Smartlead', + smtp: 'SMTP', + snowflake: 'Snowflake', + splunk: 'Splunk', + sportmonks: 'Sportmonks', + spotify: 'Spotify', + sqs: 'Amazon SQS', + square: 'Square', + ssh_v2: 'SSH', + ssm: 'AWS Systems Manager', + stagehand: 'Stagehand', + start_trigger: 'Start', + stripe: 'Stripe', + sts: 'AWS STS', + stt_v2: 'Speech-to-Text', + supabase: 'Supabase', + table_v2: 'Table', + tailscale: 'Tailscale', + tavily: 'Tavily', + telegram: 'Telegram', + temporal: 'Temporal', + textract_v2: 'AWS Textract', + thinking: 'Thinking', + thrive: 'Thrive', + tiktok: 'TikTok', + tinybird: 'Tinybird', + tinyfish: 'TinyFish', + translate: 'Translate', + trello: 'Trello', + trigger_dev: 'Trigger.dev', + tts: 'Text-to-Speech', + twilio_sms: 'Twilio SMS', + twilio_voice: 'Twilio Voice', + typeform: 'Typeform', + upstash: 'Upstash', + uptimerobot: 'UptimeRobot', + vanta: 'Vanta', + variables: 'Variables', + vercel: 'Vercel', + video_generator_v3: 'Video Generator', + vision: 'Vision (Legacy)', + vision_v2: 'Vision', + wait: 'Wait', + wealthbox: 'Wealthbox', + webflow: 'Webflow', + webhook_request: 'Webhook', + whatsapp: 'WhatsApp', + wikipedia: 'Wikipedia', + windchill: 'Windchill', + wiza: 'Wiza', + wordpress: 'WordPress', + workday: 'Workday', + workflow_input: 'Workflow', + x: 'X', + youtube: 'YouTube', + zendesk: 'Zendesk', + zep: 'Zep', + zerobounce: 'ZeroBounce', + zoho_desk: 'Zoho Desk', + zoom: 'Zoom', + zoominfo: 'ZoomInfo', +} diff --git a/apps/sim/lib/copilot/application/authorize-chat-callback.test.ts b/apps/sim/lib/copilot/application/authorize-chat-callback.test.ts new file mode 100644 index 00000000000..1c9af16437a --- /dev/null +++ b/apps/sim/lib/copilot/application/authorize-chat-callback.test.ts @@ -0,0 +1,224 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AccountBillingDecision, + BillingAttributionSnapshot, +} from '@/lib/billing/core/billing-attribution' +import { + authorizeCopilotChatCallback, + checkCopilotContinuationBilling, +} from '@/lib/copilot/application/authorize-chat-callback' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + permission: vi.fn(), + capability: vi.fn(), + organization: vi.fn(), + attributedBlocks: vi.fn(), + actorBlock: vi.fn(), + payerBlock: vi.fn(), +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({ + ...(await importOriginal()), + resolveEffectiveWorkspacePermission: mocks.permission, +})) +vi.mock('@/lib/permission-groups/capability-assertions', async (importOriginal) => ({ + ...(await importOriginal()), + assertWorkspaceCapability: mocks.capability, +})) +vi.mock('@/lib/copilot/chat/organization-chats', () => ({ + authorizeOrganizationChatDelegation: { execute: mocks.organization }, +})) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + checkAttributedBillingBlocks: mocks.attributedBlocks, +})) +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkBillingBlocked: mocks.actorBlock, + checkBillingEntityBlocked: mocks.payerBlock, +})) + +const context = { + userId: 'actor', + workspaceId: 'workspace', + chatId: 'chat', + delegationId: 'request', + purpose: 'continuation' as const, +} +const account: AccountBillingDecision = { + userId: 'actor', + billingEntity: { type: 'organization', id: 'original-payer' }, + billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, +} +const attribution: BillingAttributionSnapshot = { + actorUserId: 'actor', + billedAccountUserId: 'owner', + workspaceId: 'workspace', + organizationId: 'original-payer', + billingEntity: account.billingEntity, + billingPeriod: account.billingPeriod, + payerSubscription: null, +} + +beforeEach(() => { + vi.resetAllMocks() + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace', + workspaceOrganizationId: 'current-organization', + allowPersonalApiKeys: true, + billedAccountUserId: 'new-owner', + }) + mocks.permission.mockResolvedValue('read') + mocks.organization.mockResolvedValue(undefined) + mocks.actorBlock.mockResolvedValue({ blocked: false }) + mocks.payerBlock.mockResolvedValue({ blocked: false }) + mocks.attributedBlocks.mockResolvedValue({ blocked: false }) +}) + +describe('fresh chat callback authorization', () => { + it('checks the actor current membership and capability in the canonical workspace', async () => { + await authorizeCopilotChatCallback(context) + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace') + expect(mocks.permission).toHaveBeenCalledWith( + 'actor', + 'workspace', + 'current-organization', + undefined, + { forUpdate: undefined } + ) + expect(mocks.capability).toHaveBeenCalledWith( + 'actor', + 'workspace', + 'copilot.use', + 'current-organization' + ) + expect(mocks.permission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.capability.mock.invocationCallOrder[0] + ) + }) + + it.each(['continuation', 'cancellation'] as const)( + 'rejects removed membership on %s', + async (purpose) => { + mocks.permission.mockResolvedValueOnce(null) + await expect(authorizeCopilotChatCallback({ ...context, purpose })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.capability).not.toHaveBeenCalled() + } + ) + + it.each(['continuation', 'cancellation'] as const)( + 'rejects an archived or removed workspace on %s', + async (purpose) => { + mocks.loadWorkspace.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workspace not found') + ) + await expect(authorizeCopilotChatCallback({ ...context, purpose })).rejects.toMatchObject({ + code: 'not_found', + }) + expect(mocks.permission).not.toHaveBeenCalled() + } + ) + + it('rejects continuation after capability revocation, but allows the actor to stop', async () => { + mocks.capability.mockRejectedValue(new OrchestrationError('forbidden', 'Copilot disabled')) + await expect(authorizeCopilotChatCallback(context)).rejects.toMatchObject({ code: 'forbidden' }) + mocks.capability.mockClear() + await authorizeCopilotChatCallback({ ...context, purpose: 'cancellation' }) + expect(mocks.permission).toHaveBeenCalledTimes(2) + expect(mocks.capability).not.toHaveBeenCalled() + }) + + it('fails closed if canonical workspace scope changes unexpectedly', async () => { + mocks.loadWorkspace.mockResolvedValueOnce({ + workspaceId: 'other-workspace', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + }) + await expect(authorizeCopilotChatCallback(context)).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.permission).not.toHaveBeenCalled() + }) + + it('propagates membership infrastructure failures', async () => { + mocks.permission.mockRejectedValueOnce(new Error('database unavailable')) + await expect(authorizeCopilotChatCallback(context)).rejects.toThrow('database unavailable') + }) + + it.each([ + ['continuation', 'sim:copilot-billing'], + ['cancellation', 'sim:copilot-cancel'], + ] as const)( + 'reauthorizes the original private organization chat for %s', + async (purpose, audience) => { + await authorizeCopilotChatCallback({ + ...context, + workspaceId: undefined, + organizationId: 'org', + purpose, + }) + expect(mocks.organization).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'organization_delegated', + subjectUserId: 'actor', + organizationId: 'org', + audience, + resourceScope: { chatId: 'chat' }, + }), + }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + } + ) + + it.each([ + { workspaceId: undefined, organizationId: 'org', chatId: undefined }, + { workspaceId: 'workspace', organizationId: 'org', chatId: 'chat' }, + ])('refuses invalid organization scope %s', async (scope) => { + await expect(authorizeCopilotChatCallback({ ...context, ...scope })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.organization).not.toHaveBeenCalled() + }) +}) + +describe('continuation account standing', () => { + it('uses the existing attributed block policy with the original snapshot', async () => { + await checkCopilotContinuationBilling({ kind: 'attributed', attribution }) + expect(mocks.attributedBlocks).toHaveBeenCalledWith(attribution) + expect(mocks.actorBlock).not.toHaveBeenCalled() + expect(mocks.payerBlock).not.toHaveBeenCalled() + }) + + it('checks both actor and the exact original direct-account payer', async () => { + await checkCopilotContinuationBilling({ kind: 'account', decision: account }) + expect(mocks.actorBlock).toHaveBeenCalledWith('actor') + expect(mocks.payerBlock).toHaveBeenCalledWith({ type: 'organization', id: 'original-payer' }) + }) + + it('refuses an actor block before reading the payer', async () => { + mocks.actorBlock.mockResolvedValueOnce({ blocked: true }) + await expect( + checkCopilotContinuationBilling({ kind: 'account', decision: account }) + ).resolves.toMatchObject({ blocked: true, scope: 'actor' }) + expect(mocks.payerBlock).not.toHaveBeenCalled() + }) + + it('refuses a payer block independently of actor standing', async () => { + mocks.payerBlock.mockResolvedValueOnce({ blocked: true }) + await expect( + checkCopilotContinuationBilling({ kind: 'account', decision: account }) + ).resolves.toMatchObject({ blocked: true, scope: 'payer' }) + }) + + it('reads the same personal actor/payer only once', async () => { + await checkCopilotContinuationBilling({ + kind: 'account', + decision: { ...account, billingEntity: { type: 'user', id: 'actor' } }, + }) + expect(mocks.actorBlock).toHaveBeenCalledTimes(1) + expect(mocks.payerBlock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/application/authorize-chat-callback.ts b/apps/sim/lib/copilot/application/authorize-chat-callback.ts new file mode 100644 index 00000000000..58774447753 --- /dev/null +++ b/apps/sim/lib/copilot/application/authorize-chat-callback.ts @@ -0,0 +1,107 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { + checkBillingBlocked, + checkBillingEntityBlocked, +} from '@/lib/billing/calculations/usage-monitor' +import { + type AccountBillingDecision, + type BillingAttributionSnapshot, + checkAttributedBillingBlocks, +} from '@/lib/billing/core/billing-attribution' +import { chatOperations } from '@/lib/copilot/application/operations' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + createTrustedCopilotPrincipal, + createTrustedOrganizationCopilotPrincipal, +} from '@/lib/copilot/auth/application-delegation' +import { authorizeOrganizationChatDelegation } from '@/lib/copilot/chat/organization-chats' +import { + COPILOT_VALIDATION_PURPOSE, + type CopilotValidationPurpose, +} from '@/lib/copilot/generated/billing-protocol-v1' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const CALLBACK_AUDIENCE = 'sim:copilot-callback' + +interface WorkspaceCallbackInput { + workspaceId: string +} + +const workspaceCallbackAuthorization = { + resolveContext: ({ input }: { input: WorkspaceCallbackInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + authorizationOptions: { + delegation: { + audience: CALLBACK_AUDIENCE, + isWithinScope: (principal: DelegatedPrincipal) => Boolean(principal.subjectUserId), + }, + }, + async execute() {}, +} + +const continueWorkspaceChat = defineAuthorizedWorkspaceUseCase({ + operation: chatOperations.continue, + ...workspaceCallbackAuthorization, +}) +const cancelWorkspaceChat = defineAuthorizedWorkspaceUseCase({ + operation: chatOperations.cancel, + ...workspaceCallbackAuthorization, +}) + +interface CopilotChatCallbackContext { + userId: string + workspaceId?: string + organizationId?: string + chatId?: string + delegationId: string + purpose: Exclude +} + +/** Reauthorizes the original server-owned scope across a Go lifecycle boundary. */ +export async function authorizeCopilotChatCallback(context: CopilotChatCallbackContext) { + if (context.organizationId) { + if (!context.chatId || context.workspaceId) { + throw new OrchestrationError('forbidden', 'Invalid conversation scope') + } + const principal = createTrustedOrganizationCopilotPrincipal( + { ...context, organizationId: context.organizationId, chatId: context.chatId }, + { + audience: + context.purpose === COPILOT_VALIDATION_PURPOSE.cancellation + ? 'sim:copilot-cancel' + : 'sim:copilot-billing', + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + } + ) + await authorizeOrganizationChatDelegation.execute({ principal }) + return + } + if (!context.workspaceId) return + + const principal = createTrustedCopilotPrincipal( + { ...context, workspaceId: context.workspaceId }, + { audience: CALLBACK_AUDIENCE, ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS } + ) + const useCase = + context.purpose === COPILOT_VALIDATION_PURPOSE.cancellation + ? cancelWorkspaceChat + : continueWorkspaceChat + await useCase.execute({ principal, input: { workspaceId: context.workspaceId } }) +} + +export type CopilotContinuationBilling = + | { kind: 'attributed'; attribution: BillingAttributionSnapshot } + | { kind: 'account'; decision: AccountBillingDecision } + +/** Checks account standing against the original admission; never reads spend or selects a new payer. */ +export async function checkCopilotContinuationBilling(billing: CopilotContinuationBilling) { + if (billing.kind === 'attributed') return checkAttributedBillingBlocks(billing.attribution) + + const actor = await checkBillingBlocked(billing.decision.userId) + if (actor.blocked) return { ...actor, scope: 'actor' } + const payer = billing.decision.billingEntity + if (payer.type === 'user' && payer.id === billing.decision.userId) return actor + return { ...(await checkBillingEntityBlocked(payer)), scope: 'payer' } +} diff --git a/apps/sim/lib/copilot/application/operations.ts b/apps/sim/lib/copilot/application/operations.ts index d152c1c54b9..7051af5b838 100644 --- a/apps/sim/lib/copilot/application/operations.ts +++ b/apps/sim/lib/copilot/application/operations.ts @@ -7,6 +7,25 @@ import { defineWorkspaceOperation } from '@/lib/core/application/workspace-opera * silently substituting the key's owner. */ export const chatOperations = { + continue: defineWorkspaceOperation({ + id: 'chat.continue', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'copilot.use', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + /** + * permission-group-exempt: Stopping existing work remains available after Copilot is disabled. + */ + cancel: defineWorkspaceOperation({ + id: 'chat.cancel', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), send: defineWorkspaceOperation({ id: 'chat.send', oauthScope: 'api:write', diff --git a/apps/sim/lib/copilot/chat/fork-chat-files.ts b/apps/sim/lib/copilot/chat/fork-chat-files.ts index 084bc682cf5..e06f83ae9f5 100644 --- a/apps/sim/lib/copilot/chat/fork-chat-files.ts +++ b/apps/sim/lib/copilot/chat/fork-chat-files.ts @@ -1,5 +1,4 @@ -import { withInsertColumns } from '@sim/db/insert-columns' -import { type WorkspaceFileRow, workspaceFileColumns, workspaceFiles } from '@sim/db/schema' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' @@ -62,7 +61,7 @@ export async function listForkableChatFiles( chatId: string ): Promise { return db - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( and( @@ -146,7 +145,7 @@ export async function planChatFileCopies(params: { // Ids and keys are generated client-side, so one multi-row insert suffices — // no per-row round trips while the fork transaction is held open. if (copyRows.length > 0) { - await tx.insert(withInsertColumns(workspaceFiles, workspaceFileColumns)).values(copyRows) + await tx.insert(workspaceFiles).values(copyRows) for (const source of rows) { const targetId = idMap.get(source.id) if (!targetId) continue diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index 84415ef99e1..f5eed98acf6 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -3,6 +3,8 @@ */ import { dbChainMockFns, resetDbChainMock, schemaMock, workflowAuthzMockFns } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTrustedOrganizationCopilotPrincipal } from '@/lib/copilot/auth/application-delegation' +import { OrchestrationError } from '@/lib/core/orchestration/types' const { mockAuthorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, @@ -14,9 +16,13 @@ afterAll(() => { mockGetActiveWorkflow.mockReset() }) -const { mockAuthorizeOrganization } = vi.hoisted(() => ({ mockAuthorizeOrganization: vi.fn() })) +const { mockAuthorizeOrganization, mockAuthorizeCancellation } = vi.hoisted(() => ({ + mockAuthorizeOrganization: vi.fn(), + mockAuthorizeCancellation: vi.fn(), +})) vi.mock('@/lib/copilot/chat/organization-chats', () => ({ authorizeOrganizationChat: { execute: mockAuthorizeOrganization }, + authorizeOrganizationChatCancellation: { execute: mockAuthorizeCancellation }, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -26,6 +32,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { getAccessibleCopilotChat, + getAccessibleCopilotChatAuth, + getAccessibleCopilotChatForCancellation, getAccessibleCopilotChatWithMessages, resolveOrCreateChat, } from '@/lib/copilot/chat/lifecycle' @@ -336,3 +344,99 @@ describe('organization chat isolation', () => { expect(dbChainMockFns.values).not.toHaveBeenCalled() }) }) + +describe('owned chat cancellation policy', () => { + const orgChat = { ...chatRow, organizationId: 'org-1', type: 'mothership' } + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockAuthorizeOrganization.mockReset().mockResolvedValue(undefined) + mockAuthorizeCancellation.mockReset().mockResolvedValue(undefined) + }) + + it('allows stopping an owned org chat after capability revocation while ordinary reads remain denied', async () => { + mockAuthorizeOrganization.mockRejectedValue( + new OrchestrationError('forbidden', 'Copilot disabled') + ) + dbChainMockFns.limit.mockResolvedValueOnce([orgChat]).mockResolvedValueOnce([orgChat]) + expect( + await getAccessibleCopilotChatForCancellation(CHAT_ID, USER_ID, { principal: orgPrincipal }) + ).toEqual(orgChat) + expect(mockAuthorizeCancellation).toHaveBeenCalledWith({ + principal: orgPrincipal, + input: { organizationId: 'org-1' }, + }) + expect(mockAuthorizeOrganization).not.toHaveBeenCalled() + expect( + await getAccessibleCopilotChatAuth(CHAT_ID, USER_ID, { principal: orgPrincipal }) + ).toBeNull() + expect(mockAuthorizeOrganization).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() + }) + + it('keeps the owned-live-chat predicate on cancellation', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([orgChat]) + await getAccessibleCopilotChatForCancellation(CHAT_ID, USER_ID, { principal: orgPrincipal }) + const predicate = dbChainMockFns.where.mock.calls[0][0] as { conditions: unknown[] } + expect(predicate.conditions).toEqual([ + { type: 'eq', left: schemaMock.copilotChats.id, right: CHAT_ID }, + { type: 'eq', left: schemaMock.copilotChats.userId, right: USER_ID }, + { type: 'isNull', column: schemaMock.copilotChats.deletedAt }, + ]) + }) + + it('denies cancellation after organization membership removal', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([orgChat]) + mockAuthorizeCancellation.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Organization not found') + ) + expect( + await getAccessibleCopilotChatForCancellation(CHAT_ID, USER_ID, { principal: orgPrincipal }) + ).toBeNull() + expect(mockAuthorizeOrganization).not.toHaveBeenCalled() + }) + + it.each([undefined, { ...orgPrincipal, userId: 'other-user' }])( + 'denies cancellation without the matching actor principal', + async (principal) => { + dbChainMockFns.limit.mockResolvedValueOnce([orgChat]) + expect( + await getAccessibleCopilotChatForCancellation(CHAT_ID, USER_ID, { principal }) + ).toBeNull() + expect(mockAuthorizeCancellation).not.toHaveBeenCalled() + } + ) + + it('does not let delegated cancellation switch to another chat owned by the same actor', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([orgChat]) + const principal = createTrustedOrganizationCopilotPrincipal( + { + userId: USER_ID, + organizationId: 'org-1', + chatId: 'other-chat', + delegationId: 'request', + }, + { audience: 'sim:copilot-cancel', ttlMs: 60000 } + ) + expect( + await getAccessibleCopilotChatForCancellation(CHAT_ID, USER_ID, { principal }) + ).toBeNull() + expect(mockAuthorizeCancellation).not.toHaveBeenCalled() + }) + + it('denies missing/deleted/non-owned chats before authorizing cancellation', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + expect( + await getAccessibleCopilotChatForCancellation(CHAT_ID, USER_ID, { principal: orgPrincipal }) + ).toBeNull() + expect(mockAuthorizeCancellation).not.toHaveBeenCalled() + }) + + it('propagates cancellation authorization infrastructure errors', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([orgChat]) + mockAuthorizeCancellation.mockRejectedValueOnce(new Error('database unavailable')) + await expect( + getAccessibleCopilotChatForCancellation(CHAT_ID, USER_ID, { principal: orgPrincipal }) + ).rejects.toThrow('database unavailable') + }) +}) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 32ce1f12756..b1a53c31982 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -7,7 +7,10 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, asc, eq, isNull, sql } from 'drizzle-orm' -import { authorizeOrganizationChat } from '@/lib/copilot/chat/organization-chats' +import { + authorizeOrganizationChat, + authorizeOrganizationChatCancellation, +} from '@/lib/copilot/chat/organization-chats' import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { @@ -132,7 +135,10 @@ async function authorizeCopilotChatRow( chat: T | undefined, chatId: string, userId: string, - principal?: Principal + principal?: Principal, + organizationAuthorization: + | typeof authorizeOrganizationChat + | typeof authorizeOrganizationChatCancellation = authorizeOrganizationChat ): Promise { if (!chat) { logger.warn('Copilot chat not found or not owned by user', { chatId, userId }) @@ -141,8 +147,13 @@ async function authorizeCopilotChatRow( if (chat.organizationId) { if (!principal || resolvePrincipalSubjectUserId(principal) !== userId) return null + if ( + principal.kind === 'organization_delegated' && + (principal.serviceId !== 'copilot' || principal.resourceScope.chatId !== chat.id) + ) + return null try { - await authorizeOrganizationChat.execute({ + await organizationAuthorization.execute({ principal, input: { organizationId: chat.organizationId }, }) @@ -186,10 +197,40 @@ async function authorizeCopilotChatRow( * authorization check — use this for routes that only need ownership * verification before a mutation (rename, delete, update-messages). */ -export async function getAccessibleCopilotChatAuth( +export function getAccessibleCopilotChatAuth( chatId: string, userId: string, options?: { principal?: Principal } +): Promise { + return loadAccessibleCopilotChatAuth( + chatId, + userId, + options?.principal, + authorizeOrganizationChat + ) +} + +/** Resolves the same owned, live chat under the Stop operation's current membership policy. */ +export function getAccessibleCopilotChatForCancellation( + chatId: string, + userId: string, + options?: { principal?: Principal } +): Promise { + return loadAccessibleCopilotChatAuth( + chatId, + userId, + options?.principal, + authorizeOrganizationChatCancellation + ) +} + +async function loadAccessibleCopilotChatAuth( + chatId: string, + userId: string, + principal: Principal | undefined, + organizationAuthorization: + | typeof authorizeOrganizationChat + | typeof authorizeOrganizationChatCancellation ): Promise { const [chat] = await db .select(copilotChatAuthColumns) @@ -197,7 +238,7 @@ export async function getAccessibleCopilotChatAuth( .where(ownedLiveChatWhere(chatId, userId)) .limit(1) - return authorizeCopilotChatRow(chat, chatId, userId, options?.principal) + return authorizeCopilotChatRow(chat, chatId, userId, principal, organizationAuthorization) } /** diff --git a/apps/sim/lib/copilot/chat/organization-chats.test.ts b/apps/sim/lib/copilot/chat/organization-chats.test.ts index f5540d89d0e..a0489ea0e38 100644 --- a/apps/sim/lib/copilot/chat/organization-chats.test.ts +++ b/apps/sim/lib/copilot/chat/organization-chats.test.ts @@ -3,6 +3,7 @@ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTrustedOrganizationCopilotPrincipal } from '@/lib/copilot/auth/application-delegation' import { + authorizeOrganizationChatCancellation, authorizeOrganizationChatDelegation, authorizeOrganizationChatEvents, createOrganizationChat, @@ -67,6 +68,28 @@ describe('private organization chat delegation', () => { expect(dbChainMockFns.select).not.toHaveBeenCalled() }) + it('keeps cancellation member/chat checks while exempting the disabled Copilot capability', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'private-chat' }]) + await authorizeOrganizationChatDelegation.execute({ + principal: { ...principal(), audience: 'sim:copilot-cancel' }, + }) + expect(authorize).toHaveBeenCalledWith( + expect.objectContaining({ subjectUserId: 'member-1' }), + expect.objectContaining({ + id: 'organization.chats.cancel', + minimumRole: 'member', + capability: 'none', + }), + { organizationId: 'org-1' } + ) + dbChainMockFns.limit.mockResolvedValueOnce([]) + await expect( + authorizeOrganizationChatDelegation.execute({ + principal: { ...principal(), audience: 'sim:copilot-cancel' }, + }) + ).rejects.toThrow('Conversation not found') + }) + it('does not accept an audience outside its registered operations', async () => { await expect( authorizeOrganizationChatDelegation.execute({ @@ -104,6 +127,24 @@ describe('organization chat events application boundary', () => { ) }) + it('uses the same cancellation policy for authenticated session and delegated callbacks', async () => { + await authorizeOrganizationChatCancellation.execute({ + principal, + input: { organizationId: 'org-1' }, + }) + expect(authorize).toHaveBeenCalledWith( + principal, + expect.objectContaining({ + id: 'organization.chats.cancel', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session', 'organization_delegated'], + }), + { organizationId: 'org-1' } + ) + expect(requireSearch).not.toHaveBeenCalled() + }) + it('does not examine rollout state for a non-member', async () => { authorize.mockRejectedValueOnce(new OrchestrationError('not_found', 'Organization not found')) await expect( diff --git a/apps/sim/lib/copilot/chat/organization-chats.ts b/apps/sim/lib/copilot/chat/organization-chats.ts index 242f3ab68c8..c91833e8ffa 100644 --- a/apps/sim/lib/copilot/chat/organization-chats.ts +++ b/apps/sim/lib/copilot/chat/organization-chats.ts @@ -111,6 +111,17 @@ export const createOrganizationChat = { } export const organizationChatDelegationOperations = { + /** + * permission-group-exempt: Stopping existing work remains available after Copilot is disabled. + */ + cancel: defineOrganizationOperation({ + id: 'organization.chats.cancel', + minimumRole: 'member', + principalKinds: ['session', 'organization_delegated'], + capability: 'none', + delegationAudience: 'sim:copilot-cancel', + delegatedServices: ['copilot'], + }), knowledge: defineOrganizationOperation({ id: 'organization.chats.knowledge', minimumRole: 'member', @@ -129,6 +140,18 @@ export const organizationChatDelegationOperations = { }), } as const +/** Checks current membership for stopping an owned chat without requiring Copilot to remain enabled. */ +export const authorizeOrganizationChatCancellation = { + operation: organizationChatDelegationOperations.cancel, + execute({ principal, input }: { principal: Principal; input: OrganizationChatInput }) { + return authorizeOrganizationOperation( + principal, + organizationChatDelegationOperations.cancel, + input + ) + }, +} + /** A trusted service may act only on the subject's persisted private organization chat. */ export const authorizeOrganizationChatDelegation = { async execute({ principal }: { principal: OrganizationDelegatedPrincipal }) { diff --git a/apps/sim/lib/copilot/generated/billing-protocol-v1.ts b/apps/sim/lib/copilot/generated/billing-protocol-v1.ts index 482442a214b..493785411f1 100644 --- a/apps/sim/lib/copilot/generated/billing-protocol-v1.ts +++ b/apps/sim/lib/copilot/generated/billing-protocol-v1.ts @@ -32,6 +32,20 @@ export const COPILOT_BILLING_PROTOCOL_VALUES = [ COPILOT_BILLING_PROTOCOL.legacy, ] as const +export const COPILOT_VALIDATION_PURPOSE = { + newTurn: 'new-turn', + continuation: 'continuation', + cancellation: 'cancellation', +} as const + +export const COPILOT_VALIDATION_PURPOSE_VALUES = [ + 'new-turn', + 'continuation', + 'cancellation', +] as const + +export type CopilotValidationPurpose = (typeof COPILOT_VALIDATION_PURPOSE_VALUES)[number] + export const BILLING_ATTRIBUTION_HEADER_MAX_BYTES = 8192 export const BILLING_ACCOUNT_DECISION_HEADER_MAX_BYTES = 2048 diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index bced482121a..7a1c35fc2a2 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -109,6 +109,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/cloudformation.mdx', 'integrations/cloudtrail.mdx', 'integrations/cloudwatch.mdx', + 'integrations/coda.mdx', 'integrations/codepipeline.mdx', 'integrations/confluence.mdx', 'integrations/context_dev.mdx', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index dbf390a7c80..360c3710983 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -500,12 +500,12 @@ export const BrowserClickAt: ToolCatalogEntry = { x: { type: 'number', description: - 'X in CSS pixels within the current viewport. When read off a browser_screenshot, divide the image pixel value by scale and add clip.x when present.', + "X in CSS pixels within the current viewport. When read off a browser_screenshot, follow its caption's X mapping and crop origin.", }, y: { type: 'number', description: - 'Y in CSS pixels within the current viewport, converted from screenshot pixels the same way as x.', + "Y in CSS pixels within the current viewport. When read off a browser_screenshot, follow its caption's Y mapping and crop origin.", }, }, required: ['x', 'y'], @@ -1519,7 +1519,7 @@ export const BrowserScreenshot: ToolCatalogEntry = { elementId: { type: 'number', description: - "Optional element id from the current tab's latest browser_snapshot. When present, capture only the visible portion of that top-page element without scrolling or changing layout. Scroll explicitly first if needed. Framed elements are rejected; use a viewport screenshot for them. Use the returned clip offset when converting image coordinates.", + "Optional element id from the current tab's latest browser_snapshot. When present, capture only the visible portion of that top-page element without scrolling or changing layout. Scroll explicitly first if needed. Framed elements are rejected; use a viewport screenshot for them. Follow the image caption's coordinate mapping, including its crop origin.", }, }, }, @@ -1626,16 +1626,27 @@ export const BrowserSelectOption: ToolCatalogEntry = { route: 'client', mode: 'async', parameters: { - type: 'object', + oneOf: [{ required: ['value'] }, { required: ['values'] }], properties: { elementId: { - type: 'number', description: "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", + type: 'number', + }, + value: { + description: "One option's visible label or value. Omit when supplying values.", + type: 'string', + }, + values: { + description: + 'The complete desired selection for a native multiple-selection control: at most 100 visible labels or values. Empty array clears the selection. Omit value when using this field.', + items: { type: 'string' }, + maxItems: 100, + type: 'array', }, - value: { type: 'string', description: "The option's visible label or its value." }, }, - required: ['elementId', 'value'], + required: ['elementId'], + type: 'object', }, resultSchema: { type: 'object', @@ -1644,6 +1655,12 @@ export const BrowserSelectOption: ToolCatalogEntry = { type: 'boolean', description: 'Whether the settled readback retained the requested selection.', }, + labels: { + type: 'array', + description: + 'Visible labels for the complete selected set in a multiple-selection control, in option order.', + items: { type: 'string' }, + }, note: { type: 'string', description: 'Guidance when the page reverted the selection.' }, notices: { type: 'array', @@ -1655,8 +1672,20 @@ export const BrowserSelectOption: ToolCatalogEntry = { type: 'object', description: 'Settled selected label and value.', properties: { + labels: { + type: 'array', + description: + 'Visible labels for the complete selected set in a multiple-selection control, in option order.', + items: { type: 'string' }, + }, selected: { type: 'string', description: 'Settled visible option label.' }, value: { type: 'string', description: 'Settled option value.' }, + values: { + type: 'array', + description: + 'Selected native option values in DOM order; included for multiple-selection controls.', + items: { type: 'string' }, + }, }, }, refRecovered: { @@ -1666,6 +1695,12 @@ export const BrowserSelectOption: ToolCatalogEntry = { }, selected: { type: 'string', description: 'Canonical visible label of the matched option.' }, value: { type: 'string', description: 'Canonical value of the matched option.' }, + values: { + type: 'array', + description: + 'Selected native option values in DOM order; included for multiple-selection controls.', + items: { type: 'string' }, + }, }, required: ['selected'], }, @@ -1818,7 +1853,7 @@ export const BrowserType: ToolCatalogEntry = { text: { type: 'string', description: - "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Mod+A then Backspace with browser_press_key.", + 'The replacement value. Empty text clears an ordinary text field. For structured inputs use YYYY-MM-DD (date), HH:mm (time), YYYY-MM-DDTHH:mm (datetime-local), YYYY-MM (month), YYYY-Www (week), #rrggbb (color), or a numeric range value. Alternatively use Mod+A then Backspace to clear ordinary text with browser_press_key.', }, }, required: ['elementId', 'text'], diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index cb977c36ed5..31b27d61213 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -225,12 +225,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { x: { type: 'number', description: - 'X in CSS pixels within the current viewport. When read off a browser_screenshot, divide the image pixel value by scale and add clip.x when present.', + "X in CSS pixels within the current viewport. When read off a browser_screenshot, follow its caption's X mapping and crop origin.", }, y: { type: 'number', description: - 'Y in CSS pixels within the current viewport, converted from screenshot pixels the same way as x.', + "Y in CSS pixels within the current viewport. When read off a browser_screenshot, follow its caption's Y mapping and crop origin.", }, }, required: ['x', 'y'], @@ -1430,7 +1430,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { elementId: { type: 'number', description: - "Optional element id from the current tab's latest browser_snapshot. When present, capture only the visible portion of that top-page element without scrolling or changing layout. Scroll explicitly first if needed. Framed elements are rejected; use a viewport screenshot for them. Use the returned clip offset when converting image coordinates.", + "Optional element id from the current tab's latest browser_snapshot. When present, capture only the visible portion of that top-page element without scrolling or changing layout. Scroll explicitly first if needed. Framed elements are rejected; use a viewport screenshot for them. Follow the image caption's coordinate mapping, including its crop origin.", }, }, }, @@ -1540,19 +1540,36 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, browser_select_option: { parameters: { - type: 'object', + oneOf: [ + { + required: ['value'], + }, + { + required: ['values'], + }, + ], properties: { elementId: { - type: 'number', description: "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", + type: 'number', }, value: { + description: "One option's visible label or value. Omit when supplying values.", type: 'string', - description: "The option's visible label or its value.", + }, + values: { + description: + 'The complete desired selection for a native multiple-selection control: at most 100 visible labels or values. Empty array clears the selection. Omit value when using this field.', + items: { + type: 'string', + }, + maxItems: 100, + type: 'array', }, }, - required: ['elementId', 'value'], + required: ['elementId'], + type: 'object', }, resultSchema: { type: 'object', @@ -1561,6 +1578,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'boolean', description: 'Whether the settled readback retained the requested selection.', }, + labels: { + type: 'array', + description: + 'Visible labels for the complete selected set in a multiple-selection control, in option order.', + items: { + type: 'string', + }, + }, note: { type: 'string', description: 'Guidance when the page reverted the selection.', @@ -1577,6 +1602,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', description: 'Settled selected label and value.', properties: { + labels: { + type: 'array', + description: + 'Visible labels for the complete selected set in a multiple-selection control, in option order.', + items: { + type: 'string', + }, + }, selected: { type: 'string', description: 'Settled visible option label.', @@ -1585,6 +1618,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: 'Settled option value.', }, + values: { + type: 'array', + description: + 'Selected native option values in DOM order; included for multiple-selection controls.', + items: { + type: 'string', + }, + }, }, }, refRecovered: { @@ -1600,6 +1641,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: 'Canonical value of the matched option.', }, + values: { + type: 'array', + description: + 'Selected native option values in DOM order; included for multiple-selection controls.', + items: { + type: 'string', + }, + }, }, required: ['selected'], }, @@ -1769,7 +1818,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { text: { type: 'string', description: - "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Mod+A then Backspace with browser_press_key.", + 'The replacement value. Empty text clears an ordinary text field. For structured inputs use YYYY-MM-DD (date), HH:mm (time), YYYY-MM-DDTHH:mm (datetime-local), YYYY-MM (month), YYYY-Www (week), #rrggbb (color), or a numeric range value. Alternatively use Mod+A then Backspace to clear ordinary text with browser_press_key.', }, }, required: ['elementId', 'text'], diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index ba50ca1ef5e..265f7a8eb1b 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -990,11 +990,11 @@ async function runCheckpointLoop( payload = { ...payload, systemPromptOverride } } - // Go's auth middleware re-validates every Sim -> Go request by reading - // workspaceId from the JSON body and forwarding it to Sim's validate route, - // where it is required for the per-member usage gate. Normalize the initial - // leg from the lifecycle option so callers that only set the option (not the - // raw payload) still send it on the first request. + /** + * The initial turn needs its workspace for pooled and member spend admission. + * Resumes authenticate again, then Go rechecks current access using the + * checkpoint's original scope and payer without repeating spend admission. + */ if (lifecycleWorkspaceId && !nonBlankString(payload.workspaceId)) { payload = { ...payload, workspaceId: lifecycleWorkspaceId } } diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts index 26ac1d9200a..a4ee6e8a4f3 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts @@ -581,6 +581,25 @@ describe('executeBrowserToolOnClient', () => { } ) + it('reports an unconfirmed effect without retrying or marking completed input as failed', async () => { + const result = { dispatched: true, effectObserved: false, possibleEffectObserved: true } + mockExecuteBrowserTool.mockResolvedValue(result) + const toolCallId = nextToolCallId() + + executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 }) + await flush() + executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 }) + await flush() + + expect(mockExecuteBrowserTool).toHaveBeenCalledOnce() + expect(mockReportCompletion).toHaveBeenCalledWith( + toolCallId, + 'success', + 'Browser input completed; its effect is unconfirmed. Inspect the current state before retrying.', + result + ) + }) + it('uses unload-safe delivery when a stateful replay-guard rejection cannot be reported normally', async () => { const storageWrite = vi.spyOn(window.sessionStorage, 'setItem').mockImplementation(() => { throw new DOMException('Quota exceeded', 'QuotaExceededError') @@ -1196,6 +1215,8 @@ describe('executeBrowserToolOnClient', () => { it('reshapes a screenshot into an image attachment the model can see', async () => { mockExecuteBrowserTool.mockResolvedValue({ dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + imageSize: { width: 512, height: 320 }, + scale: 0.5, viewport: { url: 'https://example.com/pricing', title: 'Pricing', @@ -1214,6 +1235,9 @@ describe('executeBrowserToolOnClient', () => { source: { type: 'base64', media_type: 'image/jpeg', data: '/9j/4AAQ' }, }) expect(reported.content).toContain('https://example.com/pricing') + expect(reported.content).toContain('Viewport: 1024 × 640 CSS pixels') + expect(reported.content).toContain('Encoded image: 512 × 320 pixels') + expect(reported.content).toContain('cssX = 0 + imageX / 0.5; cssY = 0 + imageY / 0.5') expect(reported.dataUrl).toBeUndefined() expect(reported.viewport).toMatchObject({ width: 1024, height: 640 }) }) @@ -1234,6 +1258,7 @@ describe('executeBrowserToolOnClient', () => { mockExecuteBrowserTool.mockResolvedValue({ dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', clip: { x: 20, y: 30, width: 200, height: 100 }, + imageSize: { width: 400, height: 200 }, scale: 2, }) executeBrowserToolOnClient(nextToolCallId(), 'browser_screenshot', { elementId: 0 }) @@ -1242,8 +1267,8 @@ describe('executeBrowserToolOnClient', () => { const reported = mockReportCompletion.mock.calls[0][3] expect(reported.clip).toEqual({ x: 20, y: 30, width: 200, height: 100 }) expect(reported.scale).toBe(2) - expect(reported.content).toContain('cssX = clip.x + imageX / scale') - expect(reported.content).toContain('cssY = clip.y + imageY / scale') + expect(reported.content).toContain('cssX = 20 + imageX / 2') + expect(reported.content).toContain('cssY = 30 + imageY / 2') }) it('gives restored-tab switching the renderer navigation budget', async () => { diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts index c9b52d8b139..d09705eec7d 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts @@ -24,6 +24,7 @@ import { } from '@/lib/copilot/async-runs/lifecycle' import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' import { BrowserToolReplayLedger } from '@/lib/copilot/tools/client/browser-tool-replay-ledger' +import { sanitizeBrowserToolResultForModel } from '@/lib/copilot/tools/client/browser-tool-result' import { reportClientToolCompletion, reportClientToolCompletionOnPageExit, @@ -545,59 +546,6 @@ function timeoutForTool(toolName: BrowserToolName, params: Record;base64,` URL into its parts. */ -function parseBase64DataUrl(dataUrl: string): { mediaType: string; data: string } | null { - const match = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl) - if (!match) return null - return { mediaType: match[1], data: match[2] } -} - -/** - * Reshapes a screenshot into the `attachment` contract the copilot serializes - * into a real image content block, so the model sees the page rather than a - * note about it. The data URL itself never goes inline: `content` is the text - * the model reads beside the image, and the bytes travel under `attachment`. - * - * A malformed data URL degrades to the text note rather than shipping an - * attachment the provider would reject. - */ -function sanitizeResultForModel( - toolName: BrowserToolName, - result: unknown -): Record | undefined { - if (!isRecordLike(result)) { - return result === undefined ? undefined : { value: result } - } - if (toolName === 'browser_screenshot' && typeof result.dataUrl === 'string') { - const { dataUrl, ...rest } = result - const image = parseBase64DataUrl(dataUrl) - if (!image) { - return { - ...rest, - note: 'The screenshot could not be encoded. Use browser_snapshot or browser_read_text instead.', - } - } - const viewport = isRecordLike(rest.viewport) ? rest.viewport : null - const screenshotUrl = - typeof rest.url === 'string' && rest.url - ? rest.url - : viewport && typeof viewport.url === 'string' - ? viewport.url - : '' - const location = screenshotUrl ? ` of ${screenshotUrl}` : '' - const isElementCapture = isRecordLike(rest.clip) - return { - ...rest, - content: `Screenshot${location}. This is the rendered ${isElementCapture ? 'element' : 'viewport'} only — it carries no element ids, so use browser_snapshot before interacting.${isElementCapture ? ' For coordinate actions: cssX = clip.x + imageX / scale; cssY = clip.y + imageY / scale.' : ''}`, - attachment: { - type: 'image', - source: { type: 'base64', media_type: image.mediaType, data: image.data }, - }, - } - } - return result -} - /** * Fire-and-forget entry point invoked by the stream tool-event handler when a * `browser_*` client tool call arrives. @@ -984,6 +932,7 @@ async function doExecuteBrowserTool( } nativeActionPending = false if (cancelled) return + const effectUnconfirmed = isRecordLike(result) && result.effectObserved === false const formStopped = toolName === 'browser_fill_form' && isRecordLike(result) && result.completed === false reportTerminalCompletion( @@ -993,8 +942,10 @@ async function doExecuteBrowserTool( : ASYNC_TOOL_CONFIRMATION_STATUS.success, message: formStopped ? 'Form filling stopped; inspect the partial result' - : 'Browser action completed', - data: sanitizeResultForModel(toolName, result), + : effectUnconfirmed + ? 'Browser input completed; its effect is unconfirmed. Inspect the current state before retrying.' + : 'Browser action completed', + data: sanitizeBrowserToolResultForModel(toolName, result), }, 'Failed to report successful browser tool completion' ) diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-result.test.ts b/apps/sim/lib/copilot/tools/client/browser-tool-result.test.ts new file mode 100644 index 00000000000..fee5723b4e0 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/browser-tool-result.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeBrowserToolResultForModel } from '@/lib/copilot/tools/client/browser-tool-result' + +describe('browser screenshot model projection', () => { + it('keeps an image usable when an older desktop omits coordinate metadata', () => { + const result = sanitizeBrowserToolResultForModel('browser_screenshot', { + dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + }) + expect(result?.attachment).toBeDefined() + expect(result?.content).toContain('coordinate mapping is unavailable') + expect(result?.content).not.toContain('cssX =') + }) + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, '0.5'])( + 'does not publish an invalid scale %s', + (scale) => { + const result = sanitizeBrowserToolResultForModel('browser_screenshot', { + dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + scale, + viewport: { width: 1600, height: 900 }, + }) + expect(result?.content).toContain('Viewport: 1600 × 900 CSS pixels') + expect(result?.content).toContain('coordinate mapping is unavailable') + expect(result?.content).not.toContain('cssX =') + } + ) + + it('does not invent a crop origin or encode malformed dimensions into the caption', () => { + const result = sanitizeBrowserToolResultForModel('browser_screenshot', { + dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + scale: 2, + clip: { x: '10', y: 20 }, + viewport: { width: 0, height: 900 }, + imageSize: { width: Number.POSITIVE_INFINITY, height: 640 }, + }) + expect(result?.attachment).toBeDefined() + expect(result?.content).toContain('coordinate mapping is unavailable') + expect(result?.content).not.toMatch(/Viewport:|Encoded image:|Crop origin:|cssX =/) + }) + + it('maps each crop axis independently when pixel rounding changes its aspect ratio', () => { + const result = sanitizeBrowserToolResultForModel('browser_screenshot', { + dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + scale: 2.5, + clip: { x: 0, y: 20, width: 1.5, height: 100 }, + imageSize: { width: 3, height: 200 }, + }) + expect(result?.content).toContain('Crop origin: (0, 20) in viewport CSS pixels') + expect(result?.content).toContain('cssX = 0 + imageX / 2; cssY = 20 + imageY / 2') + }) + + it('keeps a legacy crop image without publishing its unverified scalar mapping', () => { + const result = sanitizeBrowserToolResultForModel('browser_screenshot', { + dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + scale: 3 / 1.1, + clip: { x: 0.1, y: 20, width: 1.1, height: 100 }, + }) + expect(result?.attachment).toBeDefined() + expect(result?.content).toContain('coordinate mapping is unavailable') + expect(result?.content).not.toContain('cssX =') + }) + + it('uses both encoded dimensions for a resized viewport', () => { + const result = sanitizeBrowserToolResultForModel('browser_screenshot', { + dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + scale: 0.5, + viewport: { width: 1600, height: 901 }, + imageSize: { width: 800, height: 451 }, + }) + expect(result?.content).toContain(`cssX = 0 + imageX / 0.5; cssY = 0 + imageY / ${451 / 901}`) + }) + + it('withholds a legacy viewport scalar when encoded dimensions are unavailable', () => { + const result = sanitizeBrowserToolResultForModel('browser_screenshot', { + dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + scale: 0.5, + viewport: { width: 2048, height: 1025 }, + }) + expect(result?.attachment).toBeDefined() + expect(result?.scale).toBe(0.5) + expect(result?.content).toContain('Viewport: 2048 × 1025 CSS pixels') + expect(result?.content).toContain('coordinate mapping is unavailable') + expect(result?.content).not.toContain('cssY =') + }) + + it('leaves non-image tool results unchanged', () => { + const result = { outline: 'button "Continue" [ref=3]' } + expect(sanitizeBrowserToolResultForModel('browser_snapshot', result)).toBe(result) + expect(sanitizeBrowserToolResultForModel('browser_snapshot', undefined)).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-result.ts b/apps/sim/lib/copilot/tools/client/browser-tool-result.ts new file mode 100644 index 00000000000..7ffafbcd616 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/browser-tool-result.ts @@ -0,0 +1,81 @@ +import type { BrowserToolName } from '@sim/browser-protocol' +import { isRecordLike } from '@sim/utils/object' + +function finiteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function imageDimensions(value: unknown): { width: number; height: number } | null { + if ( + !isRecordLike(value) || + !finiteNumber(value.width) || + !finiteNumber(value.height) || + value.width <= 0 || + value.height <= 0 + ) { + return null + } + return { width: value.width, height: value.height } +} + +/** Projects image bytes and coordinate metadata into the model's image-content contract. */ +export function sanitizeBrowserToolResultForModel( + toolName: BrowserToolName, + result: unknown +): Record | undefined { + if (!isRecordLike(result)) { + return result === undefined ? undefined : { value: result } + } + if (toolName !== 'browser_screenshot' || typeof result.dataUrl !== 'string') return result + + const { dataUrl, ...rest } = result + const image = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl) + if (!image) { + return { + ...rest, + note: 'The screenshot could not be encoded. Use browser_snapshot or browser_read_text instead.', + } + } + const viewport = isRecordLike(rest.viewport) ? rest.viewport : null + const screenshotUrl = + typeof rest.url === 'string' && rest.url + ? rest.url + : viewport && typeof viewport.url === 'string' + ? viewport.url + : '' + const location = screenshotUrl ? ` of ${screenshotUrl}` : '' + const clip = isRecordLike(rest.clip) ? rest.clip : null + const cropSize = imageDimensions(clip) + const viewportSize = imageDimensions(viewport) + const imageSize = imageDimensions(rest.imageSize) + const capturedSize = clip ? cropSize : viewportSize + const scaleX = imageSize && capturedSize ? imageSize.width / capturedSize.width : null + const scaleY = imageSize && capturedSize ? imageSize.height / capturedSize.height : null + const hasScale = finiteNumber(scaleX) && scaleX > 0 && finiteNumber(scaleY) && scaleY > 0 + const origin = clip + ? finiteNumber(clip.x) && finiteNumber(clip.y) + ? { x: clip.x, y: clip.y } + : null + : { x: 0, y: 0 } + const content = [ + `Screenshot${location}. This is the rendered ${clip ? 'element' : 'viewport'} only — it carries no element ids. Use browser_snapshot for element-ref actions and the mapping below for coordinate actions.`, + viewportSize && `Viewport: ${viewportSize.width} × ${viewportSize.height} CSS pixels.`, + imageSize && `Encoded image: ${imageSize.width} × ${imageSize.height} pixels.`, + hasScale && `Image scale: X=${scaleX}, Y=${scaleY} encoded image pixels per CSS pixel.`, + cropSize && `Crop size: ${cropSize.width} × ${cropSize.height} CSS pixels.`, + clip && origin && `Crop origin: (${origin.x}, ${origin.y}) in viewport CSS pixels.`, + hasScale && origin + ? `Coordinate actions use viewport CSS pixels: cssX = ${origin.x} + imageX / ${scaleX}; cssY = ${origin.y} + imageY / ${scaleY}. imageX/imageY refer to the encoded image before any display resizing.` + : 'Screenshot coordinate mapping is unavailable; use browser_snapshot element references or take a new viewport screenshot before coordinate actions.', + ] + .filter(Boolean) + .join(' ') + return { + ...rest, + content, + attachment: { + type: 'image', + source: { type: 'base64', media_type: image[1], data: image[2] }, + }, + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts index e09f1736cbd..378ff6ebd65 100644 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts +++ b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { type WorkspaceFileRow, workspaceFileColumns, workspaceFiles } from '@sim/db/schema' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, asc, desc, eq, isNull, or } from 'drizzle-orm' @@ -115,7 +115,7 @@ export async function findMothershipUploadRowByChatAndName( fileName: string ): Promise { const exactRows = await db - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( and( @@ -136,7 +136,7 @@ export async function findMothershipUploadRowByChatAndName( } const allRows = await db - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( and( @@ -157,7 +157,7 @@ export async function findMothershipUploadRowByChatAndName( export async function listChatUploads(chatId: string): Promise { try { const rows = await db - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( and( diff --git a/apps/sim/lib/copilot/tools/server/generated-schema.test.ts b/apps/sim/lib/copilot/tools/server/generated-schema.test.ts index 1df1ba9e75a..3dc3af8aabb 100644 --- a/apps/sim/lib/copilot/tools/server/generated-schema.test.ts +++ b/apps/sim/lib/copilot/tools/server/generated-schema.test.ts @@ -5,6 +5,30 @@ import { describe, expect, it } from 'vitest' import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema' import { OrchestrationError } from '@/lib/core/orchestration/types' +describe('validateGeneratedToolPayload browser_select_option parameters', () => { + it.each([ + { elementId: 0, value: 'a' }, + { elementId: 0, values: ['a', 'b'] }, + { elementId: 0, values: [] }, + ])('accepts a single selection mode %#', (payload) => { + expect(validateGeneratedToolPayload('browser_select_option', 'parameters', payload)).toBe( + payload + ) + }) + + it.each([ + { elementId: 0 }, + { elementId: 0, value: 'a', values: ['b'] }, + { elementId: 0, value: 'a', values: [] }, + { elementId: 0, values: [1] }, + { elementId: 0, values: Array.from({ length: 101 }, () => 'a') }, + ])('rejects missing, conflicting or malformed selection arguments %#', (payload) => { + expect(() => + validateGeneratedToolPayload('browser_select_option', 'parameters', payload) + ).toThrow(OrchestrationError) + }) +}) + describe('validateGeneratedToolPayload browser_fill_form parameters', () => { it('accepts mixed fields, including empty text and false checked state', () => { const payload = { diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 7f5a55a89ed..03339aac020 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -18,6 +18,7 @@ import { and, desc, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' import { getAccountBillingSnapshot } from '@/lib/billing/core/account-billing-snapshot' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { createCopilotChatPrincipal } from '@/lib/copilot/auth/application-delegation' import { buildWorkspaceContextMd, buildWorkspaceMd, @@ -116,10 +117,11 @@ import { } from '@/lib/core/config/env-flags' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { CredentialGroupRecord } from '@/lib/credential-groups/types' +import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { listPersonalCredentials } from '@/lib/credentials/application/personal-credentials' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, - getEnrolledManagedOAuthCredentials, } from '@/lib/credentials/environment' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' @@ -3158,7 +3160,21 @@ export class WorkspaceVFS { getAccessibleOAuthCredentials(workspaceId, userId, { isWorkspaceAdmin }).then( async (accessible) => [ ...accessible, - ...(await getEnrolledManagedOAuthCredentials(workspaceId, userId)), + ...( + await listPersonalCredentials.execute({ + principal: createCopilotChatPrincipal( + { workspaceId, userId }, + CREDENTIAL_DELEGATION_AUDIENCE + ), + input: { workspaceId }, + }) + ).credentials + .filter((entry) => entry.type === 'managed_oauth') + .map((entry) => ({ + ...entry, + type: 'managed_oauth' as const, + role: 'member' as const, + })), ] ), listApiKeys(workspaceId), diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index f9d1b64e6a5..aafaa156af4 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -40,6 +40,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ import { AuditAction, AuditResourceType } from '@sim/audit' import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' +import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' import { resolveCurrentOutboundRoute } from '@/lib/core/network/context.server' import type { OrchestrationError } from '@/lib/core/orchestration/types' import { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION } from '@/lib/resource-policies/registry' @@ -494,3 +495,34 @@ describe('defineAuthorizedWorkspaceUseCase', () => { ) }) }) + +describe('projected audit workspace attribution', () => { + beforeEach(() => vi.clearAllMocks()) + + it.each([ + { override: undefined, expected: 'workspace-1' }, + { override: 'workspace-2', expected: 'workspace-2' }, + { override: null, expected: null }, + ])('records the canonical workspace override $override', ({ override, expected }) => { + recordProjectedUseCaseAuditEntries( + operation, + 'workspace-1', + sessionPrincipal, + undefined, + [ + { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + workspaceId: override, + }, + ], + 'organization-1' + ) + expect(mocks.recordAudit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + workspaceId: expected, + metadata: expect.objectContaining({ organizationId: 'organization-1' }), + }) + ) + }) +}) diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index 19ed9ff3965..5bc897715d1 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -17,8 +17,8 @@ import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types import type { ResourcePolicyBinding } from '@/lib/resource-policies/registry' export interface WorkspaceUseCaseAuditEntry { - /** Canonical workspace affected by a cross-workspace mutation, when different from its authorization scope. */ - workspaceId?: string + /** Canonical affected workspace; null keeps an organization event outside the authorization workspace. */ + workspaceId?: string | null action: AuditActionType resourceType: AuditResourceTypeValue resourceId?: string @@ -106,7 +106,7 @@ export function recordProjectedUseCaseAuditEntries( const attribution: PrincipalAuditAttribution = resolvePrincipalAuditAttribution(principal) for (const entry of entries) { recordAudit({ - workspaceId: entry.workspaceId ?? workspaceId, + workspaceId: entry.workspaceId === undefined ? workspaceId : entry.workspaceId, actorId: attribution.actorId, actorName: attribution.actorName, action: entry.action, diff --git a/apps/sim/lib/core/application/organization-authorization.test.ts b/apps/sim/lib/core/application/organization-authorization.test.ts index dfe90fd235b..b89d6e0755c 100644 --- a/apps/sim/lib/core/application/organization-authorization.test.ts +++ b/apps/sim/lib/core/application/organization-authorization.test.ts @@ -58,6 +58,49 @@ beforeEach(() => { }) describe('organization operation authorization', () => { + it('rechecks a transaction member role on its own executor and locks it', async () => { + const query = { + from: vi.fn(), + where: vi.fn(), + for: vi.fn(), + limit: vi.fn().mockResolvedValue([{ role: 'member' }]), + } + query.from.mockReturnValue(query) + query.where.mockReturnValue(query) + query.for.mockReturnValue(query) + const executor = { select: vi.fn().mockReturnValue(query) } + const review = defineOrganizationOperation({ + id: 'access_requests.resolve', + minimumRole: 'admin', + principalKinds: ['session'], + /** permission-group-exempt: review must remain reachable when a requested capability is denied. */ + capability: 'none', + }) + await expect( + authorizeOrganizationOperation( + principal, + review, + { organizationId: 'org' }, + { executor, forUpdate: true } + ) + ).rejects.toThrow('Organization administrator access is required') + expect(query.for).toHaveBeenCalledExactlyOnceWith('update') + expect(db.select).not.toHaveBeenCalled() + expect(mocks.config).not.toHaveBeenCalled() + }) + it('does not acquire another pooled connection for capability-exempt session authorization', async () => { + const review = defineOrganizationOperation({ + id: 'access_requests.list_mine', + minimumRole: 'member', + principalKinds: ['session'], + /** permission-group-exempt: own request history is available independently of capability restrictions. */ + capability: 'none', + }) + await expect( + authorizeOrganizationOperation(principal, review, { organizationId: 'org' }) + ).resolves.toMatchObject({ userId: principal.userId }) + expect(mocks.config).not.toHaveBeenCalled() + }) it('admits Slack delegation only when the operation explicitly allows that service', async () => { const issuedAt = new Date() const slack: OrganizationDelegatedPrincipal = { diff --git a/apps/sim/lib/core/application/organization-authorization.ts b/apps/sim/lib/core/application/organization-authorization.ts index 97035f68272..64fa5b7d63b 100644 --- a/apps/sim/lib/core/application/organization-authorization.ts +++ b/apps/sim/lib/core/application/organization-authorization.ts @@ -29,12 +29,18 @@ export interface OrganizationMembershipContext extends OrganizationAuthorization role: OrganizationRole } +export interface OrganizationAuthorizationOptions { + executor?: Pick + forUpdate?: boolean +} + /** Rechecks routed organization membership; owning a workspace is irrelevant to this grant. */ export async function requireOrganizationMembership( principal: Principal, organizationId: string, minimumRole: 'member' | 'admin' = 'member', - capability: OperationDeclarableCapability | 'none' = 'none' + capability: OperationDeclarableCapability | 'none' = 'none', + options: OrganizationAuthorizationOptions = {} ): Promise { if (principal.kind !== 'session' && !isUserCredentialPrincipal(principal)) { throw new PrincipalKindAuthorizationError(principal.kind, 'organization.membership') @@ -44,7 +50,8 @@ export async function requireOrganizationMembership( organizationId, minimumRole, capability, - isUserCredentialPrincipal(principal) ? principal : undefined + isUserCredentialPrincipal(principal) ? principal : undefined, + options ) } @@ -53,19 +60,23 @@ async function requireOrganizationSubjectMembership( organizationId: string, minimumRole: 'member' | 'admin', capability: OperationDeclarableCapability | 'none', - userCredential?: PersonalApiKeyPrincipal | OAuthAccessTokenPrincipal + userCredential?: PersonalApiKeyPrincipal | OAuthAccessTokenPrincipal, + options: OrganizationAuthorizationOptions = {} ): Promise { - const [membership] = await db + const query = (options.executor ?? db) .select({ role: member.role }) .from(member) .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) - .limit(1) + const [membership] = options.forUpdate ? await query.for('update').limit(1) : await query.limit(1) const parsedRole = organizationRoleSchema.safeParse(membership?.role) if (!parsedRole.success) throw new OrchestrationError('not_found', 'Organization not found') if (minimumRole === 'admin' && !isOrgAdminRole(parsedRole.data)) { throw new OrchestrationError('forbidden', 'Organization administrator access is required') } - const config = await getUserPermissionConfigForOrganization(organizationId) + const config = + capability === 'none' && !userCredential + ? null + : await getUserPermissionConfigForOrganization(organizationId) if (userCredential && capabilityDeniedBy('personal_api_key.use', config)) refuseCapability('personal_api_key.use') if (userCredential?.kind === 'oauth_access_token') { @@ -81,7 +92,8 @@ async function requireOrganizationSubjectMembership( export async function authorizeOrganizationOperation( principal: Principal, operation: OrganizationOperation, - context: OrganizationAuthorizationContext + context: OrganizationAuthorizationContext, + options: OrganizationAuthorizationOptions = {} ): Promise { if (!operation.principalKinds.some((kind) => kind === principal.kind)) { throw new PrincipalKindAuthorizationError(principal.kind, operation.id) @@ -109,13 +121,16 @@ export async function authorizeOrganizationOperation( principal.subjectUserId, context.organizationId, operation.minimumRole, - operation.capability + operation.capability, + undefined, + options ) } return requireOrganizationMembership( principal, context.organizationId, operation.minimumRole, - operation.capability + operation.capability, + options ) } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 72c47f9a279..2fcfd17badb 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -471,7 +471,10 @@ export const env = createEnv({ KB_CONFIG_MISTRAL_OCR_MAX_CONCURRENT: z.number().int().positive().max(64).optional().default(2), /** JSON map from API-key SHA-256 fingerprints to organization IDs; keys in one org share capacity. */ MISTRAL_OCR_QUOTA_GROUPS: z.string().optional(), - KB_CONFIG_RERANK_REQUESTS_PER_MINUTE: z.number().positive().optional().default(60), + /** Explicit override for all rerank credentials; otherwise defaults to 60, or 600 for hosted Cohere. */ + KB_CONFIG_RERANK_REQUESTS_PER_MINUTE: z.number().positive().optional(), + /** Overrides the shared rerank setting only for Sim-hosted Cohere credentials. */ + KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: z.number().positive().optional(), KB_CONFIG_DOCUMENT_CONCURRENCY: z.number().optional().default(4), // Concurrent documents in the in-process (non-Trigger) path KB_CONFIG_BATCH_SIZE: z.number().optional().default(2000), // Chunks to process per embedding batch KB_CONFIG_DOCUMENT_BATCH_SIZE: z.number().optional().default(10), // Documents per batch in the in-process (non-Trigger) path @@ -623,6 +626,7 @@ export const env = createEnv({ FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) TABLE_ROW_TTL: z.boolean().optional(), + PERMISSION_ACCESS_REQUESTS_ENABLED: z.boolean().optional(), CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally KNOWLEDGE_MEMBER_ACCESS: z.boolean().optional(), // Enable per-member knowledge connectors and hybrid-by-default retrieval globally diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index de18088049a..bd010a4f949 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FeatureFlagContext, FeatureFlagName } from '@/lib/core/config/feature-flags' const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ @@ -13,6 +13,7 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, TABLES_V2_API: undefined as boolean | undefined, TABLE_ROW_TTL: undefined as boolean | undefined, + PERMISSION_ACCESS_REQUESTS_ENABLED: undefined as boolean | undefined, CREDENTIAL_GROUPS: undefined as boolean | undefined, KNOWLEDGE_MEMBER_ACCESS: undefined as boolean | undefined, SLACK_SEARCH_SHARED_APP: undefined as boolean | undefined, @@ -336,3 +337,26 @@ describe('table-row-ttl flag', () => { expect(await isFeatureEnabled('table-row-ttl')).toBe(true) }) }) + +describe('permission access request rollout', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isAppConfigEnabled: false }) + envRef.PERMISSION_ACCESS_REQUESTS_ENABLED = undefined + }) + afterEach(() => { + envRef.PERMISSION_ACCESS_REQUESTS_ENABLED = undefined + }) + it('defaults off and can be enabled with the fallback secret', async () => { + expect(await isFeatureEnabled('permission-access-requests')).toBe(false) + envRef.PERMISSION_ACCESS_REQUESTS_ENABLED = true + expect(await isFeatureEnabled('permission-access-requests')).toBe(true) + expect(mockFetch).not.toHaveBeenCalled() + }) + it('uses a global AppConfig rule without organization targeting', async () => { + withAppConfig({ 'permission-access-requests': { enabled: false, orgIds: ['org'] } }) + expect(await isFeatureEnabled('permission-access-requests')).toBe(false) + withAppConfig({ 'permission-access-requests': { enabled: true } }) + expect(await isFeatureEnabled('permission-access-requests')).toBe(true) + }) +}) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 2a94f48ee09..597001b5cc2 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -46,6 +46,11 @@ interface FeatureFlagDefinition { /** The single registry of known flags. To add a flag, add one entry here. */ const FEATURE_FLAGS = { + 'permission-access-requests': { + description: + 'Enable permission and member usage-cap requests globally. Organizations can opt out in access control settings.', + fallback: 'PERMISSION_ACCESS_REQUESTS_ENABLED', + }, 'slack-search-shared-app': { description: 'Enable the official shared Slack app for existing Search customers. Supports orgId ' + diff --git a/apps/sim/lib/core/outbox/constants.ts b/apps/sim/lib/core/outbox/constants.ts new file mode 100644 index 00000000000..c19580a9832 --- /dev/null +++ b/apps/sim/lib/core/outbox/constants.ts @@ -0,0 +1,8 @@ +export const OUTBOX_PROCESSOR_MAX_RUNTIME_MS = 760_000 +export const OUTBOX_PROCESSOR_RECOVERY_CUTOFF_MS = 770_000 +export const OUTBOX_PROCESSOR_MAX_DURATION_SECONDS = 900 +export const OUTBOX_PROCESSOR_INTERVAL_MS = 60_000 +/** Allow every scheduled tick to start even when earlier workers use their full execution window. */ +export const OUTBOX_PROCESSOR_CONCURRENCY = Math.ceil( + (OUTBOX_PROCESSOR_MAX_DURATION_SECONDS * 1000) / OUTBOX_PROCESSOR_INTERVAL_MS +) diff --git a/apps/sim/lib/core/outbox/enqueue.test.ts b/apps/sim/lib/core/outbox/enqueue.test.ts new file mode 100644 index 00000000000..b6d4d992185 --- /dev/null +++ b/apps/sim/lib/core/outbox/enqueue.test.ts @@ -0,0 +1,73 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + trigger: vi.fn(), + processor: vi.fn(), + enabled: true, +})) +vi.mock('@trigger.dev/sdk', () => ({ tasks: { trigger: mocks.trigger } })) +vi.mock('@/lib/core/config/env-flags', () => ({ + get isTriggerDevEnabled() { + return mocks.enabled + }, +})) +vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: async () => 'us-east-1' })) +vi.mock('@/lib/core/outbox/processor', () => ({ runOutboxProcessor: mocks.processor })) + +import { enqueueOutboxProcessor } from '@/lib/core/outbox/enqueue' + +describe('outbox processor enqueue', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-16T12:34:45Z')) + mocks.enabled = true + mocks.trigger.mockResolvedValue({ id: 'run-1' }) + }) + afterEach(() => vi.useRealTimers()) + + it('returns durable acceptance without doing outbox work in the request', async () => { + await expect(enqueueOutboxProcessor()).resolves.toEqual({ + backend: 'trigger-dev', + jobId: 'run-1', + }) + expect(mocks.trigger).toHaveBeenCalledWith('process-outbox', undefined, { + idempotencyKey: `process-outbox:${Math.floor(Date.now() / 60_000)}`, + idempotencyKeyTTL: '5m', + maxDuration: 900, + region: 'us-east-1', + }) + expect(mocks.processor).not.toHaveBeenCalled() + }) + + it('deduplicates duplicate ticks while allowing the next minute to drain more work', async () => { + await enqueueOutboxProcessor() + await enqueueOutboxProcessor() + vi.advanceTimersByTime(60_000) + await enqueueOutboxProcessor() + const keys = mocks.trigger.mock.calls.map((call) => call[2].idempotencyKey) + expect(keys[0]).toBe(keys[1]) + expect(keys[2]).not.toBe(keys[0]) + }) + + it('fails closed on an enqueue error without starting concurrent inline work', async () => { + mocks.trigger.mockRejectedValueOnce(new Error('Trigger unavailable')) + await expect(enqueueOutboxProcessor()).rejects.toThrow('Trigger unavailable') + expect(mocks.processor).not.toHaveBeenCalled() + }) + + it('preserves synchronous processing for self-hosted deployments without Trigger', async () => { + mocks.enabled = false + const output = { + result: { processed: 4, retried: 0, deadLettered: 0, leaseLost: 0, reaped: 0 }, + recoveredDocuments: 2, + reapedBackgroundWork: 1, + } + mocks.processor.mockResolvedValueOnce(output) + await expect(enqueueOutboxProcessor()).resolves.toEqual({ backend: 'inline', output }) + expect(mocks.trigger).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/core/outbox/enqueue.ts b/apps/sim/lib/core/outbox/enqueue.ts new file mode 100644 index 00000000000..95f2bf9e03d --- /dev/null +++ b/apps/sim/lib/core/outbox/enqueue.ts @@ -0,0 +1,32 @@ +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { + OUTBOX_PROCESSOR_INTERVAL_MS, + OUTBOX_PROCESSOR_MAX_DURATION_SECONDS, +} from '@/lib/core/outbox/constants' +import type { OutboxProcessorResult } from '@/lib/core/outbox/processor' +import type { processOutboxTask } from '@/background/process-outbox' + +type OutboxProcessorEnqueueResult = + | { backend: 'trigger-dev'; jobId: string } + | { backend: 'inline'; output: OutboxProcessorResult } + +/** The database owns delivery state; the cron request waits only for durable worker acceptance. */ +export async function enqueueOutboxProcessor(): Promise { + if (!isTriggerDevEnabled) { + const { runOutboxProcessor } = await import('@/lib/core/outbox/processor') + return { backend: 'inline', output: await runOutboxProcessor() } + } + + const [{ tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + const scheduleWindow = Math.floor(Date.now() / OUTBOX_PROCESSOR_INTERVAL_MS) + const handle = await tasks.trigger('process-outbox', undefined, { + idempotencyKey: `process-outbox:${scheduleWindow}`, + idempotencyKeyTTL: '5m', + maxDuration: OUTBOX_PROCESSOR_MAX_DURATION_SECONDS, + region: await resolveTriggerRegion(), + }) + return { backend: 'trigger-dev', jobId: handle.id } +} diff --git a/apps/sim/lib/core/outbox/processor.test.ts b/apps/sim/lib/core/outbox/processor.test.ts new file mode 100644 index 00000000000..e63b025eca5 --- /dev/null +++ b/apps/sim/lib/core/outbox/processor.test.ts @@ -0,0 +1,119 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + process: vi.fn(), + recover: vi.fn(), + reap: vi.fn(), +})) +vi.mock('@/lib/core/outbox/service', () => ({ processOutboxEvents: mocks.process })) +vi.mock('@/lib/knowledge/documents/processing-recovery', () => ({ + recoverKnowledgeDocumentProcessing: mocks.recover, +})) +vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({ + reapStaleBackgroundWork: mocks.reap, +})) +vi.mock('@/lib/knowledge/connectors/connector-error', () => ({ + getConnectorFailureDiagnostic: () => undefined, +})) +vi.mock('@/lib/admin/invitation-operation', () => ({ adminInvitationOperationOutboxHandlers: {} })) +vi.mock('@/lib/admin/member-operation', () => ({ adminMemberOperationOutboxHandlers: {} })) +vi.mock('@/lib/billing/enterprise-owner-claim', () => ({ enterpriseOwnerClaimOutboxHandlers: {} })) +vi.mock('@/lib/billing/enterprise-provisioning', () => ({ enterpriseIssuanceOutboxHandlers: {} })) +vi.mock('@/lib/billing/organizations/membership-reconciliation', () => ({ + membershipBillingOutboxHandlers: {}, +})) +vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({ billingOutboxHandlers: {} })) +vi.mock('@/lib/invitations/direct-grant', () => ({ directGrantOutboxHandlers: {} })) +vi.mock('@/lib/knowledge/application/slack-search/outbox', () => ({ + slackSearchOutboxHandlers: {}, +})) +vi.mock('@/lib/knowledge/documents/processing-outbox-handler', () => ({ + knowledgeDocumentProcessingOutboxHandlers: {}, +})) +vi.mock('@/lib/organizations/resource-cleanup', () => ({ + organizationResourceCleanupOutboxHandlers: {}, +})) +vi.mock('@/lib/permission-access-requests/notifications', () => ({ + permissionAccessRequestOutboxHandlers: {}, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox', () => ({ + workspaceFileLiveDocOutboxHandlers: {}, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox', () => ({ + workspaceFileStorageCleanupOutboxHandlers: {}, +})) +vi.mock('@/lib/workflows/deployment-outbox', () => ({ workflowDeploymentOutboxHandlers: {} })) +vi.mock('@/lib/workspaces/admin-move', () => ({ invitationMigrationOutboxHandlers: {} })) +vi.mock('@/lib/workspaces/operations/outbox', () => ({ workspaceOperationOutboxHandlers: {} })) +vi.mock('@/ee/workspace-forking/application/content-outbox', () => ({ + forkContentOutboxHandlers: {}, +})) + +import { runOutboxProcessor } from '@/lib/core/outbox/processor' + +describe('outbox processor recovery', () => { + const result = { processed: 5, retried: 1, deadLettered: 0, leaseLost: 0, reaped: 0 } + + beforeEach(() => { + vi.resetAllMocks() + vi.useFakeTimers() + mocks.process.mockResolvedValue(result) + mocks.recover.mockResolvedValue(2) + mocks.reap.mockResolvedValue(3) + }) + afterEach(() => vi.useRealTimers()) + + it('preserves the processing limits and reports independent recovery work', async () => { + await expect(runOutboxProcessor()).resolves.toEqual({ + result, + recoveredDocuments: 2, + reapedBackgroundWork: 3, + }) + expect(mocks.process).toHaveBeenCalledWith(expect.any(Object), { + batchSize: 500, + maxRuntimeMs: 760_000, + minRemainingMs: 95_000, + }) + }) + + it('still reaps expired background work when document recovery fails', async () => { + mocks.recover.mockRejectedValueOnce(new Error('document recovery unavailable')) + await expect(runOutboxProcessor()).resolves.toEqual({ + result, + recoveredDocuments: 0, + reapedBackgroundWork: 3, + }) + }) + + it('retains successful delivery results when the background-work reaper fails', async () => { + mocks.reap.mockRejectedValueOnce(new Error('reaper unavailable')) + await expect(runOutboxProcessor()).resolves.toEqual({ + result, + recoveredDocuments: 2, + reapedBackgroundWork: 0, + }) + }) + + it('skips document recovery after the processing budget is exhausted', async () => { + mocks.process.mockImplementationOnce(async () => { + vi.advanceTimersByTime(770_000) + return result + }) + await expect(runOutboxProcessor()).resolves.toEqual({ + result, + recoveredDocuments: 0, + reapedBackgroundWork: 3, + }) + expect(mocks.recover).not.toHaveBeenCalled() + }) + + it('propagates delivery failures to the worker instead of reporting success', async () => { + mocks.process.mockRejectedValueOnce(new Error('database unavailable')) + await expect(runOutboxProcessor()).rejects.toThrow('database unavailable') + expect(mocks.recover).not.toHaveBeenCalled() + expect(mocks.reap).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/core/outbox/processor.ts b/apps/sim/lib/core/outbox/processor.ts new file mode 100644 index 00000000000..7c9fbe9fea7 --- /dev/null +++ b/apps/sim/lib/core/outbox/processor.ts @@ -0,0 +1,101 @@ +import { db } from '@sim/db' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { adminInvitationOperationOutboxHandlers } from '@/lib/admin/invitation-operation' +import { adminMemberOperationOutboxHandlers } from '@/lib/admin/member-operation' +import { enterpriseOwnerClaimOutboxHandlers } from '@/lib/billing/enterprise-owner-claim' +import { enterpriseIssuanceOutboxHandlers } from '@/lib/billing/enterprise-provisioning' +import { membershipBillingOutboxHandlers } from '@/lib/billing/organizations/membership-reconciliation' +import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers' +import { + OUTBOX_PROCESSOR_MAX_RUNTIME_MS, + OUTBOX_PROCESSOR_RECOVERY_CUTOFF_MS, +} from '@/lib/core/outbox/constants' +import { type ProcessOutboxResult, processOutboxEvents } from '@/lib/core/outbox/service' +import { DeadlineExceededError } from '@/lib/core/utils/deadline' +import { directGrantOutboxHandlers } from '@/lib/invitations/direct-grant' +import { slackSearchOutboxHandlers } from '@/lib/knowledge/application/slack-search/outbox' +import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error' +import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler' +import { recoverKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery' +import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup' +import { permissionAccessRequestOutboxHandlers } from '@/lib/permission-access-requests/notifications' +import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' +import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' +import { invitationMigrationOutboxHandlers } from '@/lib/workspaces/admin-move' +import { workspaceOperationOutboxHandlers } from '@/lib/workspaces/operations/outbox' +import { forkContentOutboxHandlers } from '@/ee/workspace-forking/application/content-outbox' +import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store' + +const logger = createLogger('OutboxProcessor') + +const handlers = { + ...slackSearchOutboxHandlers, + ...adminInvitationOperationOutboxHandlers, + ...adminMemberOperationOutboxHandlers, + ...billingOutboxHandlers, + ...membershipBillingOutboxHandlers, + ...enterpriseIssuanceOutboxHandlers, + ...enterpriseOwnerClaimOutboxHandlers, + ...invitationMigrationOutboxHandlers, + ...directGrantOutboxHandlers, + ...knowledgeDocumentProcessingOutboxHandlers, + ...organizationResourceCleanupOutboxHandlers, + ...permissionAccessRequestOutboxHandlers, + ...workspaceFileLiveDocOutboxHandlers, + ...workspaceFileStorageCleanupOutboxHandlers, + ...workflowDeploymentOutboxHandlers, + ...workspaceOperationOutboxHandlers, + ...forkContentOutboxHandlers, +} as const + +export interface OutboxProcessorResult { + result: ProcessOutboxResult + recoveredDocuments: number + reapedBackgroundWork: number +} + +/** Processes one bounded batch and its recovery work in either the worker or self-hosted cron. */ +export async function runOutboxProcessor(): Promise { + const startedAt = Date.now() + const result = await processOutboxEvents(handlers, { + batchSize: 500, + maxRuntimeMs: OUTBOX_PROCESSOR_MAX_RUNTIME_MS, + minRemainingMs: 95_000, + }) + + let recoveredDocuments = 0 + try { + if (Date.now() - startedAt < OUTBOX_PROCESSOR_RECOVERY_CUTOFF_MS) { + recoveredDocuments = await recoverKnowledgeDocumentProcessing() + } + } catch (error) { + logger.error('Stored document recovery failed', { + error: getConnectorFailureDiagnostic(error) ?? { + category: error instanceof DeadlineExceededError ? 'deadline' : 'internal', + message: + error instanceof DeadlineExceededError + ? error.message + : 'Unexpected stored-document recovery failure', + }, + }) + } + + /** Reap independently so an expired fork lease cannot prevent outbox delivery. */ + let reapedBackgroundWork = 0 + try { + reapedBackgroundWork = await reapStaleBackgroundWork(db) + } catch (error) { + logger.error('Background-work reap failed', { error: toError(error).message }) + } + + const output = { result, reapedBackgroundWork, recoveredDocuments } + logger.info('Outbox processing completed', { + ...result, + reapedBackgroundWork, + recoveredDocuments, + durationMs: Date.now() - startedAt, + }) + return output +} diff --git a/apps/sim/lib/core/rate-limiter/provider-admission.test.ts b/apps/sim/lib/core/rate-limiter/provider-admission.test.ts index 76d71ce4a81..6e3c80bcacf 100644 --- a/apps/sim/lib/core/rate-limiter/provider-admission.test.ts +++ b/apps/sim/lib/core/rate-limiter/provider-admission.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -18,6 +19,7 @@ vi.mock('@/lib/core/rate-limiter/storage/factory', () => ({ })) import { waitForProviderAdmission } from '@/lib/core/rate-limiter/provider-admission' +import { DbTokenBucket } from '@/lib/core/rate-limiter/storage/db-token-bucket' import { retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils' const INPUT = { @@ -32,6 +34,11 @@ describe('provider admission', () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() + resetDbChainMock() + setEnv({ + KB_CONFIG_RERANK_REQUESTS_PER_MINUTE: undefined, + KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: undefined, + }) getCooldownUntil.mockResolvedValue(null) consumeTokens.mockResolvedValue({ allowed: true, tokensRemaining: 1, resetAt: new Date() }) }) @@ -66,6 +73,131 @@ describe('provider admission', () => { expect(consumeTokens).toHaveBeenCalledTimes(2) }) + it.each([ + { isHostedCredential: true, maxTokens: 16, refillRate: 10 }, + { isHostedCredential: false, maxTokens: 2, refillRate: 1 }, + { isHostedCredential: undefined, maxTokens: 2, refillRate: 1 }, + ])('selects the rerank budget for hosted=$isHostedCredential', async (fixture) => { + await waitForProviderAdmission({ + ...INPUT, + operation: 'rerank', + providerId: 'cohere', + isHostedCredential: fixture.isHostedCredential, + }) + expect(consumeTokens.mock.calls[0][0]).toEqual([ + { + key: 'provider:rerank:cohere:hashed-credential:requests', + cost: 1, + config: { + maxTokens: fixture.maxTokens, + refillRate: fixture.refillRate, + refillIntervalMs: 1000, + }, + }, + ]) + }) + + it('preserves the shared override unless a hosted-specific override is set', async () => { + setEnv({ KB_CONFIG_RERANK_REQUESTS_PER_MINUTE: '120' }) + const input = { ...INPUT, operation: 'rerank' as const, providerId: 'cohere' } + await waitForProviderAdmission({ ...input, isHostedCredential: true }) + await waitForProviderAdmission(input) + setEnv({ KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: '300' }) + await waitForProviderAdmission({ ...input, isHostedCredential: true }) + await waitForProviderAdmission(input) + expect(consumeTokens.mock.calls.map(([reservations]) => reservations[0].config)).toEqual([ + { maxTokens: 16, refillRate: 2, refillIntervalMs: 1000 }, + { maxTokens: 2, refillRate: 2, refillIntervalMs: 1000 }, + { maxTokens: 16, refillRate: 5, refillIntervalMs: 1000 }, + { maxTokens: 2, refillRate: 2, refillIntervalMs: 1000 }, + ]) + }) + + it('caps the hosted burst when the configured minute budget is smaller', async () => { + setEnv({ KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: '1' }) + await waitForProviderAdmission({ + ...INPUT, + operation: 'rerank', + providerId: 'cohere', + isHostedCredential: true, + }) + expect(consumeTokens.mock.calls[0][0][0].config).toMatchObject({ + maxTokens: 1, + refillRate: 1 / 60, + }) + }) + + it.each(['0', '-1', '', 'invalid', 'Infinity'])( + 'rejects an invalid hosted rerank override (%s) before spending capacity', + async (value) => { + setEnv({ KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: value }) + await expect( + waitForProviderAdmission({ + ...INPUT, + operation: 'rerank', + providerId: 'cohere', + isHostedCredential: true, + }) + ).rejects.toThrow('Hosted rerank requests per minute must be finite and at least 1') + expect(consumeTokens).not.toHaveBeenCalled() + } + ) + + it.each([ + { operation: 'embedding', providerId: 'openai', maxTokens: 64, refillRate: 10 }, + { operation: 'ocr', providerId: 'mistral', maxTokens: 2, refillRate: 1 }, + { operation: 'rerank', providerId: 'another-provider', maxTokens: 2, refillRate: 1 }, + ] as const)('preserves the $operation budget for $providerId', async (fixture) => { + setEnv({ KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: '300' }) + await waitForProviderAdmission({ + ...INPUT, + operation: fixture.operation, + providerId: fixture.providerId, + isHostedCredential: true, + }) + const reservations = consumeTokens.mock.calls[0][0] + expect(reservations.at(-1).config).toMatchObject({ + maxTokens: fixture.maxTokens, + refillRate: fixture.refillRate, + }) + }) + + it('sustains 600 hosted reranks per minute through the real bucket refill calculation', async () => { + const input = { + ...INPUT, + operation: 'rerank' as const, + providerId: 'cohere', + isHostedCredential: true, + maxWaitMs: 1, + } + let stored: { key: string; tokens: string; lastRefillAt: Date } | undefined + dbChainMockFns.values.mockImplementation((rows) => { + stored ??= rows.find((row: { key: string }) => row.key.endsWith(':requests')) + return { onConflictDoNothing: vi.fn().mockResolvedValue(undefined) } + }) + dbChainMockFns.limit.mockImplementation(async () => [stored]) + dbChainMockFns.set.mockImplementation((values) => { + Object.assign(stored!, values) + return { where: vi.fn().mockResolvedValue(undefined) } + }) + const bucket = new DbTokenBucket() + consumeTokens.mockImplementation((reservations, options) => + bucket.consumeTokensAtomically(reservations, options) + ) + + for (let request = 0; request < 16; request++) await waitForProviderAdmission(input) + await expect(waitForProviderAdmission(input)).rejects.toMatchObject({ retryAfterMs: 1000 }) + for (let second = 0; second < 60; second++) { + await vi.advanceTimersByTimeAsync(1000) + for (let request = 0; request < 10; request++) await waitForProviderAdmission(input) + await expect(waitForProviderAdmission(input)).rejects.toMatchObject({ retryAfterMs: 1000 }) + } + expect(stored?.tokens).toBe('0') + expect(new Set(consumeTokens.mock.calls.map(([reservations]) => reservations[0].key))).toEqual( + new Set(['provider:rerank:cohere:hashed-credential:requests']) + ) + }) + it('stops waiting immediately when the caller aborts', async () => { consumeTokens.mockResolvedValue({ allowed: false, retryAfterMs: 5000 }) const controller = new AbortController() diff --git a/apps/sim/lib/core/rate-limiter/provider-admission.ts b/apps/sim/lib/core/rate-limiter/provider-admission.ts index b9b191c2f81..b516db90a16 100644 --- a/apps/sim/lib/core/rate-limiter/provider-admission.ts +++ b/apps/sim/lib/core/rate-limiter/provider-admission.ts @@ -21,6 +21,8 @@ export interface ProviderIdentity { const BULK_LANE_SHARE = 0.9 interface ProviderAdmissionInput extends ProviderIdentity { + /** True only for platform-owned credentials resolved on hosted Sim. Does not change bucket identity. */ + isHostedCredential?: boolean inputTokens?: number signal?: AbortSignal maxWaitMs: number @@ -35,8 +37,20 @@ interface ProviderAdmissionInput extends ProviderIdentity { * race for a handful of slots while the token budget sits unused. */ const EMBEDDING_REQUEST_BURST = 64 +const HOSTED_RERANK_REQUEST_BURST = 16 const DEFAULT_REQUEST_BURST = 2 +function hostedRerankRequestsPerMinute(): number { + const configured = + env.KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE ?? env.KB_CONFIG_RERANK_REQUESTS_PER_MINUTE + if (configured === undefined) return 600 + const requestsPerMinute = Number(configured) + if (!Number.isFinite(requestsPerMinute) || requestsPerMinute < 1) { + throw new Error('Hosted rerank requests per minute must be finite and at least 1') + } + return requestsPerMinute +} + /** A local admission wait expired; the document scheduler may retry the work later. */ export class ProviderAdmissionTimeoutError extends Error { readonly retryable = false @@ -59,12 +73,16 @@ export async function waitForProviderAdmission(input: ProviderAdmissionInput): P input.signal?.throwIfAborted() const deadlineAt = Date.now() + input.maxWaitMs const key = providerKey(input) + const isHostedRerank = + input.operation === 'rerank' && input.providerId === 'cohere' && input.isHostedCredential const requestsPerMinute = input.operation === 'embedding' ? envNumber(env.KB_CONFIG_EMBEDDING_REQUESTS_PER_MINUTE, 600, { min: 1 }) : input.operation === 'ocr' ? envNumber(env.KB_CONFIG_OCR_REQUESTS_PER_MINUTE, 60, { min: 1 }) - : envNumber(env.KB_CONFIG_RERANK_REQUESTS_PER_MINUTE, 60, { min: 1 }) + : isHostedRerank + ? hostedRerankRequestsPerMinute() + : envNumber(env.KB_CONFIG_RERANK_REQUESTS_PER_MINUTE, 60, { min: 1 }) const tokenBudget = input.operation === 'embedding' && input.inputTokens ? { @@ -77,7 +95,11 @@ export async function waitForProviderAdmission(input: ProviderAdmissionInput): P throw new Error('Embedding request exceeds the configured per-credential token budget') } const requestBurst = Math.min( - input.operation === 'embedding' ? EMBEDDING_REQUEST_BURST : DEFAULT_REQUEST_BURST, + input.operation === 'embedding' + ? EMBEDDING_REQUEST_BURST + : isHostedRerank + ? HOSTED_RERANK_REQUEST_BURST + : DEFAULT_REQUEST_BURST, requestsPerMinute ) const reservations: TokenBucketReservation[] = [] diff --git a/apps/sim/lib/credential-groups/README.md b/apps/sim/lib/credential-groups/README.md index f955a7f7267..3dba7e478ee 100644 --- a/apps/sim/lib/credential-groups/README.md +++ b/apps/sim/lib/credential-groups/README.md @@ -10,7 +10,7 @@ Both pages include **People → Request connections**, with the existing manual An allowed workspace grants every normally authorized manual and deployed workflow access to every active contribution in this pool. There is no per-workflow resource-policy grant and no per-person filtering for workflow execution. Keep ordinary workspace/workflow authorization and deployment authority: an allowlist entry alone cannot authorize running a workflow. Nested workflows use their actual execution workspace. A workspace move, revocation, inactive enrollment, removed provider, disabled group, or unavailable org entitlement blocks subsequent use. -Standalone Chat uses the signed-in person’s own connections. Invited contributors do not need organization membership. Redemption requires a verified matching Sim email; the enrollment is then bound permanently to that user ID. An email change cannot transfer an enrollment. OAuth callbacks require the same verified signed-in user who started authorization. Search requires current organization membership and applies document permissions using the viewer's own verified provider identities; workspace access to the shared credential pool does not grant access to other people's indexed documents. +Standalone Chat uses the signed-in person’s own connections. Invited contributors do not need organization membership. Redemption requires a verified Sim email matching the invitation; the enrollment is then bound permanently to that user ID. An email change cannot transfer an enrollment. People can connect any provider account they can authorize, even when its email differs from their Sim or invitation email. OAuth callbacks require the same verified signed-in user who started authorization. Search requires current organization membership and applies document permissions using the viewer's own verified provider identities; workspace access to the shared credential pool does not grant access to other people's indexed documents. Disconnect revokes the local grant and invalidates pending invitation-based authorization. Administrators can revoke an enrollment; the person cannot restore it themselves. Removing workspace access stops future authorized calls, but cannot recall a provider request already in flight or erase data already returned to a workflow. Full-pool sharing includes public, scheduled, and webhook deployments that otherwise pass workflow authorization. diff --git a/apps/sim/lib/credential-groups/application/authorization.test.ts b/apps/sim/lib/credential-groups/application/authorization.test.ts index 25b2d806c86..8638bed8885 100644 --- a/apps/sim/lib/credential-groups/application/authorization.test.ts +++ b/apps/sim/lib/credential-groups/application/authorization.test.ts @@ -48,6 +48,7 @@ const context = { organizationId: 'org-1', allowPersonalApiKeys: true, credentialId: 'credential-1', + credentialType: 'oauth:gmail' as const, credentialGroupId: 'group-1', credentialGroupEnrollmentId: 'enrollment-1', } @@ -69,7 +70,10 @@ function storedPolicy(workspaceIds: string[] = ['workspace-1']) { id: 'policy-1', organizationId: 'org-1', revision: 1, - document: buildOrganizationAccountAccessPolicy('group-1', workspaceIds), + document: buildOrganizationAccountAccessPolicy( + 'group-1', + workspaceIds.map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ), } } @@ -250,6 +254,23 @@ describe('requireCredentialGroupCredentialAccess', () => { ).rejects.toThrow('Reconnect this account') }) + it.each([executorPrincipal, copilotPrincipal])( + 'rechecks the canonical integration even when the workspace still has other grants', + async (makePrincipal) => { + await expect(requireAccess(makePrincipal())).resolves.toBeUndefined() + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['oauth:google-calendar'] }, + }, + ]), + }) + await expect(requireAccess(makePrincipal())).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.requirePolicy).toHaveBeenCalledTimes(2) + } + ) + it('rechecks the org feature flag before credential use', async () => { mocks.isAvailable.mockResolvedValue(false) await expect(requireAccess(executorPrincipal())).rejects.toMatchObject({ code: 'not_found' }) diff --git a/apps/sim/lib/credential-groups/application/authorization.ts b/apps/sim/lib/credential-groups/application/authorization.ts index 13a51696fd6..2d6b92d3379 100644 --- a/apps/sim/lib/credential-groups/application/authorization.ts +++ b/apps/sim/lib/credential-groups/application/authorization.ts @@ -15,6 +15,10 @@ import { credentialGroupWorkflowAccessPolicyCodec, evaluateCredentialGroupActorCredentialAccess, } from '@/lib/credential-groups/application/workflow-access-policy' +import { + isOrganizationCredentialType, + type OrganizationCredentialType, +} from '@/lib/credential-groups/credential-types' import type { CredentialGroupCredentialListContext, ManagedCredentialGroupBinding, @@ -165,10 +169,19 @@ export async function requireCredentialGroupCredentialAccess( principal: Principal, context: CredentialGroupAuthorizationContext & { credentialId: string + credentialType: OrganizationCredentialType credentialGroupEnrollmentId: string }, resourcePolicy: ResourcePolicyBindingFor<'credential_group'> ): Promise { + if (principal.kind === 'delegated' && principal.serviceId === 'copilot') { + const subject = resolvePrincipalSubject(principal) + if (subject?.kind !== 'sim_user' || !subject.userId) { + throw new OrchestrationError('forbidden', 'Credential Group actor access required') + } + } else { + requireCredentialGroupWorkflowActor(principal) + } /** * A managed OAuth credential is usable only while its credential, enrollment, * option, and group are all live, whoever is using it: an admin disabling the @@ -179,21 +192,23 @@ export async function requireCredentialGroupCredentialAccess( if (binding && !isManagedCredentialGroupBindingLive(binding)) { throw new OrchestrationError('forbidden', 'Credential Group credential access denied') } + if (context.organizationId) { + if (!isOrganizationCredentialType(context.credentialType)) + throw new Error('Organization credential access requires a canonical credential type') + await requireOrganizationAccountsWorkspaceAccess( + { ...context, organizationId: context.organizationId }, + context.credentialType + ) + } if (principal.kind === 'delegated' && principal.serviceId === 'copilot') { return requireCredentialGroupActorCredentialAccess(principal, context, binding, resourcePolicy) } - requireCredentialGroupWorkflowActor(principal) - requireCurrentWorkflow(principal) if (!context.organizationId) { throw new OrchestrationError( 'forbidden', 'Reconnect this account in organization settings and replace the legacy Connected Accounts block' ) } - await requireOrganizationAccountsWorkspaceAccess({ - ...context, - organizationId: context.organizationId, - }) } export const credentialGroupDelegationPolicy = { diff --git a/apps/sim/lib/credential-groups/application/list-credentials.test.ts b/apps/sim/lib/credential-groups/application/list-credentials.test.ts index b940d915701..ae3ed7fcca4 100644 --- a/apps/sim/lib/credential-groups/application/list-credentials.test.ts +++ b/apps/sim/lib/credential-groups/application/list-credentials.test.ts @@ -117,7 +117,10 @@ describe('listCredentialGroupCredentials', () => { beforeEach(() => { vi.clearAllMocks() mocks.requirePolicy.mockResolvedValue({ - document: buildOrganizationAccountAccessPolicy('group-1', ['workspace-1']), + document: buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-1'].map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ), }) mocks.loadGroup.mockResolvedValue(groupContext) mocks.loadWorkspace.mockResolvedValue(workspaceContext) @@ -133,6 +136,7 @@ describe('listCredentialGroupCredentials', () => { { credentialId: 'credential-1', email: 'person@example.com', + accountEmail: 'personal@example.com', displayName: 'person@example.com', providerId: 'google-email', providerSubjectId: 'google-subject-1', @@ -251,6 +255,7 @@ describe('listCredentialGroupCredentials', () => { { credentialId: 'credential-1', email: 'person@example.com', + accountEmail: 'personal@example.com', displayName: 'person@example.com', providerId: 'google-email', providerSubjectId: 'google-subject-1', @@ -263,6 +268,36 @@ describe('listCredentialGroupCredentials', () => { }) }) + it('filters restricted integrations before pagination and rejects explicitly requesting them', async () => { + mocks.loadGroup.mockResolvedValue({ + ...groupContext, + options: [ + ...groupContext.options, + { ...groupContext.options[0], id: 'calendar-option', provider: 'google-calendar' }, + ], + }) + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['oauth:gmail'] }, + }, + ]), + }) + await listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + expect(mocks.listCredentials).toHaveBeenCalledWith( + expect.objectContaining({ credentialGroupOptionIds: ['option-1'], limit: 50 }) + ) + mocks.listCredentials.mockClear() + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, credentialProviderIds: ['google-calendar'] }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + it('filters by canonical providers active in the group', async () => { await listCredentialGroupCredentials.execute({ principal: executorPrincipal(), @@ -274,6 +309,34 @@ describe('listCredentialGroupCredentials', () => { ) }) + it('rechecks a provider grant before the next page can expose account identities', async () => { + mocks.loadGroup.mockResolvedValue({ + ...groupContext, + options: [ + ...groupContext.options, + { ...groupContext.options[0], id: 'calendar-option', provider: 'google-calendar' }, + ], + }) + const query = { ...input, credentialProviderIds: ['google-email'] } + await listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input: query }) + mocks.listCredentials.mockClear() + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['oauth:google-calendar'] }, + }, + ]), + }) + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...query, cursor: 'credential-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + it('normalizes an optional email filter independently of caller identity', async () => { await listCredentialGroupCredentials.execute({ principal: executorPrincipal(), diff --git a/apps/sim/lib/credential-groups/application/list-credentials.ts b/apps/sim/lib/credential-groups/application/list-credentials.ts index a81af811f5c..68fbde5cb1b 100644 --- a/apps/sim/lib/credential-groups/application/list-credentials.ts +++ b/apps/sim/lib/credential-groups/application/list-credentials.ts @@ -10,11 +10,12 @@ import { requireOrganizationAccountsWorkspaceAccess, resolveOrganizationAccountsWorkspaceContext, } from '@/lib/credential-groups/application/organization-workspace-access' +import { organizationAccountPolicyAllowsWorkspace } from '@/lib/credential-groups/application/workspace-access-policy' import { CredentialGroupCredentialCursorNotFoundError, - type CredentialGroupCredentialReference, listCredentialGroupCredentialReferences, MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE, + type OrganizationAccountCredentialReference, } from '@/lib/credential-groups/credentials' import { getCredentialGroupProviderId, @@ -30,7 +31,7 @@ export interface ListCredentialGroupCredentialsInput { } export interface ListCredentialGroupCredentialsResult { - credentials: CredentialGroupCredentialReference[] + credentials: OrganizationAccountCredentialReference[] count: number hasMore: boolean nextCursor: string | null @@ -43,7 +44,7 @@ export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ authorizationOptions: { delegation: credentialGroupDelegationPolicy }, async authorizeResource({ principal, context }) { requireCredentialGroupWorkflowActor(principal) - await requireOrganizationAccountsWorkspaceAccess(context) + context.workspaceAccessPolicy = await requireOrganizationAccountsWorkspaceAccess(context) }, execute: async ({ input, context }): Promise => { if ( @@ -69,14 +70,17 @@ export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ if (credentialProviderIds.some((providerId) => !providerId.trim())) { throw new OrchestrationError('validation', 'Credential provider IDs must not be empty') } - const activeOptions = context.options.filter((option) => option.status === 'active') - const activeProviderIds = new Set( - activeOptions.map((option) => { - if (!isCredentialGroupProvider(option.provider)) { - throw new Error(`Credential Group provider is not registered: ${option.provider}`) - } - return getCredentialGroupProviderId(option.provider) + const policy = context.workspaceAccessPolicy + if (!policy) throw new Error('Credential listing requires workspace policy authorization') + const activeOptions = context.options + .filter((option) => option.status === 'active') + .map((option) => { + if (!isCredentialGroupProvider(option.provider)) + throw new Error(`Unsupported credential provider: ${option.provider}`) + return { ...option, provider: option.provider } }) + const activeProviderIds = new Set( + activeOptions.map((option) => getCredentialGroupProviderId(option.provider)) ) const invalidProviderIds = credentialProviderIds.filter( (providerId) => !activeProviderIds.has(providerId) @@ -88,12 +92,29 @@ export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ ) } + const allowedOptions = activeOptions.filter((option) => + organizationAccountPolicyAllowsWorkspace( + policy, + context.workspaceId, + `oauth:${option.provider}` + ) + ) + const allowedProviders = new Set( + allowedOptions.map((option) => getCredentialGroupProviderId(option.provider)) + ) + if (credentialProviderIds.some((providerId) => !allowedProviders.has(providerId))) { + throw new OrchestrationError( + 'forbidden', + 'This workspace is not allowed to use the requested credential provider' + ) + } + let page try { page = await listCredentialGroupCredentialReferences({ organizationId: context.organizationId, credentialGroupId: context.credentialGroupId, - credentialGroupOptionIds: activeOptions.map((option) => option.id), + credentialGroupOptionIds: allowedOptions.map((option) => option.id), limit: input.limit, cursor: input.cursor, email, diff --git a/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts b/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts index 25487113610..f16108c8d3b 100644 --- a/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts +++ b/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts @@ -97,7 +97,10 @@ describe('listCredentialGroupMcpConnections', () => { beforeEach(() => { vi.clearAllMocks() mocks.requirePolicy.mockResolvedValue({ - document: buildOrganizationAccountAccessPolicy('group-1', ['workspace-1']), + document: buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-1'].map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ), }) mocks.loadGroup.mockResolvedValue(groupContext) mocks.loadWorkspace.mockResolvedValue(workspaceContext) @@ -142,6 +145,29 @@ describe('listCredentialGroupMcpConnections', () => { expect(mocks.listMcpConnections).not.toHaveBeenCalled() }) + it('limits discovery to allowed MCP types and rejects an explicit restricted connector', async () => { + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['mcp:fireflies'] }, + }, + ]), + }) + await listCredentialGroupMcpConnections.execute({ principal: executorPrincipal(), input }) + expect(mocks.listMcpConnections).toHaveBeenCalledWith( + expect.objectContaining({ allowedConnectorIds: ['fireflies'] }) + ) + mocks.listMcpConnections.mockClear() + await expect( + listCredentialGroupMcpConnections.execute({ + principal: executorPrincipal(), + input: { ...input, connectorId: 'granola' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listMcpConnections).not.toHaveBeenCalled() + }) + it('lists bounded MCP connection references after authorization and entitlement checks', async () => { const result = await listCredentialGroupMcpConnections.execute({ principal: executorPrincipal(), @@ -160,6 +186,7 @@ describe('listCredentialGroupMcpConnections', () => { email: 'person@example.com', mcpServerId: 'mcp-server-1', connectorId: undefined, + allowedConnectorIds: ['fireflies', 'granola', 'databricks'], }) expect(result).toEqual({ mcpConnections: [ diff --git a/apps/sim/lib/credential-groups/application/list-mcp-connections.ts b/apps/sim/lib/credential-groups/application/list-mcp-connections.ts index 576b86d27dd..d0baadc58a2 100644 --- a/apps/sim/lib/credential-groups/application/list-mcp-connections.ts +++ b/apps/sim/lib/credential-groups/application/list-mcp-connections.ts @@ -10,7 +10,11 @@ import { requireOrganizationAccountsWorkspaceAccess, resolveOrganizationAccountsWorkspaceContext, } from '@/lib/credential-groups/application/organization-workspace-access' -import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { organizationAccountPolicyAllowsWorkspace } from '@/lib/credential-groups/application/workspace-access-policy' +import { + getManagedMcpConnector, + MANAGED_MCP_CONNECTOR_IDS, +} from '@/lib/credential-groups/managed-mcp-connectors' import { CredentialGroupMcpConnectionCursorNotFoundError, type CredentialGroupMcpConnectionReference, @@ -41,7 +45,7 @@ export const listCredentialGroupMcpConnections = defineAuthorizedWorkspaceUseCas authorizationOptions: { delegation: credentialGroupDelegationPolicy }, async authorizeResource({ principal, context }) { requireCredentialGroupWorkflowActor(principal) - await requireOrganizationAccountsWorkspaceAccess(context) + context.workspaceAccessPolicy = await requireOrganizationAccountsWorkspaceAccess(context) }, execute: async ({ input, context }): Promise => { if ( @@ -68,6 +72,17 @@ export const listCredentialGroupMcpConnections = defineAuthorizedWorkspaceUseCas throw new OrchestrationError('validation', 'MCP server ID must not be empty') } + const policy = context.workspaceAccessPolicy + if (!policy) throw new Error('MCP listing requires workspace policy authorization') + const allowedConnectorIds = MANAGED_MCP_CONNECTOR_IDS.filter((id) => + organizationAccountPolicyAllowsWorkspace(policy, context.workspaceId, `mcp:${id}`) + ) + if (input.connectorId && !allowedConnectorIds.some((id) => id === input.connectorId)) { + throw new OrchestrationError( + 'forbidden', + 'This workspace is not allowed to use the requested MCP provider' + ) + } let page try { page = await listCredentialGroupMcpConnectionReferences({ @@ -78,6 +93,7 @@ export const listCredentialGroupMcpConnections = defineAuthorizedWorkspaceUseCas email, mcpServerId, connectorId: input.connectorId, + allowedConnectorIds, }) } catch (error) { if (error instanceof CredentialGroupMcpConnectionCursorNotFoundError) { diff --git a/apps/sim/lib/credential-groups/application/organization-access.test.ts b/apps/sim/lib/credential-groups/application/organization-access.test.ts index 32c4897be29..020657e082c 100644 --- a/apps/sim/lib/credential-groups/application/organization-access.test.ts +++ b/apps/sim/lib/credential-groups/application/organization-access.test.ts @@ -55,6 +55,7 @@ import { startOrganizationAccountConnection, } from '@/lib/credential-groups/application/organization-accounts' import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' +import { ORGANIZATION_CREDENTIAL_TYPES } from '@/lib/credential-groups/credential-types' import { ResourcePolicyRevisionConflictError } from '@/lib/resource-policies/repository' const principal: SessionPrincipal = { @@ -62,7 +63,16 @@ const principal: SessionPrincipal = { userId: 'admin-user', sessionId: 'session-1', } -const input = { organizationId: 'org-1', revision: 3, workspaceIds: ['workspace-1'] } +const input = { + organizationId: 'org-1', + revision: 3, + grants: [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected' as const, credentialTypes: ['oauth:gmail' as const] }, + }, + ], +} describe('organization workspace sharing administration', () => { beforeEach(() => { @@ -166,7 +176,15 @@ describe('organization workspace sharing administration', () => { queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }]) await expect( updateOrganizationAccountWorkspaceAccess.execute({ principal, input }) - ).resolves.toMatchObject({ revision: 4, workspaceIds: ['workspace-1'] }) + ).resolves.toMatchObject({ + revision: 4, + grants: [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected' as const, credentialTypes: ['oauth:gmail' as const] }, + }, + ], + }) expect(eq).toHaveBeenCalledWith(schemaMock.member.userId, 'admin-user') expect(eq).toHaveBeenCalledWith(schemaMock.member.organizationId, 'org-1') expect(eq).toHaveBeenCalledWith(schemaMock.workspace.organizationId, 'org-1') @@ -175,6 +193,7 @@ describe('organization workspace sharing administration', () => { organizationId: 'org-1', actorUserId: 'admin-user', expectedRevision: 3, + document: buildOrganizationAccountAccessPolicy('group-1', input.grants), }) ) }) @@ -193,21 +212,37 @@ describe('organization workspace sharing administration', () => { await expect( updateOrganizationAccountWorkspaceAccess.execute({ principal, - input: { ...input, workspaceIds: [] }, + input: { ...input, grants: [] }, }) - ).resolves.toMatchObject({ workspaceIds: [] }) + ).resolves.toMatchObject({ grants: [] }) expect(mocks.write).toHaveBeenCalledWith( expect.objectContaining({ document: buildOrganizationAccountAccessPolicy('group-1', []) }) ) }) + it('rejects selected grants exceeding the persisted policy size bound before writing', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + const grants = Array.from({ length: 1000 }, (_, index) => ({ + workspaceId: `workspace-${index}`, + access: { mode: 'selected' as const, credentialTypes: [...ORGANIZATION_CREDENTIAL_TYPES] }, + })) + queueTableRows( + schemaMock.workspace, + grants.map((grant) => ({ id: grant.workspaceId })) + ) + await expect( + updateOrganizationAccountWorkspaceAccess.execute({ principal, input: { ...input, grants } }) + ).rejects.toMatchObject({ code: 'validation', message: expect.stringContaining('too large') }) + expect(mocks.write).not.toHaveBeenCalled() + }) + it('rejects a stale revision rather than overwriting another admin', async () => { queueTableRows(schemaMock.member, [{ role: 'admin' }]) mocks.write.mockRejectedValue(new ResourcePolicyRevisionConflictError()) await expect( updateOrganizationAccountWorkspaceAccess.execute({ principal, - input: { ...input, workspaceIds: [] }, + input: { ...input, grants: [] }, }) ).rejects.toMatchObject({ code: 'conflict' }) }) diff --git a/apps/sim/lib/credential-groups/application/organization-access.ts b/apps/sim/lib/credential-groups/application/organization-access.ts index 7300e2bbfb8..1c73f3a6cd6 100644 --- a/apps/sim/lib/credential-groups/application/organization-access.ts +++ b/apps/sim/lib/credential-groups/application/organization-access.ts @@ -1,4 +1,5 @@ import { db } from '@sim/db' +import { ORGANIZATION_ACCOUNT_POLICY_DOCUMENT_MAX_BYTES } from '@sim/db/credential-group-resource-policies' import { workspace } from '@sim/db/schema' import { and, asc, eq, inArray, isNull } from 'drizzle-orm' import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization' @@ -7,12 +8,16 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineOrganizationAccountsUseCase } from '@/lib/credential-groups/application/organization-accounts' import { buildOrganizationAccountAccessPolicy, - listOrganizationAccountWorkspaceIds, + listOrganizationAccountWorkspaceGrants, organizationAccountAccessPolicyCodec, - organizationAccountWorkspaceIdsSchema, } from '@/lib/credential-groups/application/workspace-access-policy' +import { getOrganizationCredentialTypeCatalog } from '@/lib/credential-groups/credential-types' import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' +import { + type OrganizationAccountWorkspaceGrant, + organizationAccountWorkspaceGrantsSchema, +} from '@/lib/credential-groups/workspace-grants' import { ResourcePolicyRevisionConflictError, requireResourcePolicy, @@ -71,8 +76,9 @@ export const getOrganizationAccountWorkspaceAccess = defineOrganizationAccountsU ) return { revision: policy.revision, - workspaceIds: listOrganizationAccountWorkspaceIds(policy.document), + grants: listOrganizationAccountWorkspaceGrants(policy.document), workspaces, + credentialTypes: getOrganizationCredentialTypeCatalog(), } }, }) @@ -83,14 +89,14 @@ export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccoun input, context, }: { - input: { organizationId: string; revision: number; workspaceIds: string[] } + input: { organizationId: string; revision: number; grants: OrganizationAccountWorkspaceGrant[] } context: OrganizationMembershipContext }) { - const parsed = organizationAccountWorkspaceIdsSchema.safeParse(input.workspaceIds) + const parsed = organizationAccountWorkspaceGrantsSchema.safeParse(input.grants) if (!parsed.success) throw new OrchestrationError( 'validation', - 'Workspace IDs must be unique, valid identifiers within the supported limit' + 'Workspace grants must contain unique workspace IDs and valid integration selections' ) const group = await requireGroup(context.organizationId) if (parsed.data.length) { @@ -100,7 +106,10 @@ export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccoun .where( and( eq(workspace.organizationId, context.organizationId), - inArray(workspace.id, parsed.data), + inArray( + workspace.id, + parsed.data.map((grant) => grant.workspaceId) + ), isNull(workspace.archivedAt) ) ) @@ -110,6 +119,17 @@ export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccoun 'Every allowed workspace must be active and belong to this organization' ) } + const document = buildOrganizationAccountAccessPolicy(group.credentialGroupId, parsed.data) + /** Indented JSON conservatively includes the whitespace PostgreSQL adds to jsonb text. */ + if ( + Buffer.byteLength(JSON.stringify(document, null, 1), 'utf8') > + ORGANIZATION_ACCOUNT_POLICY_DOCUMENT_MAX_BYTES + ) { + throw new OrchestrationError( + 'validation', + 'Workspace access policy is too large. Reduce the number of selected integrations or workspaces.' + ) + } try { const policy = await writeResourcePolicy({ organizationId: context.organizationId, @@ -117,14 +137,14 @@ export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccoun resourceId: group.credentialGroupId, codec: organizationAccountAccessPolicyCodec, expectedRevision: input.revision, - document: buildOrganizationAccountAccessPolicy(group.credentialGroupId, parsed.data), + document, actorUserId: context.userId, }) return { credentialGroupId: group.credentialGroupId, name: group.name, revision: policy.revision, - workspaceIds: listOrganizationAccountWorkspaceIds(policy.document), + grants: listOrganizationAccountWorkspaceGrants(policy.document), } } catch (error) { if (error instanceof ResourcePolicyRevisionConflictError) @@ -138,6 +158,6 @@ export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccoun projectAudit: (result) => ({ resourceId: result.credentialGroupId, resourceName: result.name, - description: `Allowed ${result.workspaceIds.length} workspaces to use organization connected accounts`, + description: `Allowed ${result.grants.length} workspaces to use organization connected accounts`, }), }) diff --git a/apps/sim/lib/credential-groups/application/organization-workspace-access.ts b/apps/sim/lib/credential-groups/application/organization-workspace-access.ts index 76ed97340cf..b6f09a114ac 100644 --- a/apps/sim/lib/credential-groups/application/organization-workspace-access.ts +++ b/apps/sim/lib/credential-groups/application/organization-workspace-access.ts @@ -1,16 +1,19 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import type { CredentialGroupApplicationContext } from '@/lib/credential-groups/application/authorization' import { resolveCredentialGroupWorkspaceContext } from '@/lib/credential-groups/application/context' +import type { OrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import { organizationAccountAccessPolicyCodec, organizationAccountPolicyAllowsWorkspace, } from '@/lib/credential-groups/application/workspace-access-policy' +import type { OrganizationCredentialType } from '@/lib/credential-groups/credential-types' import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { requireResourcePolicy } from '@/lib/resource-policies/repository' export interface OrganizationAccountsWorkspaceContext extends CredentialGroupApplicationContext { organizationId: string + workspaceAccessPolicy?: OrganizationAccountAccessPolicy } /** Resolves the singleton using the executing workspace's current organization. */ @@ -31,12 +34,15 @@ export async function resolveOrganizationAccountsWorkspaceContext( } /** Uses live policy and ownership; cached selections and deployment snapshots never grant access. */ -export async function requireOrganizationAccountsWorkspaceAccess(context: { - workspaceId: string - workspaceOrganizationId: string | null - organizationId: string - credentialGroupId: string -}): Promise { +export async function requireOrganizationAccountsWorkspaceAccess( + context: { + workspaceId: string + workspaceOrganizationId: string | null + organizationId: string + credentialGroupId: string + }, + credentialType?: OrganizationCredentialType +): Promise { if (context.organizationId !== context.workspaceOrganizationId) { throw new OrchestrationError('forbidden', 'Connected accounts belong to another organization') } @@ -54,10 +60,15 @@ export async function requireOrganizationAccountsWorkspaceAccess(context: { resourceId: context.credentialGroupId, codec: organizationAccountAccessPolicyCodec, }) - if (!organizationAccountPolicyAllowsWorkspace(policy.document, context.workspaceId)) { + if ( + !organizationAccountPolicyAllowsWorkspace(policy.document, context.workspaceId, credentialType) + ) { throw new OrchestrationError( 'forbidden', - 'An organization admin must allow this workspace to use connected accounts' + credentialType + ? `This workspace is not allowed to use ${credentialType} credentials` + : 'An organization admin must allow this workspace to use Credential Groups' ) } + return policy.document } diff --git a/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts b/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts index c29a825eab1..5e37acc8c6d 100644 --- a/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts +++ b/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest' import { buildOrganizationAccountAccessPolicy, + listOrganizationAccountWorkspaceGrants, listOrganizationAccountWorkspaceIds, organizationAccountAccessPolicyCodec, organizationAccountPolicyAllowsWorkspace, } from '@/lib/credential-groups/application/workspace-access-policy' +import { organizationAccountWorkspaceGrantsSchema } from '@/lib/credential-groups/workspace-grants' describe('organization account workspace policy', () => { it('denies every workspace by default', () => { @@ -14,7 +16,13 @@ describe('organization account workspace policy', () => { }) it('grants only selected workspaces without a workflow or deployment condition', () => { - const policy = buildOrganizationAccountAccessPolicy('group-1', ['workspace-2', 'workspace-1']) + const policy = buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-2', 'workspace-1'].map((workspaceId) => ({ + workspaceId, + access: { mode: 'all' as const }, + })) + ) expect(listOrganizationAccountWorkspaceIds(policy)).toEqual(['workspace-1', 'workspace-2']) expect(organizationAccountPolicyAllowsWorkspace(policy, 'workspace-1')).toBe(true) expect(organizationAccountPolicyAllowsWorkspace(policy, 'workspace-3')).toBe(false) @@ -35,7 +43,10 @@ describe('organization account workspace policy', () => { { type: 'workflow', workflowId: 'workflow-1' }, { type: 'knowledge_connector', connectorId: 'connector-1' }, ]) { - const policy = buildOrganizationAccountAccessPolicy('group-1', ['workspace-1']) + const policy = buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-1'].map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ) expect(() => organizationAccountAccessPolicyCodec.parse( { ...policy, statements: [{ ...policy.statements[0], principals: [principal] }] }, @@ -47,8 +58,127 @@ describe('organization account workspace policy', () => { it('rejects duplicate selections and malformed IDs', () => { expect(() => - buildOrganizationAccountAccessPolicy('group-1', ['workspace-1', 'workspace-1']) + buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-1', 'workspace-1'].map((workspaceId) => ({ + workspaceId, + access: { mode: 'all' as const }, + })) + ) + ).toThrow() + expect(() => + buildOrganizationAccountAccessPolicy( + 'group-1', + [' workspace-1 '].map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ) ).toThrow() - expect(() => buildOrganizationAccountAccessPolicy('group-1', [' workspace-1 '])).toThrow() + }) +}) + +describe('integration-specific organization grants', () => { + const grants = [ + { + workspaceId: 'mail-workspace', + access: { + mode: 'selected' as const, + credentialTypes: ['oauth:gmail' as const, 'mcp:fireflies' as const], + }, + }, + { + workspaceId: 'calendar-workspace', + access: { + mode: 'selected' as const, + credentialTypes: ['oauth:google-calendar' as const, 'personal_token:gitlab' as const], + }, + }, + { workspaceId: 'all-workspace', access: { mode: 'all' as const } }, + ] + const policy = buildOrganizationAccountAccessPolicy('group-1', grants) + + it('evaluates type and workspace together across OAuth, MCP, and personal tokens', () => { + expect(organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace', 'oauth:gmail')).toBe( + true + ) + expect( + organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace', 'oauth:google-calendar') + ).toBe(false) + expect( + organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace', 'mcp:fireflies') + ).toBe(true) + expect(organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace', 'mcp:granola')).toBe( + false + ) + expect( + organizationAccountPolicyAllowsWorkspace( + policy, + 'calendar-workspace', + 'personal_token:gitlab' + ) + ).toBe(true) + expect( + organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace', 'personal_token:gitlab') + ).toBe(false) + expect( + organizationAccountPolicyAllowsWorkspace(policy, 'unknown-workspace', 'oauth:gmail') + ).toBe(false) + }) + + it('keeps all-integration grants unconditional and round-trips selected grants', () => { + expect(organizationAccountPolicyAllowsWorkspace(policy, 'all-workspace', 'oauth:zoom')).toBe( + true + ) + expect( + policy.statements.find((statement) => statement.sid === 'WorkspaceCredentialAccess') + ).not.toHaveProperty('condition') + const restored = listOrganizationAccountWorkspaceGrants(policy) + for (const grant of grants) { + const match = restored.find((value) => value.workspaceId === grant.workspaceId) + expect(match?.access.mode).toBe(grant.access.mode) + if (grant.access.mode === 'selected' && match?.access.mode === 'selected') { + expect(new Set(match.access.credentialTypes)).toEqual(new Set(grant.access.credentialTypes)) + } + } + expect(organizationAccountPolicyAllowsWorkspace(policy, 'mail-workspace')).toBe(true) + }) + + it('fails closed for unknown types, duplicate types, and empty selections', () => { + for (const types of [[], ['oauth:unknown'], ['oauth:gmail', 'oauth:gmail']]) { + expect( + organizationAccountWorkspaceGrantsSchema.safeParse([ + { workspaceId: 'mail-workspace', access: { mode: 'selected', credentialTypes: types } }, + ]).success + ).toBe(false) + expect(() => + organizationAccountAccessPolicyCodec.parse( + { + ...policy, + statements: [ + { + ...policy.statements.find((statement) => statement.condition), + condition: { StringEquals: { 'credential_group:CredentialType': types } }, + }, + ], + }, + { type: 'credential_group', id: 'group-1' } + ) + ).toThrow() + } + }) + + it('rejects ambiguous overlapping or duplicate statements', () => { + const selected = policy.statements.find((statement) => statement.condition)! + const unrestricted = policy.statements.find((statement) => !statement.condition)! + for (const statements of [ + [selected, selected], + [selected, { ...unrestricted, principals: selected.principals }], + [{ ...selected, sid: 'WorkspaceCredentialAccess:oauth:unknown' }], + ]) { + expect(() => + organizationAccountAccessPolicyCodec.parse( + { ...policy, statements }, + { type: 'credential_group', id: 'group-1' } + ) + ).toThrow() + } }) }) diff --git a/apps/sim/lib/credential-groups/application/workspace-access-policy.ts b/apps/sim/lib/credential-groups/application/workspace-access-policy.ts index dd9af53ba90..bc037f9277c 100644 --- a/apps/sim/lib/credential-groups/application/workspace-access-policy.ts +++ b/apps/sim/lib/credential-groups/application/workspace-access-policy.ts @@ -1,34 +1,59 @@ import { z } from 'zod' +import { + ORGANIZATION_CREDENTIAL_TYPES, + type OrganizationCredentialType, +} from '@/lib/credential-groups/credential-types' import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' +import { + type OrganizationAccountWorkspaceGrant, + organizationAccountWorkspaceGrantsSchema, + organizationCredentialTypeSchema, +} from '@/lib/credential-groups/workspace-grants' +import { CREDENTIAL_TYPE_CONDITION_KEY } from '@/lib/resource-policies/conditions/credential-type' import { evaluateResourcePolicy } from '@/lib/resource-policies/evaluator' import { workspaceResourcePolicyPrincipalSchema } from '@/lib/resource-policies/principals/workspace' import { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION } from '@/lib/resource-policies/registry' import type { ResourcePolicyCodec } from '@/lib/resource-policies/types' -export const organizationAccountWorkspaceIdsSchema = z - .array(workspaceResourcePolicyPrincipalSchema.shape.workspaceId) - .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) - .refine((ids) => new Set(ids).size === ids.length, 'Workspace IDs must be unique') - const workspaceAccessStatementSchema = z .object({ - sid: z.literal('WorkspaceCredentialAccess'), + sid: z.string().min(1).max(256), effect: z.literal('allow'), actions: z.tuple([z.literal(CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION)]), principals: z .array(workspaceResourcePolicyPrincipalSchema) .min(1) .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT), + condition: z + .object({ + StringEquals: z + .object({ [CREDENTIAL_TYPE_CONDITION_KEY]: organizationCredentialTypeSchema }) + .strict(), + }) + .strict() + .optional(), }) .strict() - .refine( - ({ principals }) => - principals.every( + .superRefine((statement, context) => { + const type = statement.condition?.StringEquals[CREDENTIAL_TYPE_CONDITION_KEY] + const expectedSid = type ? `WorkspaceCredentialAccess:${type}` : 'WorkspaceCredentialAccess' + if (statement.sid !== expectedSid) + context.addIssue({ + code: 'custom', + message: 'Workspace statement ID must match its credential type', + }) + if ( + !statement.principals.every( (principal, index) => - index === 0 || principals[index - 1].workspaceId < principal.workspaceId - ), - 'Workspace principals must be sorted and unique' - ) + index === 0 || statement.principals[index - 1].workspaceId < principal.workspaceId + ) + ) { + context.addIssue({ + code: 'custom', + message: 'Workspace principals must be sorted and unique', + }) + } + }) export const organizationAccountAccessPolicySchema = z .object({ @@ -36,9 +61,34 @@ export const organizationAccountAccessPolicySchema = z resource: z .object({ type: z.literal('credential_group'), id: z.string().min(1).max(128) }) .strict(), - statements: z.array(workspaceAccessStatementSchema).max(1), + statements: z + .array(workspaceAccessStatementSchema) + .max(ORGANIZATION_CREDENTIAL_TYPES.length + 1), }) .strict() + .superRefine(({ statements }, context) => { + const statementIds = new Set(statements.map((statement) => statement.sid)) + if (statementIds.size !== statements.length) + context.addIssue({ code: 'custom', message: 'Workspace statements must be unique' }) + const unrestricted = new Set( + statements + .filter((statement) => !statement.condition) + .flatMap((statement) => statement.principals.map((principal) => principal.workspaceId)) + ) + const workspaceIds = new Set() + for (const statement of statements) { + for (const principal of statement.principals) { + workspaceIds.add(principal.workspaceId) + if (statement.condition && unrestricted.has(principal.workspaceId)) + context.addIssue({ + code: 'custom', + message: 'A workspace cannot have both all and selected credential access', + }) + } + } + if (workspaceIds.size > ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) + context.addIssue({ code: 'custom', message: 'Too many workspace grants' }) + }) export type OrganizationAccountAccessPolicy = z.output @@ -49,51 +99,93 @@ export const organizationAccountAccessPolicyCodec: ResourcePolicyCodec< resourceType: 'credential_group', parse(value, expected) { const document = organizationAccountAccessPolicySchema.parse(value) - if (document.resource.type !== expected.type || document.resource.id !== expected.id) { - throw new Error('Connected accounts policy does not match its canonical group') - } + if (document.resource.type !== expected.type || document.resource.id !== expected.id) + throw new Error('Credential Groups policy does not match its canonical group') return document }, } export function buildOrganizationAccountAccessPolicy( credentialGroupId: string, - workspaceIds: string[] + grants: OrganizationAccountWorkspaceGrant[] ): OrganizationAccountAccessPolicy { - const ids = organizationAccountWorkspaceIdsSchema.parse(workspaceIds).sort() + const parsed = organizationAccountWorkspaceGrantsSchema.parse(grants) + const byType = new Map() + for (const { workspaceId, access } of parsed) { + const types = access.mode === 'all' ? ['all' as const] : access.credentialTypes + for (const type of types) { + const workspaces = byType.get(type) ?? [] + workspaces.push(workspaceId) + byType.set(type, workspaces) + } + } return organizationAccountAccessPolicySchema.parse({ version: 2, resource: { type: 'credential_group', id: credentialGroupId }, - statements: ids.length - ? [ - { - sid: 'WorkspaceCredentialAccess', - effect: 'allow', - actions: [CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION], - principals: ids.map((workspaceId) => ({ type: 'workspace', workspaceId })), - }, - ] - : [], + statements: [...byType] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([type, workspaceIds]) => ({ + sid: type === 'all' ? 'WorkspaceCredentialAccess' : `WorkspaceCredentialAccess:${type}`, + effect: 'allow', + actions: [CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION], + principals: workspaceIds.sort().map((workspaceId) => ({ type: 'workspace', workspaceId })), + ...(type === 'all' + ? {} + : { condition: { StringEquals: { [CREDENTIAL_TYPE_CONDITION_KEY]: type } } }), + })), }) } +export function listOrganizationAccountWorkspaceGrants( + document: OrganizationAccountAccessPolicy +): OrganizationAccountWorkspaceGrant[] { + const grants = new Map() + for (const statement of document.statements) { + const type = statement.condition?.StringEquals[CREDENTIAL_TYPE_CONDITION_KEY] + for (const { workspaceId } of statement.principals) { + if (!type) { + grants.set(workspaceId, { workspaceId, access: { mode: 'all' } }) + } else { + const existing = grants.get(workspaceId) + if (existing?.access.mode === 'all') throw new Error('Overlapping workspace grants') + if (existing) existing.access.credentialTypes.push(type) + else + grants.set(workspaceId, { + workspaceId, + access: { mode: 'selected', credentialTypes: [type] }, + }) + } + } + } + return [...grants.values()].sort((left, right) => + left.workspaceId.localeCompare(right.workspaceId) + ) +} + export function listOrganizationAccountWorkspaceIds( document: OrganizationAccountAccessPolicy ): string[] { - return document.statements.flatMap((statement) => - statement.principals.map((principal) => principal.workspaceId) - ) + return [ + ...new Set( + document.statements.flatMap((statement) => + statement.principals.map((principal) => principal.workspaceId) + ) + ), + ].sort() } +/** Tests the resource policy with the canonical integration, or any registered integration for a catalog entry point. */ export function organizationAccountPolicyAllowsWorkspace( document: OrganizationAccountAccessPolicy, - workspaceId: string + workspaceId: string, + credentialType?: OrganizationCredentialType ): boolean { - return ( - evaluateResourcePolicy({ - document, - action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, - facts: { currentWorkspaceId: workspaceId }, - }).decision === 'allow' + return (credentialType ? [credentialType] : ORGANIZATION_CREDENTIAL_TYPES).some( + (type) => + evaluateResourcePolicy({ + document, + action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, + facts: { currentWorkspaceId: workspaceId, credentialType: type }, + }).decision === 'allow' ) } diff --git a/apps/sim/lib/credential-groups/application/workspace-organization-accounts.test.ts b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.test.ts new file mode 100644 index 00000000000..dbb592c9a22 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.test.ts @@ -0,0 +1,87 @@ +/** @vitest-environment node */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ group: vi.fn(), policy: vi.fn() })) +vi.mock('@/lib/credential-groups/application/context', () => ({ + resolveCredentialGroupWorkspaceContext: async () => ({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + }), +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + resolveEffectiveWorkspacePermission: vi.fn().mockResolvedValue('read'), + permissionSatisfies: (permission: string, required: string) => permission === required, +})) +vi.mock('@/lib/credential-groups/credentials', () => ({ + loadScopedAccountsCredentialListContext: mocks.group, +})) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: vi.fn().mockResolvedValue(true), +})) +vi.mock('@/lib/resource-policies/repository', () => ({ requireResourcePolicy: mocks.policy })) + +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' +import { getWorkspaceOrganizationAccounts } from '@/lib/credential-groups/application/workspace-organization-accounts' + +function read() { + return getWorkspaceOrganizationAccounts.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1' }, + }) +} + +describe('workspace organization provider projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(schemaMock.organization, [{ name: 'Organization' }]) + queueTableRows(schemaMock.member, [{ role: 'member' }]) + queueTableRows(schemaMock.mcpServers, [{ connectorId: 'fireflies' }]) + mocks.group.mockResolvedValue({ + credentialGroupId: 'group-1', + status: 'active', + options: [ + { provider: 'gmail', status: 'active' }, + { provider: 'google-calendar', status: 'active' }, + { provider: 'retired-provider', status: 'disabled' }, + ], + }) + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { workspaceId: 'workspace-1', access: { mode: 'all' } }, + ]), + }) + }) + + it('ignores disabled legacy options before validating active providers', async () => { + const result = await read() + expect(result.providers.map(({ id }) => id)).toEqual(['google-email', 'google-calendar']) + expect(result.mcpProviders.map(({ id }) => id)).toEqual(['fireflies']) + }) + + it('projects only credential types allowed for the current workspace', async () => { + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['oauth:gmail'] }, + }, + ]), + }) + const result = await read() + expect(result.allowed).toBe(true) + expect(result.providers.map(({ id }) => id)).toEqual(['google-email']) + expect(result.mcpProviders).toEqual([]) + }) + + it('fails fast for an unregistered active provider', async () => { + mocks.group.mockResolvedValue({ + credentialGroupId: 'group-1', + status: 'active', + options: [{ provider: 'unknown', status: 'active' }], + }) + await expect(read()).rejects.toThrow('Unsupported organization provider: unknown') + }) +}) diff --git a/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts index c33cf518f70..e09baa58387 100644 --- a/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts @@ -77,7 +77,16 @@ export const getWorkspaceOrganizationAccounts = defineAuthorizedWorkspaceUseCase result.allowed = organizationAccountPolicyAllowsWorkspace(policy.document, context.workspaceId) if (!result.allowed) return result result.providers = group.options - .filter((option) => option.status === 'active') + .filter((option) => { + if (option.status !== 'active') return false + if (!isCredentialGroupProvider(option.provider)) + throw new Error(`Unsupported organization provider: ${option.provider}`) + return organizationAccountPolicyAllowsWorkspace( + policy.document, + context.workspaceId, + `oauth:${option.provider}` + ) + }) .map((option) => { if (!isCredentialGroupProvider(option.provider)) throw new Error(`Unsupported organization provider: ${option.provider}`) @@ -95,11 +104,17 @@ export const getWorkspaceOrganizationAccounts = defineAuthorizedWorkspaceUseCase isNull(mcpServers.deletedAt) ) ) - result.mcpProviders = servers.map((server) => { + result.mcpProviders = servers.flatMap((server) => { if (!server.connectorId) throw new Error('Organization MCP provider is missing its connector ID') const connector = getManagedMcpConnector(server.connectorId) - return { id: connector.id, label: connector.name } + return organizationAccountPolicyAllowsWorkspace( + policy.document, + context.workspaceId, + `mcp:${connector.id}` + ) + ? [{ id: connector.id, label: connector.name }] + : [] }) return result }, diff --git a/apps/sim/lib/credential-groups/credential-types.ts b/apps/sim/lib/credential-groups/credential-types.ts new file mode 100644 index 00000000000..2cb90078aed --- /dev/null +++ b/apps/sim/lib/credential-groups/credential-types.ts @@ -0,0 +1,42 @@ +import { + MANAGED_MCP_CONNECTOR_IDS, + MANAGED_MCP_CONNECTORS, +} from '@/lib/credential-groups/managed-mcp-connectors' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + getCredentialGroupProviderFromProviderId, + getCredentialGroupProviderService, +} from '@/lib/credential-groups/providers' + +export type OrganizationCredentialType = + | `oauth:${(typeof CREDENTIAL_GROUP_PROVIDER_IDS)[number]}` + | `mcp:${(typeof MANAGED_MCP_CONNECTOR_IDS)[number]}` + | 'personal_token:gitlab' + +export const ORGANIZATION_CREDENTIAL_TYPES: readonly OrganizationCredentialType[] = [ + ...CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => `oauth:${provider}` as const), + ...MANAGED_MCP_CONNECTOR_IDS.map((provider) => `mcp:${provider}` as const), + 'personal_token:gitlab', +] + +export function isOrganizationCredentialType(value: string): value is OrganizationCredentialType { + return ORGANIZATION_CREDENTIAL_TYPES.some((type) => type === value) +} + +export function organizationOAuthCredentialType(providerId: string): OrganizationCredentialType { + return `oauth:${getCredentialGroupProviderFromProviderId(providerId)}` +} + +export function getOrganizationCredentialTypeCatalog() { + return [ + ...CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => ({ + id: `oauth:${provider}` as const, + label: getCredentialGroupProviderService(provider).name, + })), + ...MANAGED_MCP_CONNECTOR_IDS.map((provider) => ({ + id: `mcp:${provider}` as const, + label: MANAGED_MCP_CONNECTORS[provider].name, + })), + { id: 'personal_token:gitlab' as const, label: 'GitLab' }, + ].sort((left, right) => left.label.localeCompare(right.label)) +} diff --git a/apps/sim/lib/credential-groups/credentials.test.ts b/apps/sim/lib/credential-groups/credentials.test.ts index 98077cea353..279bedd3326 100644 --- a/apps/sim/lib/credential-groups/credentials.test.ts +++ b/apps/sim/lib/credential-groups/credentials.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { credential, credentialGroupEnrollment } from '@sim/db/schema' import { dbChainMockFns, hasMockCondition, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -10,6 +12,7 @@ vi.mock('@/lib/credential-groups/providers', () => ({ })) import { + CredentialGroupCredentialCursorNotFoundError, listCredentialGroupCredentialReferences, loadCredentialGroupEnrollmentAccessForSubject, } from '@/lib/credential-groups/credentials' @@ -20,11 +23,12 @@ describe('listCredentialGroupCredentialReferences', () => { resetDbChainMock() }) - it('returns the invited email associated with each managed credential', async () => { + it('keeps the enrollment email separate from the verified provider account email', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'credential-1', email: 'person@example.com', + accountEmail: 'personal@example.com', displayName: 'Personal Gmail', providerId: 'google-email', providerSubjectId: 'google-subject-1', @@ -45,6 +49,7 @@ describe('listCredentialGroupCredentialReferences', () => { { credentialId: 'credential-1', email: 'person@example.com', + accountEmail: 'personal@example.com', displayName: 'Personal Gmail', providerId: 'google-email', providerSubjectId: 'google-subject-1', @@ -55,6 +60,119 @@ describe('listCredentialGroupCredentialReferences', () => { }) }) + it('lists every provider account across pages without an email filter or secret projection', async () => { + const row = (id: string, accountEmail: string) => ({ + id, + email: 'person@example.com', + accountEmail, + displayName: accountEmail, + providerId: 'google-email', + providerSubjectId: `subject-${id}`, + providerTenantId: null, + createdAt: new Date('2026-08-12T12:00:00.000Z'), + }) + const rows = [ + row('first', 'one@example.com'), + row('second', 'two@example.com'), + row('third', 'three@example.com'), + ] + dbChainMockFns.limit.mockResolvedValueOnce(rows) + const input = { + organizationId: 'organization-1', + credentialGroupId: 'group-1', + credentialGroupOptionIds: ['gmail-option'], + credentialProviderIds: ['google-email'], + limit: 2, + } + const first = await listCredentialGroupCredentialReferences(input) + expect(first.nextCursor).toBe('second') + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'second' }]).mockResolvedValueOnce([rows[2]]) + const second = await listCredentialGroupCredentialReferences({ + ...input, + cursor: first.nextCursor!, + }) + expect(second.nextCursor).toBeNull() + expect( + [...first.credentials, ...second.credentials].map((account) => account.accountEmail) + ).toEqual(['one@example.com', 'two@example.com', 'three@example.com']) + expect(dbChainMockFns.limit.mock.calls.map(([limit]) => limit)).toEqual([3, 1, 3]) + + for (const [where] of dbChainMockFns.where.mock.calls) { + for (const [column, value] of [ + [credential.organizationId, 'organization-1'], + [credentialGroupEnrollment.credentialGroupId, 'group-1'], + [credential.managedOauthStatus, 'active'], + [credential.createdBy, credentialGroupEnrollment.userId], + ]) { + expect( + hasMockCondition( + where, + (condition) => + condition.type === 'eq' && condition.left === column && condition.right === value + ) + ).toBe(true) + } + expect( + hasMockCondition( + where, + (condition) => + condition.type === 'inArray' && + condition.column === credential.credentialGroupOptionId && + JSON.stringify(condition.values) === '["gmail-option"]' + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (condition) => + condition.type === 'eq' && condition.left === credentialGroupEnrollment.email + ) + ).toBe(false) + } + expect(Object.keys(dbChainMockFns.select.mock.calls[0]![0])).toEqual([ + 'id', + 'email', + 'accountEmail', + 'displayName', + 'providerId', + 'providerSubjectId', + 'providerTenantId', + 'managedOauthStatus', + 'enrollmentStatus', + 'createdAt', + ]) + }) + + it.each([null, '', 'not-an-email'])( + 'fails fast when the provider account email is invalid: %s', + async (accountEmail) => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'credential-1', accountEmail }]) + await expect( + listCredentialGroupCredentialReferences({ + organizationId: 'organization-1', + credentialGroupId: 'group-1', + credentialGroupOptionIds: ['gmail-option'], + limit: 50, + }) + ).rejects.toThrow('no valid provider account email') + } + ) + + it('rejects a cursor outside the current provider and organization scope before reading a page', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + await expect( + listCredentialGroupCredentialReferences({ + organizationId: 'organization-1', + credentialGroupId: 'group-1', + credentialGroupOptionIds: ['gmail-option'], + credentialProviderIds: ['google-email'], + cursor: 'foreign-credential', + limit: 2, + }) + ).rejects.toBeInstanceOf(CredentialGroupCredentialCursorNotFoundError) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + it('filters credential references by normalized enrollment email', async () => { dbChainMockFns.limit.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/credential-groups/credentials.ts b/apps/sim/lib/credential-groups/credentials.ts index ec0fe7889af..fb7d0d4af20 100644 --- a/apps/sim/lib/credential-groups/credentials.ts +++ b/apps/sim/lib/credential-groups/credentials.ts @@ -7,6 +7,7 @@ import { credentialGroupEnrollment, user, } from '@sim/db/schema' +import { isValidEmailSyntax } from '@sim/utils/string' import { and, asc, eq, gt, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' @@ -37,6 +38,10 @@ export interface CredentialGroupCredentialReference { providerTenantId: string | null } +export interface OrganizationAccountCredentialReference extends CredentialGroupCredentialReference { + accountEmail: string +} + /** * A credential collected under one option, in any state. Carries both statuses * so a caller reconciling membership can tell a live credential from one that @@ -285,6 +290,7 @@ export async function loadManagedCredentialGroupBinding( interface CredentialReferencePageRow { id: string email: string + accountEmail: string | null displayName: string providerId: string | null providerSubjectId: string | null @@ -325,6 +331,7 @@ async function pageCredentialReferences( .select({ id: credential.id, email: credentialGroupEnrollment.email, + accountEmail: sql`${credential.providerMetadata}->>'email'`, displayName: credential.displayName, providerId: credential.providerId, providerSubjectId: credential.providerSubjectId, @@ -396,7 +403,7 @@ export async function listCredentialGroupCredentialReferences({ credentialProviderIds, credentialGroupOptionIds, }: ListCredentialGroupCredentialReferencesInput): Promise<{ - credentials: CredentialGroupCredentialReference[] + credentials: OrganizationAccountCredentialReference[] nextCursor: string | null }> { if (credentialGroupOptionIds.length === 0) { @@ -426,7 +433,15 @@ export async function listCredentialGroupCredentialReferences({ limit, cursor ) - return { credentials: page.rows.map(toCredentialReference), nextCursor: page.nextCursor } + return { + credentials: page.rows.map((row) => { + if (!row.accountEmail || !isValidEmailSyntax(row.accountEmail)) { + throw new Error(`Managed credential ${row.id} has no valid provider account email`) + } + return { ...toCredentialReference(row), accountEmail: row.accountEmail } + }), + nextCursor: page.nextCursor, + } } /** diff --git a/apps/sim/lib/credential-groups/mcp-connections.ts b/apps/sim/lib/credential-groups/mcp-connections.ts index 12a891a9427..7e65efa40bc 100644 --- a/apps/sim/lib/credential-groups/mcp-connections.ts +++ b/apps/sim/lib/credential-groups/mcp-connections.ts @@ -32,6 +32,7 @@ interface ListCredentialGroupMcpConnectionReferencesInput { email?: string mcpServerId?: string connectorId?: string + allowedConnectorIds?: readonly string[] } function decodeToolNames(value: unknown): string[] { @@ -52,15 +53,23 @@ export async function listCredentialGroupMcpConnectionReferences({ email, mcpServerId, connectorId, + allowedConnectorIds, }: ListCredentialGroupMcpConnectionReferencesInput): Promise<{ mcpConnections: CredentialGroupMcpConnectionReference[] nextCursor: string | null }> { + if (allowedConnectorIds?.length === 0) { + if (cursor) throw new CredentialGroupMcpConnectionCursorNotFoundError() + return { mcpConnections: [], nextCursor: null } + } const ownerScope = resourceScopeFromOwner({ workspaceId, organizationId }) const scope = () => and( resourceScopeCondition(credential, ownerScope), eq(credential.type, 'managed_mcp'), + allowedConnectorIds + ? inArray(mcpServers.managedConnectorId, [...allowedConnectorIds]) + : undefined, eq(credential.managedOauthStatus, 'active'), eq(credential.mcpOauthConfigVersion, mcpServers.oauthConfigVersion), eq(credentialGroup.id, credentialGroupId), diff --git a/apps/sim/lib/credential-groups/oauth-completion.ts b/apps/sim/lib/credential-groups/oauth-completion.ts index 1c732bdfc8c..25785030fde 100644 --- a/apps/sim/lib/credential-groups/oauth-completion.ts +++ b/apps/sim/lib/credential-groups/oauth-completion.ts @@ -3,9 +3,8 @@ import { isValidUuid } from '@sim/utils/id' export const CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES = { expired: 'This connection attempt expired. Try connecting your account again.', denied: 'Authorization was canceled. Try connecting your account again.', - account_mismatch: 'Choose the account matching your Sim email address.', - github_email_mismatch: - 'In GitHub Settings → Emails, add and verify the email address used for this Sim connection, then try again. A verified secondary email is supported.', + github_email_unverified: + 'In GitHub Settings → Emails, verify your primary email address, then try again.', github_email_access_denied: 'GitHub did not allow access to your email addresses. Ask an admin to check that the GitHub App has Email addresses: Read-only permission, then authorize the app again.', permissions_required: 'All requested permissions are required to connect this account.', diff --git a/apps/sim/lib/credential-groups/oauth.test.ts b/apps/sim/lib/credential-groups/oauth.test.ts index 0fae1303e67..9837a8eac51 100644 --- a/apps/sim/lib/credential-groups/oauth.test.ts +++ b/apps/sim/lib/credential-groups/oauth.test.ts @@ -98,8 +98,8 @@ describe('credential group OAuth persistence', () => { providerId: POLICY.providerId, providerSubjectId: 'google-subject-1', providerTenantId: null, - displayName: 'person@example.com', - metadata: { email: 'person@example.com' }, + displayName: 'provider@example.com', + metadata: { email: 'provider@example.com' }, accessToken: 'access-token', refreshToken: 'refresh-token', grantedScopes: POLICY.requiredScopes, @@ -177,7 +177,7 @@ describe('credential group OAuth persistence', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) - it('returns a created event result after inserting a first credential', async () => { + it('persists a different-email provider account under the enrolled Sim user', async () => { dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'invited' }]) queueTableRows(schemaMock.credentialGroup, [GROUP]) queueTableRows(schemaMock.credential, []) @@ -214,10 +214,19 @@ describe('credential group OAuth persistence', () => { credentialGroupOptionId: 'option-1', provider: 'gmail', providerId: 'google-email', - displayName: 'person@example.com', + displayName: 'provider@example.com', enrollmentStatus: 'in_progress', }) expect(dbChainMockFns.insert).toHaveBeenCalledWith(schemaMock.credential) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + createdBy: CONTEXT.credentialOwnerId, + credentialGroupEnrollmentId: CONTEXT.enrollmentId, + providerSubjectId: 'google-subject-1', + displayName: 'provider@example.com', + providerMetadata: { email: 'provider@example.com' }, + }) + ) }) it.each([true, false])( @@ -362,7 +371,7 @@ describe('credential group OAuth persistence', () => { credentialGroupOptionId: 'option-1', provider: 'gmail', providerId: 'google-email', - displayName: 'person@example.com', + displayName: 'provider@example.com', enrollmentStatus: 'completed', }) }) @@ -417,7 +426,7 @@ describe('credential group OAuth persistence', () => { credentialGroupOptionId: 'option-1', provider: 'gmail', providerId: 'google-email', - displayName: 'person@example.com', + displayName: 'provider@example.com', enrollmentStatus: 'completed', }) }) @@ -562,7 +571,7 @@ describe('credential group OAuth persistence', () => { credentialGroupOptionId: 'option-1', provider: 'gmail', providerId: 'google-email', - displayName: 'person@example.com', + displayName: 'provider@example.com', enrollmentStatus: 'completed', }) }) diff --git a/apps/sim/lib/credential-groups/slack-provider.test.ts b/apps/sim/lib/credential-groups/slack-provider.test.ts index 2ec9c3b22d0..88ca1ca474f 100644 --- a/apps/sim/lib/credential-groups/slack-provider.test.ts +++ b/apps/sim/lib/credential-groups/slack-provider.test.ts @@ -66,7 +66,7 @@ describe('Slack member scope policy', () => { workspaceId: 'workspace-1', workspaceName: 'Fixture', workspaceOwnerId: 'owner', - email: 'member@fixture.test', + email: 'sim-member@fixture.test', enrollmentStatus: 'in_progress', option, options: [option], @@ -90,22 +90,83 @@ describe('Slack member scope policy', () => { expect(url.searchParams.get('user_scope')?.split(',')).toEqual([...scopes]) }) - it('accepts a minimal search grant and rejects the same grant for a workflow option', async () => { - for (const scopes of [SLACK_SEARCH_USER_SCOPES, SLACK_MANAGED_USER_SCOPES]) { - const current = context(scopes) - const policy = await adapter.getPolicy(current.option, { - workspaceId: current.workspaceId, + it.each([ + { name: 'search', scopes: SLACK_SEARCH_USER_SCOPES }, + { name: 'workflow', scopes: SLACK_MANAGED_USER_SCOPES }, + ])('accepts a different provider email for a $name option', async ({ scopes }) => { + const current = context(scopes) + mocks.exchange.mockResolvedValueOnce({ + appId: 'A1', + teamId: 'T1', + userId: 'U1', + accessToken: 'fixture-token', + tokenType: 'user', + scopes: [...scopes], + }) + const policy = await adapter.getPolicy(current.option, { + workspaceId: current.workspaceId, + credentialGroupId: current.credentialGroupId, + }) + const result = adapter.exchangeAndVerify({ + context: current, + policy, + code: 'code', + attempt: { + state: 'state', + provider: 'slack', + workspaceId: 'workspace-1', + email: current.email, + nonceHash: 'nonce-hash', + enrollmentId: current.enrollmentId, credentialGroupId: current.credentialGroupId, - }) - const result = adapter.exchangeAndVerify({ + optionId: current.option.id, + authorizationAppId: policy.authorizationAppId, + scopeVersion: policy.scopeVersion, + requiredScopes: policy.requiredScopes, + redirectUri: 'https://sim.fixture.test/api/credential-groups/oauth/slack/callback', + invitationToken: 'invitation', + createdAt: Date.now(), + }, + }) + await expect(result).resolves.toMatchObject({ + providerSubjectId: 'U1', + providerTenantId: 'T1', + displayName: 'member@fixture.test', + metadata: { email: 'member@fixture.test' }, + grantedScopes: [...scopes], + }) + expect(mocks.revoke).not.toHaveBeenCalled() + }) + + it.each([ + { name: 'missing permissions', grant: { scopes: [...SLACK_SEARCH_USER_SCOPES] } }, + { name: 'a different team', grant: { teamId: 'T2' } }, + { name: 'a different app', grant: { appId: 'A2' } }, + ])('still rejects $name and revokes the grant', async ({ grant }) => { + const current = context(SLACK_MANAGED_USER_SCOPES) + const policy = await adapter.getPolicy(current.option, { + workspaceId: current.workspaceId, + credentialGroupId: current.credentialGroupId, + }) + mocks.exchange.mockResolvedValueOnce({ + appId: 'A1', + teamId: 'T1', + userId: 'U1', + accessToken: 'fixture-token', + tokenType: 'user', + scopes: [...SLACK_MANAGED_USER_SCOPES], + ...grant, + }) + await expect( + adapter.exchangeAndVerify({ context: current, policy, code: 'code', attempt: { state: 'state', provider: 'slack', - workspaceId: 'workspace-1', - email: 'person@example.com', + workspaceId: current.workspaceId, + email: current.email, nonceHash: 'nonce-hash', enrollmentId: current.enrollmentId, credentialGroupId: current.credentialGroupId, @@ -118,12 +179,7 @@ describe('Slack member scope policy', () => { createdAt: Date.now(), }, }) - if (scopes === SLACK_SEARCH_USER_SCOPES) - await expect(result).resolves.toMatchObject({ - grantedScopes: [...SLACK_SEARCH_USER_SCOPES], - }) - else await expect(result).rejects.toThrow('All requested Slack permissions') - } + ).rejects.toMatchObject({ statusCode: 403 }) expect(mocks.revoke).toHaveBeenCalledExactlyOnceWith('fixture-token') }) diff --git a/apps/sim/lib/credential-groups/slack-provider.ts b/apps/sim/lib/credential-groups/slack-provider.ts index 0860ff43313..f94d443e428 100644 --- a/apps/sim/lib/credential-groups/slack-provider.ts +++ b/apps/sim/lib/credential-groups/slack-provider.ts @@ -246,12 +246,6 @@ export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter expectedUserId: grant.userId, }) const email = normalizeEmail(identity.email) - if (email !== context.email) { - throw new CredentialGroupOAuthError( - `Sign in with ${context.email} to complete this invitation.`, - 403 - ) - } return { providerId: policy.providerId, diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts index ac06e6f834b..f0151f739d3 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -34,7 +34,6 @@ vi.mock('@/lib/auth/connectors/managed-oauth', () => ({ requiresRefreshToken: true, pkce: true, nonceVerification: 'id_token', - includeLoginHint: true, prompt: 'consent select_account', authorizationUrlParams: { include_granted_scopes: 'false' }, getAuthorizationAppId: (clientId: string) => `google:${clientId}`, @@ -64,7 +63,6 @@ vi.mock('@/lib/auth/connectors/managed-oauth', () => ({ requiresRefreshToken: true, pkce: false, nonceVerification: 'state_only', - includeLoginHint: false, prompt: 'consent', authorizationUrlParams: { audience: 'api.atlassian.com' }, getAuthorizationAppId: (clientId: string) => `jira:${clientId}`, @@ -174,7 +172,8 @@ describe('standard OAuth Credential Group provider', () => { expect(authorizationUrl.searchParams.get('client_id')).toBe('client-1') expect(authorizationUrl.searchParams.get('state')).toBe('state-1') expect(authorizationUrl.searchParams.get('nonce')).toBe('nonce-1') - expect(authorizationUrl.searchParams.get('login_hint')).toBe('person@example.com') + expect(authorizationUrl.searchParams.has('login_hint')).toBe(false) + expect(authorizationUrl.searchParams.get('prompt')).toBe('consent select_account') expect(authorizationUrl.searchParams.get('include_granted_scopes')).toBe('false') expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256') }) @@ -213,7 +212,52 @@ describe('standard OAuth Credential Group provider', () => { }) }) - it('rejects a different invited email', async () => { + it.each(['workspace', 'organization'] as const)( + 'accepts a different provider email for a %s credential group', + async (scope) => { + mockVerifyIdentity.mockResolvedValueOnce({ + providerSubjectId: 'google-sub-2', + providerTenantId: null, + email: ' Other@Example.com ', + emailVerified: true, + nonce: 'nonce-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + }) + const context = buildContext() + if (scope === 'organization') { + context.workspaceId = undefined + context.organizationId = 'org-1' + } + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + + await expect( + adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + ).resolves.toMatchObject({ + providerSubjectId: 'google-sub-2', + displayName: 'other@example.com', + metadata: { email: 'other@example.com' }, + }) + expect(mockVerifyIdentity).toHaveBeenCalledExactlyOnceWith({ + tokens: expect.objectContaining({ accessToken: 'access-1' }), + clientId: 'client-1', + }) + } + ) + + it.each([ + { name: 'an unverified email', identity: { emailVerified: false }, statusCode: 502 }, + { name: 'a mismatched nonce', identity: { nonce: 'wrong-nonce' }, statusCode: 502 }, + { name: 'a missing nonce', identity: { nonce: undefined }, statusCode: 502 }, + { name: 'missing permissions', identity: { grantedScopes: ['openid'] }, statusCode: 403 }, + ])('still rejects $name when connecting a different email', async ({ identity, statusCode }) => { mockVerifyIdentity.mockResolvedValueOnce({ providerSubjectId: 'google-sub-2', providerTenantId: null, @@ -221,13 +265,13 @@ describe('standard OAuth Credential Group provider', () => { emailVerified: true, nonce: 'nonce-1', grantedScopes: ['calendar.read', 'profile', 'openid'], + ...identity, }) const context = buildContext() const policy = await adapter.getPolicy(context.option, { workspaceId: context.workspaceId, credentialGroupId: context.credentialGroupId, }) - await expect( adapter.exchangeAndVerify({ context, @@ -235,11 +279,11 @@ describe('standard OAuth Credential Group provider', () => { code: 'code-1', policy, }) - ).rejects.toMatchObject({ statusCode: 403 }) + ).rejects.toMatchObject({ statusCode }) }) it.each([ - new OAuthIdentityVerificationError('email_mismatch', 'emails'), + new OAuthIdentityVerificationError('email_unverified', 'emails'), new OAuthIdentityVerificationError('email_access_denied', 'emails', 403), new OAuthIdentityVerificationError('provider_unavailable', 'profile', 503), ])('preserves safe identity diagnostics through managed authorization: %s', async (failure) => { diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts index 4492ad3812d..5c4c343277d 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts @@ -248,7 +248,7 @@ export function createStandardOAuthCredentialGroupProviderAdapter( async getPolicy() { return getCurrentProvider(provider).policy }, - async prepareAuthorization(context, policy) { + async prepareAuthorization(_context, policy) { const current = getCurrentProvider(provider) assertCurrentPolicy(policy, current.policy) const managed = current.connector.managedOAuth @@ -278,7 +278,6 @@ export function createStandardOAuthCredentialGroupProviderAdapter( accessType: current.connector.accessType, responseType: current.connector.responseType, responseMode: current.connector.responseMode, - loginHint: managed.includeLoginHint ? context.email : undefined, additionalParams: { ...staticParams( current.connector.authorizationUrlParams, @@ -292,7 +291,7 @@ export function createStandardOAuthCredentialGroupProviderAdapter( }, } }, - async exchangeAndVerify({ context, attempt, code, policy }) { + async exchangeAndVerify({ attempt, code, policy }) { const current = getCurrentProvider(provider) assertCurrentPolicy(policy, current.policy) const redirectUri = getRedirectUri(provider, current) @@ -331,7 +330,6 @@ export function createStandardOAuthCredentialGroupProviderAdapter( identity = await managed.verifyIdentity({ tokens, clientId: current.connector.clientId, - expectedEmail: context.email, }) } catch (error) { throw new CredentialGroupOAuthError( @@ -350,12 +348,6 @@ export function createStandardOAuthCredentialGroupProviderAdapter( ) } const email = normalizeEmail(identity.email) - if (email !== context.email) { - throw new CredentialGroupOAuthError( - `Sign in with ${context.email} to complete this invitation.`, - 403 - ) - } if (!managed.hasRequiredScopes(identity.grantedScopes, policy.requiredScopes)) { throw new CredentialGroupOAuthError( `All requested ${service.name} permissions are required to connect this account.`, diff --git a/apps/sim/lib/credential-groups/trigger.test.ts b/apps/sim/lib/credential-groups/trigger.test.ts index 1f8d1ac446d..62c6cf2a663 100644 --- a/apps/sim/lib/credential-groups/trigger.test.ts +++ b/apps/sim/lib/credential-groups/trigger.test.ts @@ -73,7 +73,13 @@ describe('Credential Group trigger delivery', () => { beforeEach(() => { vi.clearAllMocks() mocks.requirePolicy.mockResolvedValue({ - document: buildOrganizationAccountAccessPolicy('group-1', ['workspace-1', 'workspace-2']), + document: buildOrganizationAccountAccessPolicy( + 'group-1', + ['workspace-1', 'workspace-2'].map((workspaceId) => ({ + workspaceId, + access: { mode: 'all' as const }, + })) + ), }) mocks.resolveWorkspace.mockImplementation(async (workspaceId: string) => ({ workspaceId, @@ -108,6 +114,29 @@ describe('Credential Group trigger delivery', () => { ) }) + it('discovers subscribers only for the event integration and rechecks that type before delivery', async () => { + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', [ + { + workspaceId: 'workspace-1', + access: { mode: 'selected', credentialTypes: ['oauth:gmail'] }, + }, + { + workspaceId: 'workspace-2', + access: { mode: 'selected', credentialTypes: ['oauth:google-calendar'] }, + }, + ]), + }) + mocks.fetchSubscriptions.mockResolvedValue([subscription({ workflowId: 'allowed' })]) + await fireCredentialGroupTrigger(EVENT) + expect(mocks.fetchSubscriptions).toHaveBeenCalledWith('org-1', ['workspace-1']) + expect(mocks.requireAccess).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-1' }), + 'oauth:gmail' + ) + expect(mocks.processEvent).toHaveBeenCalledOnce() + }) + it('does not scan subscriptions when no workspace has access', async () => { mocks.requirePolicy.mockResolvedValue({ document: buildOrganizationAccountAccessPolicy('group-1', []), diff --git a/apps/sim/lib/credential-groups/trigger.ts b/apps/sim/lib/credential-groups/trigger.ts index c9fa7e964a6..19d5f896312 100644 --- a/apps/sim/lib/credential-groups/trigger.ts +++ b/apps/sim/lib/credential-groups/trigger.ts @@ -8,8 +8,14 @@ import { import { listOrganizationAccountWorkspaceIds, organizationAccountAccessPolicyCodec, + organizationAccountPolicyAllowsWorkspace, } from '@/lib/credential-groups/application/workspace-access-policy' +import { + type OrganizationCredentialType, + organizationOAuthCredentialType, +} from '@/lib/credential-groups/credential-types' import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { CREDENTIAL_GROUP_EVENT_TRIGGER_ID, @@ -121,7 +127,15 @@ export async function fireCredentialGroupTrigger( resourceId: event.credentialGroupId, codec: organizationAccountAccessPolicyCodec, }) - const allowedWorkspaceIds = listOrganizationAccountWorkspaceIds(policy.document) + const credentialType: OrganizationCredentialType | undefined = + event.event === 'form_submitted' + ? undefined + : event.credential.mcpServerId + ? `mcp:${getManagedMcpConnector(event.credential.provider).id}` + : organizationOAuthCredentialType(event.credential.providerId) + const allowedWorkspaceIds = listOrganizationAccountWorkspaceIds(policy.document).filter((id) => + organizationAccountPolicyAllowsWorkspace(policy.document, id, credentialType) + ) if (allowedWorkspaceIds.length === 0) return const subscriptions = await fetchCredentialGroupTriggerSubscriptions( event.organizationId, @@ -141,7 +155,7 @@ export async function fireCredentialGroupTrigger( const context = await resolveOrganizationAccountsWorkspaceContext(workflow.workspaceId) if (context.credentialGroupId !== event.credentialGroupId || context.status !== 'active') continue - await requireOrganizationAccountsWorkspaceAccess(context) + await requireOrganizationAccountsWorkspaceAccess(context, credentialType) } catch (error) { /** Revocations and workspace moves remove subscribers between discovery and delivery. */ if ( diff --git a/apps/sim/lib/credential-groups/workspace-grants.ts b/apps/sim/lib/credential-groups/workspace-grants.ts new file mode 100644 index 00000000000..b982dd53a3e --- /dev/null +++ b/apps/sim/lib/credential-groups/workspace-grants.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import { ORGANIZATION_CREDENTIAL_TYPES } from '@/lib/credential-groups/credential-types' +import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' +import { workspaceResourcePolicyPrincipalSchema } from '@/lib/resource-policies/principals/workspace' + +export const organizationCredentialTypeSchema = z.enum(ORGANIZATION_CREDENTIAL_TYPES, { + error: 'Unknown credential type', +}) + +export const organizationAccountWorkspaceGrantSchema = z + .object({ + workspaceId: workspaceResourcePolicyPrincipalSchema.shape.workspaceId, + access: z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('all') }).strict(), + z + .object({ + mode: z.literal('selected'), + credentialTypes: z + .array(organizationCredentialTypeSchema) + .min(1, 'Select at least one credential type') + .max(ORGANIZATION_CREDENTIAL_TYPES.length) + .refine( + (types) => new Set(types).size === types.length, + 'Credential types must be unique' + ), + }) + .strict(), + ]), + }) + .strict() + +export const organizationAccountWorkspaceGrantsSchema = z + .array(organizationAccountWorkspaceGrantSchema) + .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) + .refine( + (grants) => new Set(grants.map((grant) => grant.workspaceId)).size === grants.length, + 'Workspace grants must be unique' + ) + +export type OrganizationAccountWorkspaceGrant = z.output< + typeof organizationAccountWorkspaceGrantSchema +> diff --git a/apps/sim/lib/credentials/__integration__/organization-personal-tokens.integration.ts b/apps/sim/lib/credentials/__integration__/organization-personal-tokens.integration.ts index 9a86b0f3415..fb1f2ffdd7c 100644 --- a/apps/sim/lib/credentials/__integration__/organization-personal-tokens.integration.ts +++ b/apps/sim/lib/credentials/__integration__/organization-personal-tokens.integration.ts @@ -1,13 +1,11 @@ /** Real storage, encryption, migration, and authorization; no external GitLab calls. */ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import { credential, credentialGroup, credentialGroupEnrollment, member, organization, - organizationColumns, permissions, resourcePolicy, user, @@ -97,7 +95,7 @@ describe('organization personal tokens', () => { updatedAt: now, })) ) - await db.insert(withInsertColumns(organization, organizationColumns)).values( + await db.insert(organization).values( [ids.org, ids.foreignOrg].map((id) => ({ id, name: 'Token fixture organization', diff --git a/apps/sim/lib/credentials/application/personal-connection.test.ts b/apps/sim/lib/credentials/application/personal-connection.test.ts index c1b3190ffb6..c2544fc67c5 100644 --- a/apps/sim/lib/credentials/application/personal-connection.test.ts +++ b/apps/sim/lib/credentials/application/personal-connection.test.ts @@ -82,7 +82,10 @@ describe('personal connection launch', () => { mocks.organizationMembership.mockResolvedValue({ userId: 'viewer', role: 'member' }) mocks.available.mockResolvedValue(true) mocks.policy.mockResolvedValue({ - document: buildOrganizationAccountAccessPolicy('canonical-group', ['workspace']), + document: buildOrganizationAccountAccessPolicy( + 'canonical-group', + ['workspace'].map((workspaceId) => ({ workspaceId, access: { mode: 'all' as const } })) + ), }) mocks.catalog.mockResolvedValue([ { diff --git a/apps/sim/lib/credentials/application/personal-credentials.test.ts b/apps/sim/lib/credentials/application/personal-credentials.test.ts index 295f4e04fc6..ccad2c1d180 100644 --- a/apps/sim/lib/credentials/application/personal-credentials.test.ts +++ b/apps/sim/lib/credentials/application/personal-credentials.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -70,6 +71,7 @@ function delegatedPrincipal(overrides: Partial = {}): Delega describe('personal credential application access', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') mocks.listPersonal.mockResolvedValue([personalCredential]) @@ -114,6 +116,16 @@ describe('personal credential application access', () => { instanceUrl: 'https://gitlab.example.com', } mocks.listTokens.mockResolvedValue([token]) + queueTableRows(schemaMock.credential, [ + { + ...token, + organizationId: null, + workspaceId: 'workspace-1', + groupId: 'legacy-group', + groupOrganizationId: null, + groupWorkspaceId: 'workspace-1', + }, + ]) const result = await listPersonalCredentials.execute({ principal, input: { workspaceId: 'workspace-1' }, @@ -136,6 +148,16 @@ describe('personal credential application access', () => { it('authorizes a managed account returned by the same personal policy', async () => { const managed = { ...personalCredential, providerId: 'slack', type: 'managed_oauth' as const } mocks.listPersonal.mockResolvedValue([managed]) + queueTableRows(schemaMock.credential, [ + { + ...managed, + organizationId: null, + workspaceId: 'workspace-1', + groupId: 'legacy-group', + groupOrganizationId: null, + groupWorkspaceId: 'workspace-1', + }, + ]) const result = await authorizePersonalCredential.execute({ principal, diff --git a/apps/sim/lib/credentials/application/personal-credentials.ts b/apps/sim/lib/credentials/application/personal-credentials.ts index 725ee40fc98..9271ff4c449 100644 --- a/apps/sim/lib/credentials/application/personal-credentials.ts +++ b/apps/sim/lib/credentials/application/personal-credentials.ts @@ -3,6 +3,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' import { credentialOperations } from '@/lib/credentials/application/operations' +import { filterWorkspaceAccountCredentials } from '@/lib/credentials/application/workspace-account-visibility' import { getPersonalOAuthCredentials, type PersonalOAuthCredential, @@ -33,7 +34,10 @@ export const listPersonalCredentials = defineAuthorizedWorkspaceUseCase({ getPersonalTokenCredentials(context.workspaceId, userId), ]) return { - credentials: [...oauthCredentials, ...tokenCredentials], + credentials: await filterWorkspaceAccountCredentials(context, [ + ...oauthCredentials, + ...tokenCredentials, + ]), } }, }) @@ -57,7 +61,8 @@ export const authorizePersonalCredential = defineAuthorizedWorkspaceUseCase({ input.credentialId ) const providerIds = providerIdsForService(input.expectedProviderId) - const credential = credentials.find( + const visible = await filterWorkspaceAccountCredentials(context, credentials) + const credential = visible.find( (entry) => entry.id === input.credentialId && providerIds.includes(entry.providerId) ) if (!credential) { diff --git a/apps/sim/lib/credentials/application/resolve-personal-token.test.ts b/apps/sim/lib/credentials/application/resolve-personal-token.test.ts index 92b3969f17f..5bd8e7eb18f 100644 --- a/apps/sim/lib/credentials/application/resolve-personal-token.test.ts +++ b/apps/sim/lib/credentials/application/resolve-personal-token.test.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ decrypt: vi.fn(), audit: vi.fn(), enrollment: vi.fn(), + policy: vi.fn(), + available: vi.fn(), })) vi.mock('@/lib/credentials/application/credential-context', () => ({ resolveCredentialApplicationContext: mocks.context, @@ -27,6 +29,12 @@ vi.mock('@sim/audit', () => ({ recordAudit: mocks.audit, })) +vi.mock('@/lib/resource-policies/repository', () => ({ requireResourcePolicy: mocks.policy })) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.available, +})) + +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import { resolvePersonalToken } from '@/lib/credentials/application/resolve-personal-token' const principal = { kind: 'session', userId: 'owner', sessionId: 'session' } as const @@ -116,6 +124,45 @@ describe('authorized personal token resolution', () => { expect(mocks.decrypt).not.toHaveBeenCalled() expect(mocks.audit).not.toHaveBeenCalled() }) + it('authorizes organization token type before decrypting and rechecks revocation', async () => { + const organizationToken = { ...current, workspaceId: null, organizationId: 'org' } + mocks.context.mockResolvedValue({ + ...context, + workspaceOrganizationId: 'org', + credential: organizationToken, + }) + mocks.access.mockResolvedValue({ + credential: organizationToken, + member: null, + hasWorkspaceAccess: true, + canWriteWorkspace: false, + isAdmin: true, + }) + mocks.enrollment.mockResolvedValue({ credentialGroupId: 'group' }) + mocks.available.mockResolvedValue(true) + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group', [ + { + workspaceId: 'ws', + access: { mode: 'selected', credentialTypes: ['personal_token:gitlab'] }, + }, + ]), + }) + await expect(resolvePersonalToken.execute({ principal, input })).resolves.toMatchObject({ + accessToken: 'secret', + }) + mocks.decrypt.mockClear() + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group', [ + { workspaceId: 'ws', access: { mode: 'selected', credentialTypes: ['oauth:gmail'] } }, + ]), + }) + await expect(resolvePersonalToken.execute({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.decrypt).not.toHaveBeenCalled() + }) + it('refuses revoked workspace access before secret resolution', async () => { mocks.permission.mockResolvedValue(null) await expect(resolvePersonalToken.execute({ principal, input })).rejects.toThrow( diff --git a/apps/sim/lib/credentials/application/resolve-personal-token.ts b/apps/sim/lib/credentials/application/resolve-personal-token.ts index 1901ee3aa19..3b0613df538 100644 --- a/apps/sim/lib/credentials/application/resolve-personal-token.ts +++ b/apps/sim/lib/credentials/application/resolve-personal-token.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' +import { requireOrganizationAccountsWorkspaceAccess } from '@/lib/credential-groups/application/organization-workspace-access' import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' import { credentialOperations } from '@/lib/credentials/application/operations' @@ -41,11 +42,21 @@ export const resolvePersonalToken = defineAuthorizedCredentialUseCase({ 'Connect your own active personal token for this integration' ) } - await requirePersonalTokenEnrollment({ + const enrollment = await requirePersonalTokenEnrollment({ ...resourceScopeFields(resourceScopeFromOwner(current)), userId, enrollmentId: current.credentialGroupEnrollmentId, }) + if (current.organizationId) { + await requireOrganizationAccountsWorkspaceAccess( + { + ...context, + organizationId: current.organizationId, + credentialGroupId: enrollment.credentialGroupId, + }, + 'personal_token:gitlab' + ) + } const accessToken = await decryptPersonalToken(current.encryptedPersonalToken, { providerId: 'gitlab', ownerUserId: userId, diff --git a/apps/sim/lib/credentials/application/workspace-account-visibility.test.ts b/apps/sim/lib/credentials/application/workspace-account-visibility.test.ts new file mode 100644 index 00000000000..69d94bd8b47 --- /dev/null +++ b/apps/sim/lib/credentials/application/workspace-account-visibility.test.ts @@ -0,0 +1,154 @@ +/** @vitest-environment node */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ policy: vi.fn(), available: vi.fn() })) +vi.mock('@/lib/resource-policies/repository', () => ({ requireResourcePolicy: mocks.policy })) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.available, +})) + +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' +import { filterWorkspaceAccountCredentials } from '@/lib/credentials/application/workspace-account-visibility' + +const context = { workspaceId: 'ws', workspaceOrganizationId: 'org', allowPersonalApiKeys: true } +const entries = [ + { id: 'ordinary', type: 'oauth', providerId: 'google-email' }, + { id: 'mail', type: 'managed_oauth', providerId: 'google-email' }, + { id: 'calendar', type: 'managed_oauth', providerId: 'google-calendar' }, + { id: 'token', type: 'personal_token', providerId: 'gitlab' }, +] +const bindings = entries.slice(1).map((entry) => ({ + ...entry, + organizationId: 'org', + workspaceId: null, + groupId: 'group', + groupOrganizationId: 'org', + groupWorkspaceId: null, +})) + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.available.mockResolvedValue(true) + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group', [ + { + workspaceId: 'ws', + access: { mode: 'selected', credentialTypes: ['oauth:gmail', 'personal_token:gitlab'] }, + }, + ]), + }) +}) + +describe('workspace organization credential visibility', () => { + it('filters canonical OAuth and token types with a single policy read while preserving ordinary accounts', async () => { + queueTableRows(schemaMock.credential, bindings) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual([ + entries[0], + entries[1], + entries[3], + ]) + expect(mocks.policy).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ organizationId: 'org', resourceId: 'group' }) + ) + expect(dbChainMockFns.select).toHaveBeenCalledExactlyOnceWith({ + id: schemaMock.credential.id, + organizationId: schemaMock.credential.organizationId, + workspaceId: schemaMock.credential.workspaceId, + groupId: schemaMock.credentialGroup.id, + groupOrganizationId: schemaMock.credentialGroup.organizationId, + groupWorkspaceId: schemaMock.credentialGroup.workspaceId, + providerId: schemaMock.credential.providerId, + type: schemaMock.credential.type, + }) + }) + + it('rechecks revocation and does not reuse a previously allowed selection', async () => { + queueTableRows(schemaMock.credential, bindings) + await filterWorkspaceAccountCredentials(context, entries) + mocks.policy.mockResolvedValue({ document: buildOrganizationAccountAccessPolicy('group', []) }) + queueTableRows(schemaMock.credential, bindings) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual([entries[0]]) + }) + + it.each([null, 'other-org'])( + 'hides organization accounts when the workspace belongs to %s', + async (workspaceOrganizationId) => { + queueTableRows(schemaMock.credential, bindings) + expect( + await filterWorkspaceAccountCredentials({ ...context, workspaceOrganizationId }, entries) + ).toEqual([entries[0]]) + expect(mocks.policy).not.toHaveBeenCalled() + } + ) + + it('fails closed for removed bindings and a disabled feature', async () => { + queueTableRows(schemaMock.credential, []) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual([entries[0]]) + mocks.available.mockResolvedValue(false) + queueTableRows(schemaMock.credential, bindings) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual([entries[0]]) + expect(mocks.policy).not.toHaveBeenCalled() + }) + + it('preserves independently managed workspace accounts', async () => { + queueTableRows( + schemaMock.credential, + bindings.map((binding) => ({ + ...binding, + organizationId: null, + workspaceId: 'ws', + groupOrganizationId: null, + groupWorkspaceId: 'ws', + })) + ) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual(entries) + expect(mocks.available).not.toHaveBeenCalled() + expect(mocks.policy).not.toHaveBeenCalled() + }) + + it.each([ + { groupOrganizationId: 'other-org', groupWorkspaceId: null }, + { groupOrganizationId: null, groupWorkspaceId: 'ws' }, + ])('rejects mismatched group ownership before loading policy: %j', async (owner) => { + queueTableRows( + schemaMock.credential, + bindings.map((binding) => ({ ...binding, ...owner })) + ) + await expect(filterWorkspaceAccountCredentials(context, entries)).rejects.toThrow( + 'Credential and enrollment group owners do not match' + ) + expect(mocks.policy).not.toHaveBeenCalled() + }) + + it('hides independently managed credentials that moved to another workspace', async () => { + queueTableRows( + schemaMock.credential, + bindings.map((binding) => ({ + ...binding, + organizationId: null, + workspaceId: 'other-ws', + groupOrganizationId: null, + groupWorkspaceId: 'other-ws', + })) + ) + expect(await filterWorkspaceAccountCredentials(context, entries)).toEqual([entries[0]]) + expect(mocks.policy).not.toHaveBeenCalled() + }) + + it('throws for malformed policy or a changed canonical provider instead of granting access', async () => { + queueTableRows(schemaMock.credential, bindings) + mocks.policy.mockRejectedValueOnce(new Error('Malformed policy')) + await expect(filterWorkspaceAccountCredentials(context, entries)).rejects.toThrow( + 'Malformed policy' + ) + queueTableRows( + schemaMock.credential, + bindings.map((binding) => ({ ...binding, providerId: 'slack' })) + ) + await expect(filterWorkspaceAccountCredentials(context, entries)).rejects.toThrow( + 'binding changed' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/workspace-account-visibility.ts b/apps/sim/lib/credentials/application/workspace-account-visibility.ts new file mode 100644 index 00000000000..a472cd895f0 --- /dev/null +++ b/apps/sim/lib/credentials/application/workspace-account-visibility.ts @@ -0,0 +1,98 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { eq, inArray } from 'drizzle-orm' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { resourceScopeFromOwner, sameResourceScope } from '@/lib/core/resource-scope' +import { + type OrganizationAccountAccessPolicy, + organizationAccountAccessPolicyCodec, + organizationAccountPolicyAllowsWorkspace, +} from '@/lib/credential-groups/application/workspace-access-policy' +import { organizationOAuthCredentialType } from '@/lib/credential-groups/credential-types' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' +import { requireResourcePolicy } from '@/lib/resource-policies/repository' + +/** Applies organization grants after the calling application operation authorizes workspace access. */ +export async function filterWorkspaceAccountCredentials< + T extends { id: string; type: string; providerId: string }, +>(context: WorkspaceAuthorizationContext, credentials: T[]): Promise { + const managedIds = credentials + .filter((entry) => entry.type === 'managed_oauth' || entry.type === 'personal_token') + .map((entry) => entry.id) + if (!managedIds.length) return credentials + const bindings = await db + .select({ + id: credential.id, + organizationId: credential.organizationId, + workspaceId: credential.workspaceId, + groupId: credentialGroup.id, + groupOrganizationId: credentialGroup.organizationId, + groupWorkspaceId: credentialGroup.workspaceId, + providerId: credential.providerId, + type: credential.type, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where(inArray(credential.id, managedIds)) + for (const binding of bindings) { + if ( + !sameResourceScope( + resourceScopeFromOwner(binding), + resourceScopeFromOwner({ + organizationId: binding.groupOrganizationId, + workspaceId: binding.groupWorkspaceId, + }) + ) + ) + throw new Error('Credential and enrollment group owners do not match') + } + const byId = new Map(bindings.map((binding) => [binding.id, binding])) + const policies = new Map() + const organizationId = context.workspaceOrganizationId + const organizationAvailable = + organizationId && bindings.some((binding) => binding.organizationId === organizationId) + ? await isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }) + : false + for (const binding of bindings) { + if ( + !binding.organizationId || + binding.organizationId !== organizationId || + !organizationAvailable || + policies.has(binding.groupId) + ) + continue + const policy = await requireResourcePolicy({ + organizationId: binding.organizationId, + resourceType: 'credential_group', + resourceId: binding.groupId, + codec: organizationAccountAccessPolicyCodec, + }) + policies.set(binding.groupId, policy.document) + } + return credentials.filter((entry) => { + if (entry.type !== 'managed_oauth' && entry.type !== 'personal_token') return true + const binding = byId.get(entry.id) + if (!binding) return false + if (!binding.organizationId) return binding.workspaceId === context.workspaceId + if (binding.organizationId !== organizationId || !organizationAvailable) return false + const policy = policies.get(binding.groupId) + if (!policy) throw new Error('Organization credential policy was not loaded') + if ( + !binding.providerId || + binding.providerId !== entry.providerId || + binding.type !== entry.type + ) + throw new Error('Credential binding changed while listing accounts') + if (binding.type === 'personal_token' && binding.providerId !== 'gitlab') + throw new Error('Unsupported personal-token provider') + const type = + binding.type === 'personal_token' + ? 'personal_token:gitlab' + : organizationOAuthCredentialType(binding.providerId) + return organizationAccountPolicyAllowsWorkspace(policy, context.workspaceId, type) + }) +} diff --git a/apps/sim/lib/credentials/environment.test.ts b/apps/sim/lib/credentials/environment.test.ts index 06852cef485..2d67adc0aa9 100644 --- a/apps/sim/lib/credentials/environment.test.ts +++ b/apps/sim/lib/credentials/environment.test.ts @@ -1,20 +1,31 @@ /** * @vitest-environment node */ -import { credential, permissions, workspace } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { credential, environment, permissions, workspace } from '@sim/db/schema' +import { + dbChainMock, + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { eq } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DbOrTx } from '@/lib/db/types' -const { mockAcquireUserBillingIdentityLock } = vi.hoisted(() => ({ +const { mockAcquireUserBillingIdentityLock, mockLockPersonalEnvMap } = vi.hoisted(() => ({ mockAcquireUserBillingIdentityLock: vi.fn(), + mockLockPersonalEnvMap: vi.fn(), })) vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({ acquireUserBillingIdentityLock: mockAcquireUserBillingIdentityLock, })) +vi.mock('@/lib/credentials/env-locks', () => ({ + lockPersonalEnvMap: mockLockPersonalEnvMap, +})) + import { createWorkspaceEnvCredentials, getEnrolledManagedOAuthCredentials, @@ -196,9 +207,10 @@ describe('syncPersonalEnvCredentialsForUser', () => { vi.clearAllMocks() resetDbChainMock() mockAcquireUserBillingIdentityLock.mockResolvedValue(undefined) + mockLockPersonalEnvMap.mockResolvedValue(undefined) }) - it('uses one transaction and acquires the transfer fence before discovering workspaces', async () => { + it('locks the map before the transfer fence and reads current keys before reconciling', async () => { const base = dbChainMock.db const tx = { select: vi.fn(base.select), @@ -206,20 +218,24 @@ describe('syncPersonalEnvCredentialsForUser', () => { delete: vi.fn(base.delete), } as unknown as DbOrTx dbChainMockFns.transaction.mockImplementationOnce(async (callback) => callback(tx)) + queueTableRows(environment, [{ variables: { API_KEY: 'encrypted' } }]) queueTableRows(permissions, [{ workspaceId: 'ws-1' }]) queueTableRows(workspace, []) queueTableRows(credential, [{ id: 'credential-1' }]) await syncPersonalEnvCredentialsForUser({ userId: 'user-1', - envKeys: ['API_KEY'], }) + expect(mockLockPersonalEnvMap).toHaveBeenCalledWith(tx, 'user-1') + expect(mockLockPersonalEnvMap.mock.invocationCallOrder[0]).toBeLessThan( + mockAcquireUserBillingIdentityLock.mock.invocationCallOrder[0] + ) expect(mockAcquireUserBillingIdentityLock).toHaveBeenCalledWith(tx, 'user-1') expect(mockAcquireUserBillingIdentityLock.mock.invocationCallOrder[0]).toBeLessThan( (tx.select as ReturnType).mock.invocationCallOrder[0] ) - expect(tx.select).toHaveBeenCalledTimes(3) + expect(tx.select).toHaveBeenCalledTimes(4) expect(tx.insert).toHaveBeenCalledTimes(2) expect(tx.delete).toHaveBeenCalledTimes(1) }) @@ -232,19 +248,19 @@ describe('syncPersonalEnvCredentialsForUser', () => { delete: vi.fn(base.delete), } as unknown as DbOrTx dbChainMockFns.transaction.mockImplementationOnce(async (callback) => callback(tx)) + queueTableRows(environment, [{ variables: { API_KEY: 'encrypted' } }]) queueTableRows(permissions, []) queueTableRows(workspace, []) await syncPersonalEnvCredentialsForUser({ userId: 'user-1', - envKeys: ['API_KEY'], }) expect(mockAcquireUserBillingIdentityLock.mock.invocationCallOrder[0]).toBeLessThan( (tx.select as ReturnType).mock.invocationCallOrder[0] ) expect(tx.insert).not.toHaveBeenCalled() - expect(tx.delete).not.toHaveBeenCalled() + expect(tx.delete).toHaveBeenCalledTimes(1) }) it('syncs every workspace with one credential insert, lookup, membership insert, and cleanup', async () => { @@ -255,16 +271,16 @@ describe('syncPersonalEnvCredentialsForUser', () => { delete: vi.fn(base.delete), } as unknown as DbOrTx dbChainMockFns.transaction.mockImplementationOnce(async (callback) => callback(tx)) + queueTableRows(environment, [{ variables: { API_KEY: 'encrypted' } }]) queueTableRows(permissions, [{ workspaceId: 'ws-2' }, { workspaceId: 'ws-1' }]) queueTableRows(workspace, []) queueTableRows(credential, [{ id: 'credential-1' }, { id: 'credential-2' }]) await syncPersonalEnvCredentialsForUser({ userId: 'user-1', - envKeys: ['API_KEY'], }) - expect(tx.select).toHaveBeenCalledTimes(3) + expect(tx.select).toHaveBeenCalledTimes(4) expect(tx.insert).toHaveBeenCalledTimes(2) expect(tx.delete).toHaveBeenCalledTimes(1) expect(dbChainMockFns.values).toHaveBeenNthCalledWith(1, [ @@ -272,6 +288,57 @@ describe('syncPersonalEnvCredentialsForUser', () => { expect.objectContaining({ workspaceId: 'ws-2', envKey: 'API_KEY' }), ]) }) + + it.each([ + { label: 'missing', rows: [] }, + { label: 'empty', rows: [{ variables: {} }] }, + ])( + 'cleans archived-workspace mirrors with a $label map and no active workspaces', + async ({ rows }) => { + queueTableRows(environment, rows) + const deleteWhere = vi.fn().mockResolvedValue([]) + dbChainMock.db.delete.mockReturnValue({ where: deleteWhere }) + + await syncPersonalEnvCredentialsForUser({ userId: 'user-1' }) + + expect(dbChainMock.db.delete).toHaveBeenCalledWith(credential) + expect(flattenMockConditions(deleteWhere.mock.calls[0][0])).toEqual([ + { type: 'eq', left: credential.type, right: 'env_personal' }, + { type: 'eq', left: credential.envOwnerUserId, right: 'user-1' }, + ]) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + } + ) + + it.each([ + { label: 'no active workspaces', activeWorkspaces: [] }, + { label: 'an active workspace', activeWorkspaces: [{ workspaceId: 'active-workspace' }] }, + ])( + 'prunes deleted keys across archived workspaces with $label, preserving current keys and owners', + async ({ activeWorkspaces }) => { + queueTableRows(environment, [{ variables: { KEEP: 'encrypted', NEW: 'encrypted-new' } }]) + queueTableRows(permissions, activeWorkspaces) + queueTableRows(workspace, []) + const deleteWhere = vi.fn().mockResolvedValue([]) + dbChainMock.db.delete.mockReturnValue({ where: deleteWhere }) + + await syncPersonalEnvCredentialsForUser({ userId: 'user-1' }) + + expect(flattenMockConditions(deleteWhere.mock.calls[0][0])).toEqual([ + { type: 'eq', left: credential.type, right: 'env_personal' }, + { type: 'eq', left: credential.envOwnerUserId, right: 'user-1' }, + { type: 'notInArray', column: credential.envKey, values: ['KEEP', 'NEW'] }, + ]) + if (activeWorkspaces.length === 0) { + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + } else { + expect(dbChainMockFns.values).toHaveBeenCalledWith([ + expect.objectContaining({ workspaceId: 'active-workspace', envKey: 'KEEP' }), + expect.objectContaining({ workspaceId: 'active-workspace', envKey: 'NEW' }), + ]) + } + } + ) }) describe('createWorkspaceEnvCredentials', () => { diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 4d77e3dcb01..1840847871c 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -4,6 +4,7 @@ import { credentialGroup, credentialGroupEnrollment, credentialMember, + environment, permissions, user, workspace, @@ -15,6 +16,7 @@ import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNotNull, isNull, notInArray, or, sql } from 'drizzle-orm' import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock' import { isManagedCredentialGroupBindingLive } from '@/lib/credential-groups/credentials' +import { lockPersonalEnvMap } from '@/lib/credentials/env-locks' import type { DbOrTx } from '@/lib/db/types' import { getEffectiveWorkspacePermission, @@ -677,15 +679,13 @@ export async function deletePersonalEnvCredentialForUser(params: { await db.transaction(remove) } -export async function syncPersonalEnvCredentialsForUser(params: { - userId: string - envKeys: string[] -}): Promise { - const { userId, envKeys } = params - const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean))) +/** Reconciles user-global secret deletions and active-workspace mirrors against the locked map. */ +export async function syncPersonalEnvCredentialsForUser(params: { userId: string }): Promise { + const { userId } = params const now = new Date() await db.transaction(async (tx) => { + await lockPersonalEnvMap(tx, userId) /** * Cross-organization transfer takes this same user-identity fence before * checking source-owned credentials. If this sync wins, transfer observes @@ -693,85 +693,81 @@ export async function syncPersonalEnvCredentialsForUser(params: { * workspace re-read cannot recreate credentials in the departed org. */ await acquireUserBillingIdentityLock(tx, userId) - const workspaceIds = (await getUserWorkspaceIds(userId, tx)).sort() - - if (workspaceIds.length === 0) return - - if (normalizedKeys.length > 0) { - const credentialValues = workspaceIds.flatMap((workspaceId) => - normalizedKeys.map((envKey) => ({ - id: generateId(), - workspaceId, - type: 'env_personal' as const, - displayName: envKey, - envKey, - envOwnerUserId: userId, - createdBy: userId, - createdAt: now, - updatedAt: now, - })) + const [personalEnvironment] = await tx + .select({ variables: environment.variables }) + .from(environment) + .where(eq(environment.userId, userId)) + .limit(1) + const envKeys = Object.keys(personalEnvironment?.variables ?? {}).filter(Boolean) + + /** Deleted keys must lose mirrors even in archived or no-longer-accessible workspaces. */ + await tx + .delete(credential) + .where( + and( + eq(credential.type, 'env_personal'), + eq(credential.envOwnerUserId, userId), + envKeys.length > 0 ? notInArray(credential.envKey, envKeys) : undefined + ) ) - for (const values of chunkArray(credentialValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) { - await tx.insert(credential).values(values).onConflictDoNothing() - } - const currentCredentials = await tx - .select({ id: credential.id }) - .from(credential) - .where( - and( - inArray(credential.workspaceId, workspaceIds), - eq(credential.type, 'env_personal'), - eq(credential.envOwnerUserId, userId), - inArray(credential.envKey, normalizedKeys) - ) - ) + if (envKeys.length === 0) return - if (currentCredentials.length > 0) { - const membershipValues = currentCredentials.map(({ id: credentialId }) => ({ - id: generateId(), - credentialId, - userId, - role: 'admin' as const, - status: 'active' as const, - joinedAt: now, - invitedBy: userId, - createdAt: now, - updatedAt: now, - })) - for (const values of chunkArray(membershipValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) { - await tx - .insert(credentialMember) - .values(values) - .onConflictDoUpdate({ - target: [credentialMember.credentialId, credentialMember.userId], - set: { role: 'admin', status: 'active', updatedAt: now }, - }) - } - } + const workspaceIds = (await getUserWorkspaceIds(userId, tx)).sort() - await tx - .delete(credential) - .where( - and( - inArray(credential.workspaceId, workspaceIds), - eq(credential.type, 'env_personal'), - eq(credential.envOwnerUserId, userId), - notInArray(credential.envKey, normalizedKeys) - ) - ) - return + if (workspaceIds.length === 0) return + + const credentialValues = workspaceIds.flatMap((workspaceId) => + envKeys.map((envKey) => ({ + id: generateId(), + workspaceId, + type: 'env_personal' as const, + displayName: envKey, + envKey, + envOwnerUserId: userId, + createdBy: userId, + createdAt: now, + updatedAt: now, + })) + ) + for (const values of chunkArray(credentialValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) { + await tx.insert(credential).values(values).onConflictDoNothing() } - await tx - .delete(credential) + const currentCredentials = await tx + .select({ id: credential.id }) + .from(credential) .where( and( inArray(credential.workspaceId, workspaceIds), eq(credential.type, 'env_personal'), - eq(credential.envOwnerUserId, userId) + eq(credential.envOwnerUserId, userId), + inArray(credential.envKey, envKeys) ) ) + + if (currentCredentials.length > 0) { + const membershipValues = currentCredentials.map(({ id: credentialId }) => ({ + id: generateId(), + credentialId, + userId, + role: 'admin' as const, + status: 'active' as const, + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + })) + for (const values of chunkArray(membershipValues, ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) { + await tx + .insert(credentialMember) + .values(values) + .onConflictDoUpdate({ + target: [credentialMember.credentialId, credentialMember.userId], + set: { role: 'admin', status: 'active', updatedAt: now }, + }) + } + } }) } diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts index df621ae31c7..597a93e94db 100644 --- a/apps/sim/lib/credentials/managed-mcp.ts +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -20,6 +20,7 @@ import { sameResourceScopeCondition, } from '@/lib/core/resource-scope.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import type { OrganizationCredentialType } from '@/lib/credential-groups/credential-types' import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' @@ -36,6 +37,7 @@ interface ManagedMcpTokenEnvelope { } export interface ManagedMcpCredentialApplicationContext extends WorkspaceAuthorizationContext { + credentialType: OrganizationCredentialType organizationId?: string credentialId: string credentialGroupId: string @@ -45,6 +47,7 @@ export interface ManagedMcpCredentialApplicationContext extends WorkspaceAuthori } export interface ManagedMcpRuntimeCredential { + credentialType: OrganizationCredentialType grantedAt: Date oauthConfigVersion: number scope: ResourceScope @@ -150,7 +153,7 @@ export async function loadManagedMcpCredentialApplicationContext( if (!row.managedConnectorId) { throw new Error(`Managed MCP server ${row.mcpServerId} has no connector ID`) } - getManagedMcpConnector(row.managedConnectorId) + const connector = getManagedMcpConnector(row.managedConnectorId) const workspaceContext = await loadActiveWorkspaceApplicationContext(workspaceId) if ( !workspaceContext || @@ -159,7 +162,12 @@ export async function loadManagedMcpCredentialApplicationContext( : row.workspaceId !== workspaceId) ) return null - return { ...row, ...workspaceContext, organizationId: row.organizationId ?? undefined } + return { + ...row, + ...workspaceContext, + credentialType: `mcp:${connector.id}` as const, + organizationId: row.organizationId ?? undefined, + } } export async function loadManagedMcpRuntimeCredential( @@ -221,7 +229,7 @@ export async function loadManagedMcpRuntimeCredential( if (!row.managedConnectorId) { throw new ManagedMcpCredentialError('Managed MCP connector metadata is missing', 500) } - getManagedMcpConnector(row.managedConnectorId) + const connector = getManagedMcpConnector(row.managedConnectorId) if ( row.status !== 'active' || row.groupStatus !== 'active' || @@ -239,6 +247,7 @@ export async function loadManagedMcpRuntimeCredential( if (!row.grantedAt) throw new ManagedMcpCredentialError('Managed MCP grant version is missing', 500) return { + credentialType: `mcp:${connector.id}`, credentialId: row.credentialId, oauthConfigVersion: row.serverOauthConfigVersion, credentialGroupId: row.credentialGroupId, diff --git a/apps/sim/lib/credentials/managed-oauth.ts b/apps/sim/lib/credentials/managed-oauth.ts index 57a4cc1998b..51429749b89 100644 --- a/apps/sim/lib/credentials/managed-oauth.ts +++ b/apps/sim/lib/credentials/managed-oauth.ts @@ -11,6 +11,10 @@ import { } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { + type OrganizationCredentialType, + organizationOAuthCredentialType, +} from '@/lib/credential-groups/credential-types' import { type CredentialGroupProviderAdapter, CredentialGroupProviderConfigurationError, @@ -73,6 +77,7 @@ interface ResolveManagedOAuthTokenParams { } export interface ManagedOAuthCredentialApplicationContext extends WorkspaceAuthorizationContext { + credentialType: OrganizationCredentialType organizationId?: string credentialId: string credentialGroupId: string @@ -196,9 +201,11 @@ export async function loadManagedOAuthCredentialApplicationContext( : row.workspaceId !== workspaceId ) return null + if (!row.providerId) throw new Error('Managed OAuth credential is missing its provider') return { ...workspaceContext, ...(row.organizationId ? { organizationId: row.organizationId } : {}), + credentialType: organizationOAuthCredentialType(row.providerId), credentialId: row.id, credentialGroupId: row.credentialGroupId, credentialGroupEnrollmentId: row.credentialGroupEnrollmentId, diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index babe6aa6e1c..356a0084680 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -602,11 +602,8 @@ export async function deleteCredentialRecord( * Same read-modify-write on the personal map, under the same lock its * other writers take, with the mirrors removed in the same transaction. * - * Targeted rather than a reconcile: the reconcile prunes every mirror - * absent from a caller-supplied key list, so a secret added between the - * read and the prune lost its mirror while its value survived. Deleting - * this one key's mirrors cannot strand another secret, and the lock order - * — map, then user identity — is the one `setPersonalSecret` already takes. + * Delete only this key's mirrors across every workspace. The lock order + * — map, then user identity — matches `setPersonalSecret` and bulk sync. */ await db.transaction(async (tx) => { await lockPersonalEnvMap(tx, envOwnerUserId) diff --git a/apps/sim/lib/credentials/personal-tokens.ts b/apps/sim/lib/credentials/personal-tokens.ts index 41cbd2b2ba3..00f300f9751 100644 --- a/apps/sim/lib/credentials/personal-tokens.ts +++ b/apps/sim/lib/credentials/personal-tokens.ts @@ -120,7 +120,7 @@ export async function requirePersonalTokenEnrollment( input: ResourceOwner & { userId: string; enrollmentId: string | null }, executor: DbOrTx = db, lock = false -): Promise { +): Promise<{ credentialGroupId: string }> { const scope = resourceScopeFromOwner(input) if (!input.enrollmentId) throw new OrchestrationError( @@ -173,6 +173,7 @@ export async function requirePersonalTokenEnrollment( executor ) } + return { credentialGroupId: binding.credentialGroupId } } export interface CreatePersonalTokenParams { diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index c2d089ddb3e..1ff8453881c 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -81,6 +81,7 @@ export const PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID = 'pipedrive-service-account' export const CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID = 'claude-platform-service-account' as const export const SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID = 'snowflake-service-account' as const +export const CODA_SERVICE_ACCOUNT_PROVIDER_ID = 'coda-service-account' as const const SHOPIFY_DOMAIN_HINT_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i @@ -109,6 +110,7 @@ export type TokenServiceAccountProviderId = | typeof PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID | typeof CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID | typeof SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof CODA_SERVICE_ACCOUNT_PROVIDER_ID export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< TokenServiceAccountProviderId, @@ -407,6 +409,23 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/managed-agent', }, + [CODA_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: CODA_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Coda', + tokenNoun: 'API token', + connectNoun: 'API token', + fields: [ + { + id: 'apiToken', + label: 'API token', + placeholder: 'Paste a Coda API token', + secret: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/coda', + helpText: + 'Create a token under Account settings → API settings. A token restricted to specific docs or tables can only read and write those.', + }, [SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Snowflake', diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index b341006753a..4e6daa6582d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -6,6 +6,7 @@ import { CALCOM_SERVICE_ACCOUNT_PROVIDER_ID, CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID, CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID, + CODA_SERVICE_ACCOUNT_PROVIDER_ID, HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID, HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID, isTokenServiceAccountProviderId, @@ -27,6 +28,7 @@ import { validateAttioServiceAccount } from '@/lib/credentials/token-service-acc import { validateCalcomServiceAccount } from '@/lib/credentials/token-service-accounts/validators/calcom' import { validateClaudePlatformServiceAccount } from '@/lib/credentials/token-service-accounts/validators/claude-platform' import { validateClickupServiceAccount } from '@/lib/credentials/token-service-accounts/validators/clickup' +import { validateCodaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/coda' import { validateHarmonicServiceAccount } from '@/lib/credentials/token-service-accounts/validators/harmonic' import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot' import { validateLinearServiceAccount } from '@/lib/credentials/token-service-accounts/validators/linear' @@ -100,6 +102,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID]: validatePipedriveServiceAccount, [CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID]: validateClaudePlatformServiceAccount, [SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID]: validateSnowflakeServiceAccount, + [CODA_SERVICE_ACCOUNT_PROVIDER_ID]: validateCodaServiceAccount, } export function getTokenServiceAccountValidator( diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/coda.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/coda.test.ts new file mode 100644 index 00000000000..4bb418a3510 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/coda.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + CODA_SERVICE_ACCOUNT_PROVIDER_ID, + TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, +} from '@/lib/credentials/token-service-accounts/descriptors' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { getTokenServiceAccountValidator } from '@/lib/credentials/token-service-accounts/server' +import { validateCodaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/coda' + +const mockFetch = vi.fn() + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('validateCodaServiceAccount', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) + mockFetch.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('is registered with its descriptor', () => { + expect(getTokenServiceAccountValidator(CODA_SERVICE_ACCOUNT_PROVIDER_ID)).toBe( + validateCodaServiceAccount + ) + expect(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[CODA_SERVICE_ACCOUNT_PROVIDER_ID]).toMatchObject({ + serviceLabel: 'Coda', + fields: [{ id: 'apiToken', secret: true }], + }) + }) + + it('returns the token owner as principal and workspace metadata', async () => { + mockFetch.mockResolvedValue( + jsonResponse(200, { + name: 'Jane Doe', + loginId: 'jane@example.com', + type: 'user', + scoped: false, + tokenName: 'Sim workflows', + href: 'https://coda.io/apis/v1/whoami', + workspace: { id: 'ws-1Ab234', type: 'workspace', name: 'Acme' }, + }) + ) + + const result = await validateCodaServiceAccount({ apiToken: 'coda-token' }) + + expect(result).toEqual({ + displayName: 'Sim workflows (jane@example.com)', + principal: { kind: 'user', id: 'jane@example.com', label: 'Jane Doe' }, + auditMetadata: { codaWorkspaceId: 'ws-1Ab234' }, + storedMetadata: { workspaceId: 'ws-1Ab234', scoped: 'false', tokenName: 'Sim workflows' }, + }) + expect(mockFetch).toHaveBeenCalledWith('https://coda.io/apis/v1/whoami', { + headers: { Authorization: 'Bearer coda-token', Accept: 'application/json' }, + redirect: 'error', + signal: expect.any(AbortSignal), + }) + }) + + it('maps 401 to invalid_credentials', async () => { + mockFetch.mockResolvedValue( + jsonResponse(401, { statusCode: 401, statusMessage: 'Unauthorized', message: 'Unauthorized' }) + ) + + const error = await validateCodaServiceAccount({ apiToken: 'bad' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('invalid_credentials') + expect(error.status).toBe(401) + }) + + it('maps 429 and 500 to provider_unavailable', async () => { + for (const status of [429, 500]) { + mockFetch.mockResolvedValueOnce(jsonResponse(status, { message: 'nope' })) + const error = await validateCodaServiceAccount({ apiToken: 'coda-token' }).catch((e) => e) + expect(error.code).toBe('provider_unavailable') + } + }) + + it('rejects a success body without a login id', async () => { + for (const body of [{ name: 'Jane' }, null, { loginId: ' ' }]) { + mockFetch.mockResolvedValueOnce(jsonResponse(200, body)) + const error = await validateCodaServiceAccount({ apiToken: 'coda-token' }).catch((e) => e) + expect(error.code).toBe('provider_unavailable') + } + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/coda.ts b/apps/sim/lib/credentials/token-service-accounts/validators/coda.ts new file mode 100644 index 00000000000..dddb0909b68 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/coda.ts @@ -0,0 +1,64 @@ +import { userPrincipal } from '@/lib/credentials/principal' +import { + fetchProvider, + parseProviderJson, + TokenServiceAccountValidationError, + throwForProviderResponse, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' +import { CODA_API_BASE, codaHeaders } from '@/tools/coda/utils' + +const CODA_WHOAMI_URL = `${CODA_API_BASE}/whoami` + +interface CodaWhoamiResponse { + name?: string + loginId?: string + tokenName?: string + scoped?: boolean + workspace?: { id?: string; name?: string } +} + +/** + * Validates a Coda API token by calling `GET /whoami`, which every token may + * call regardless of doc or table restrictions. The header set comes from the + * same helper the runtime tools use, so a token that verifies here is proven + * against the exact request shape tools send. Coda exposes no numeric user id, + * so the login email is the principal id. + */ +export async function validateCodaServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const res = await fetchProvider( + CODA_WHOAMI_URL, + { headers: codaHeaders(fields.apiToken), redirect: 'error' }, + 'whoami' + ) + await throwForProviderResponse(res, 'whoami') + + const body = await parseProviderJson(res, 'whoami') + if (typeof body?.loginId !== 'string' || !body.loginId.trim()) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'whoami', + reason: 'missing loginId in response', + }) + } + + const auditMetadata: Record = {} + const storedMetadata: Record = {} + if (body.workspace?.id) { + auditMetadata.codaWorkspaceId = body.workspace.id + storedMetadata.workspaceId = body.workspace.id + } + if (typeof body.scoped === 'boolean') storedMetadata.scoped = String(body.scoped) + if (body.tokenName) storedMetadata.tokenName = body.tokenName + + return { + displayName: body.tokenName ? `${body.tokenName} (${body.loginId})` : body.loginId, + principal: userPrincipal(body.loginId, body.name), + auditMetadata, + storedMetadata, + } +} diff --git a/apps/sim/lib/data-drains/sources/workflow-logs.ts b/apps/sim/lib/data-drains/sources/workflow-logs.ts index 5d198a435c7..a0f7b9e14ae 100644 --- a/apps/sim/lib/data-drains/sources/workflow-logs.ts +++ b/apps/sim/lib/data-drains/sources/workflow-logs.ts @@ -1,5 +1,5 @@ import { dbReplica } from '@sim/db' -import { workflowExecutionLogColumns, workflowExecutionLogs } from '@sim/db/schema' +import { workflowExecutionLogs } from '@sim/db/schema' import { and, inArray, isNotNull } from 'drizzle-orm' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { @@ -35,7 +35,7 @@ async function* pages(input: SourcePageInput): AsyncIterable { ) const rows = await dbReplica - .select(workflowExecutionLogColumns) + .select() .from(workflowExecutionLogs) .where( and( diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 0c72b260fb2..3e43c5f48ac 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -656,7 +656,7 @@ export async function upsertPersonalEnvVars( * plaintext. `added`/`updated` describe the earlier read and are reporting * only — the keys actually written are exactly the re-encrypted ones. */ - const finalEncrypted = await db.transaction(async (tx) => { + await db.transaction(async (tx) => { await lockPersonalEnvMap(tx, userId) const [currentRow] = await tx @@ -679,14 +679,11 @@ export async function upsertPersonalEnvVars( target: [environment.userId], set: { variables: merged, updatedAt: new Date() }, }) - - return merged }) invalidateEffectiveDecryptedEnvCache({ userId }) await syncPersonalEnvCredentialsForUser({ userId, - envKeys: Object.keys(finalEncrypted), }) return { added, updated } diff --git a/apps/sim/lib/file-parsers/index.ts b/apps/sim/lib/file-parsers/index.ts index 7c4cc43a512..03bf72c26b8 100644 --- a/apps/sim/lib/file-parsers/index.ts +++ b/apps/sim/lib/file-parsers/index.ts @@ -176,7 +176,7 @@ export async function parseBuffer( } const kind = sniffFileKind(buffer, normalizedExtension) - const route = reconcileParserRoute(normalizedExtension, kind) + const route = reconcileParserRoute(normalizedExtension, kind, options) const parser = PARSERS.get(route.extension) if (!parser?.parseBuffer) { diff --git a/apps/sim/lib/file-parsers/sniff.test.ts b/apps/sim/lib/file-parsers/sniff.test.ts index 5e94bc89698..e79f0a575c0 100644 --- a/apps/sim/lib/file-parsers/sniff.test.ts +++ b/apps/sim/lib/file-parsers/sniff.test.ts @@ -308,6 +308,38 @@ describe('parseBuffer reconciles the extension with the sniffed bytes', () => { expect(result.metadata?.detectedType).toBe('html') }) + it.each([ + '
', + '{\\rtf1\\ansi Source-format example}', + ])( + 'preserves textual markup when the caller supplies a canonical text artifact', + async (content) => { + const result = await parseBuffer(Buffer.from(content), 'txt', { textMode: 'literal' }) + + expect(result.content).toBe(content) + expect(result.metadata?.detectedType).toBeUndefined() + } + ) + + it('keeps literal-text handling scoped to txt artifacts', async () => { + const result = await parseBuffer( + Buffer.from('

Readable page

'), + 'html', + { textMode: 'literal' } + ) + + expect(result.content).toContain('Readable page') + expect(result.content).not.toContain('') + await expect( + parseBuffer(Buffer.from('403 Forbidden'), 'json', { + textMode: 'literal', + }) + ).rejects.toMatchObject({ code: 'invalid_format' }) + await expect(parseBuffer(oleBinary(), 'txt', { textMode: 'literal' })).rejects.toMatchObject({ + code: 'invalid_format', + }) + }) + it('extracts a docx labelled .xlsx through the Word parser', async () => { const result = await parseBuffer(await buildDocx('Office Relocation'), 'xlsx') diff --git a/apps/sim/lib/file-parsers/sniff.ts b/apps/sim/lib/file-parsers/sniff.ts index 365b4c4b66f..a976b97e22b 100644 --- a/apps/sim/lib/file-parsers/sniff.ts +++ b/apps/sim/lib/file-parsers/sniff.ts @@ -1,5 +1,6 @@ import { FileParserError } from '@/lib/file-parsers/errors' import { isEncryptedOoxmlContainer } from '@/lib/file-parsers/ooxml-encryption' +import type { FileParseOptions } from '@/lib/file-parsers/types' import { decodeTextBuffer, detectBomlessUtf16 } from '@/lib/file-parsers/utils' import { isZipShaped } from '@/lib/file-parsers/zip-guard' @@ -307,7 +308,18 @@ function invalidFormat(extension: string, kind: SniffedKind): FileParserError { * text (as CSV under a spreadsheet extension), and an OLE2 file under a modern * Word extension is the legacy `.doc` parser's job. Legacy `.ppt` has no reader. */ -export function reconcileParserRoute(extension: string, kind: SniffedKind): ParserRoute { +export function reconcileParserRoute( + extension: string, + kind: SniffedKind, + options: Pick = {} +): ParserRoute { + if ( + extension === 'txt' && + options.textMode === 'literal' && + (kind === 'html' || kind === 'rtf') + ) { + return { extension } + } if (kind === 'rtf') { throw new FileParserError( 'unsupported_type', diff --git a/apps/sim/lib/file-parsers/types.ts b/apps/sim/lib/file-parsers/types.ts index 36b059a3bc7..028e2e7382b 100644 --- a/apps/sim/lib/file-parsers/types.ts +++ b/apps/sim/lib/file-parsers/types.ts @@ -33,6 +33,8 @@ export interface FileParseResult { export interface FileParseOptions { signal?: AbortSignal + /** Preserve textual markup in a canonical .txt artifact instead of interpreting it as HTML or RTF. */ + textMode?: 'literal' /** Complete PDF extraction rejects safety limits instead of returning preview text. */ pdfTextMode?: 'preview' | 'complete' } diff --git a/apps/sim/lib/integrations/credential-display.test.ts b/apps/sim/lib/integrations/credential-display.test.ts index cc138c9d705..69dbcfff521 100644 --- a/apps/sim/lib/integrations/credential-display.test.ts +++ b/apps/sim/lib/integrations/credential-display.test.ts @@ -49,6 +49,7 @@ const EXPECTED_COVERAGE: Record = { 'calcom-service-account': ['cal-com'], 'claude-platform-service-account': [], 'clickup-service-account': ['clickup'], + 'coda-service-account': [], 'github-app-installation': ['github'], 'google-service-account': [ 'gmail', diff --git a/apps/sim/lib/integrations/credential-display.ts b/apps/sim/lib/integrations/credential-display.ts index 2ad42a6b617..f65ef1128c0 100644 --- a/apps/sim/lib/integrations/credential-display.ts +++ b/apps/sim/lib/integrations/credential-display.ts @@ -12,18 +12,17 @@ import type { ComponentType } from 'react' import { getIntegrationTypesForOAuthServiceId } from '@sim/deployment-config/integration-availability' -import integrationsJson from '@sim/deployment-config/integrations.json' +import { + INTEGRATION_METADATA, + type IntegrationMetadata, +} from '@sim/deployment-config/integration-metadata' import { GitlabIcon } from '@/components/icons' import { getServiceAccountConnectNoun } from '@/lib/credentials/service-account-provider-ids' import { CANONICAL_SERVICE_ACCOUNT_SLUGS } from '@/lib/integrations/oauth-service' -import type { Integration } from '@/lib/integrations/types' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import type { OAuthProvider, OAuthServiceConfig } from '@/lib/oauth/types' import { getServiceConfigByProviderId, parseProvider } from '@/lib/oauth/utils' -const INTEGRATIONS_DATA: readonly Integration[] = - integrationsJson.integrations as readonly Integration[] - /** * Above this many covered integrations the subtitle states a count instead of * enumerating names — Google issues one service account for 13 integrations, @@ -36,16 +35,16 @@ const MAX_ENUMERATED_INTEGRATIONS = 3 * inline per credential per render on the integrations surfaces, so its lookups * must be O(1) rather than scanning the full catalog each time. */ -const INTEGRATION_BY_SLUG: ReadonlyMap = new Map( - INTEGRATIONS_DATA.map((i) => [i.slug, i]) +const INTEGRATION_BY_SLUG: ReadonlyMap = new Map( + INTEGRATION_METADATA.map((i) => [i.slug, i]) ) -const INTEGRATION_BY_TYPE: ReadonlyMap = new Map( - INTEGRATIONS_DATA.map((integration) => [integration.type, integration]) +const INTEGRATION_BY_TYPE: ReadonlyMap = new Map( + INTEGRATION_METADATA.map((integration) => [integration.type, integration]) ) /** Keyed by lowercased display name, matching how OAuth services are named. */ -const INTEGRATION_BY_LOWER_NAME: ReadonlyMap = new Map( - INTEGRATIONS_DATA.map((i) => [i.name.toLowerCase(), i]) +const INTEGRATION_BY_LOWER_NAME: ReadonlyMap = new Map( + INTEGRATION_METADATA.map((i) => [i.name.toLowerCase(), i]) ) /** Every provider id that some service designates as its service-account id. */ @@ -66,42 +65,45 @@ const SERVICE_ACCOUNT_PROVIDER_IDS: ReadonlySet = new Set( * The deployment service mapping also covers Search-only OAuth credentials * whose corresponding workflow integration uses an API key. */ -const INTEGRATIONS_BY_CREDENTIAL_PROVIDER: ReadonlyMap = (() => { - const index = new Map() - const add = (providerId: string | undefined, integration: Integration) => { - if (!providerId) return - const existing = index.get(providerId) - if (existing) existing.push(integration) - else index.set(providerId, [integration]) - } +const INTEGRATIONS_BY_CREDENTIAL_PROVIDER: ReadonlyMap = + (() => { + const index = new Map() + const add = (providerId: string | undefined, integration: IntegrationMetadata) => { + if (!providerId) return + const existing = index.get(providerId) + if (existing) existing.push(integration) + else index.set(providerId, [integration]) + } - for (const provider of Object.values(OAUTH_PROVIDERS)) { - for (const [serviceId, service] of Object.entries(provider.services)) { - for (const integrationType of getIntegrationTypesForOAuthServiceId(serviceId)) { - const integration = INTEGRATION_BY_TYPE.get(integrationType) - if (!integration) continue - add(service.providerId, integration) - add(service.serviceAccountProviderId, integration) - for (const extraProviderId of service.additionalProviderIds ?? []) { - add(extraProviderId, integration) + for (const provider of Object.values(OAUTH_PROVIDERS)) { + for (const [serviceId, service] of Object.entries(provider.services)) { + for (const integrationType of getIntegrationTypesForOAuthServiceId(serviceId)) { + const integration = INTEGRATION_BY_TYPE.get(integrationType) + if (!integration) continue + add(service.providerId, integration) + add(service.serviceAccountProviderId, integration) + for (const extraProviderId of service.additionalProviderIds ?? []) { + add(extraProviderId, integration) + } } } } - } - const catalogOrder = new Map( - INTEGRATIONS_DATA.map((integration, order) => [integration.type, order]) - ) - for (const covered of index.values()) { - covered.sort( - (left, right) => (catalogOrder.get(left.type) ?? 0) - (catalogOrder.get(right.type) ?? 0) + const catalogOrder = new Map( + INTEGRATION_METADATA.map((integration, order) => [integration.type, order]) ) - } - return index -})() + for (const covered of index.values()) { + covered.sort( + (left, right) => (catalogOrder.get(left.type) ?? 0) - (catalogOrder.get(right.type) ?? 0) + ) + } + return index + })() /** Catalog integrations a credential of this provider id can authenticate. */ -export function getIntegrationsForCredentialProvider(providerId: string): readonly Integration[] { +export function getIntegrationsForCredentialProvider( + providerId: string +): readonly IntegrationMetadata[] { return INTEGRATIONS_BY_CREDENTIAL_PROVIDER.get(providerId) ?? [] } @@ -185,7 +187,7 @@ export interface CredentialDisplay { * service account this is the family's canonical integration, since no single * product owns the credential. */ - integration: Integration | null + integration: IntegrationMetadata | null /** `integration.type`, or '' — drives the brand tile background. */ blockType: string /** Mark to render: the family's corporate icon, else the service's own. */ @@ -193,7 +195,7 @@ export interface CredentialDisplay { /** Vendor name when this is a family service account, else null. */ familyName: string | null /** Catalog integrations this credential authenticates, in catalog order. */ - coveredIntegrations: readonly Integration[] + coveredIntegrations: readonly IntegrationMetadata[] /** * Generated sentence describing the credential's service and reach. Never the * user's own description — list surfaces prefer `credential.description` and @@ -291,7 +293,7 @@ function resolveCatalogIntegration( providerId: string, service: OAuthServiceConfig | null, isFamily: boolean -): Integration | null { +): IntegrationMetadata | null { if (isFamily) { const slug = CANONICAL_SERVICE_ACCOUNT_SLUGS[providerId] const canonical = slug ? INTEGRATION_BY_SLUG.get(slug) : undefined @@ -309,7 +311,7 @@ interface SubtitleArgs { providerId: string service: OAuthServiceConfig | null familyName: string | null - coveredIntegrations: readonly Integration[] + coveredIntegrations: readonly IntegrationMetadata[] isServiceAccount: boolean } diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index bad2b73663e..6167ee0d684 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -46,6 +46,7 @@ import { CloudflareIcon, CloudTrailIcon, CloudWatchIcon, + CodaIcon, CodePipelineIcon, ConfluenceIcon, ContextDevIcon, @@ -319,6 +320,7 @@ export const blockTypeToIconMap: Record = { cloudformation: CloudFormationIcon, cloudtrail: CloudTrailIcon, cloudwatch: CloudWatchIcon, + coda: CodaIcon, codepipeline: CodePipelineIcon, confluence: ConfluenceIcon, confluence_v2: ConfluenceIcon, diff --git a/apps/sim/lib/integrations/metadata-boundary.test.ts b/apps/sim/lib/integrations/metadata-boundary.test.ts new file mode 100644 index 00000000000..cd243f41fc3 --- /dev/null +++ b/apps/sim/lib/integrations/metadata-boundary.test.ts @@ -0,0 +1,31 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/deployment-config/integrations.json', () => { + throw new Error('Identity and authentication helpers must not load the rich integration catalog') +}) + +import { resolveIntegrationAvailability } from '@sim/deployment-config/integration-availability' +import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' +import { resolveOAuthServiceForSlug } from '@/lib/integrations/oauth-service' + +describe('integration metadata boundary', () => { + it('resolves deployment availability without marketing or operation data', () => { + expect(resolveIntegrationAvailability({})).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'github_v2', state: 'ready' })]) + ) + }) + + it('resolves OAuth and credential presentation without the rich catalog', () => { + expect(resolveOAuthServiceForSlug('google-sheets')?.providerId).toBe('google-sheets') + expect( + resolveCredentialDisplay({ + type: 'service_account', + displayName: 'Automation', + providerId: 'google-service-account', + }).integration + ).toMatchObject({ slug: 'google-drive', name: 'Google Drive', integrationType: 'documents' }) + }) +}) diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index 977d0845eab..36ac1922d7e 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -1,13 +1,12 @@ import type { ComponentType } from 'react' -import integrationsJson from '@sim/deployment-config/integrations.json' +import { + INTEGRATION_METADATA, + type IntegrationMetadata, +} from '@sim/deployment-config/integration-metadata' import { asServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' -import type { Integration } from '@/lib/integrations/types' import { getServiceConfigByServiceId } from '@/lib/oauth' import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' -const INTEGRATIONS_DATA: readonly Integration[] = - integrationsJson.integrations as readonly Integration[] - /** * Shape returned from resolving an integration to its OAuth service entry in * `OAUTH_PROVIDERS`. Carries the metadata needed to mount `ConnectOAuthModal` @@ -35,7 +34,7 @@ export interface OAuthServiceMatch { * `OAUTH_PROVIDERS`. */ export function resolveOAuthServiceForIntegration( - integration: Integration + integration: IntegrationMetadata ): OAuthServiceMatch | null { if (integration.authType !== 'oauth' || !integration.oauthServiceId) return null const service = getServiceConfigByServiceId(integration.oauthServiceId) @@ -55,7 +54,7 @@ export function resolveOAuthServiceForIntegration( * integration is not an OAuth integration. */ export function resolveOAuthServiceForSlug(slug: string): OAuthServiceMatch | null { - const integration = INTEGRATIONS_DATA.find((entry) => entry.slug === slug) + const integration = INTEGRATION_METADATA.find((entry) => entry.slug === slug) if (!integration) return null return resolveOAuthServiceForIntegration(integration) } @@ -97,7 +96,7 @@ export const CANONICAL_SERVICE_ACCOUNT_SLUGS: Readonly> = * entry, which is wasted work to repeat on each lookup. */ const SERVICE_ACCOUNT_INTEGRATIONS: readonly ServiceAccountIntegrationMatch[] = - INTEGRATIONS_DATA.flatMap((integration) => { + INTEGRATION_METADATA.flatMap((integration) => { const match = resolveOAuthServiceForIntegration(integration) if (!match?.serviceAccountProviderId) return [] return [ diff --git a/apps/sim/lib/integrations/types.ts b/apps/sim/lib/integrations/types.ts index b1df1a9d59b..3960011d2ec 100644 --- a/apps/sim/lib/integrations/types.ts +++ b/apps/sim/lib/integrations/types.ts @@ -4,11 +4,12 @@ * serialized projection of `BlockConfig` consumed by landing + workspace UIs. */ +import type { IntegrationMetadata } from '@sim/deployment-config/integration-metadata' import type { IntegrationLandingContent } from '@/app/(landing)/integrations/data/types' import type { BlockConfig, IntegrationTag } from '@/blocks/types' /** Normalized authentication mode surfaced in the catalog. */ -export type AuthType = 'oauth' | 'api-key' | 'none' +export type AuthType = IntegrationMetadata['authType'] /** Trigger entry enriched from the trigger registry at generation time. */ interface TriggerInfo { @@ -30,22 +31,16 @@ export interface FAQItem { } /** - * Catalog projection of a `BlockConfig`. Direct `BlockConfig` fields are - * referenced via indexed access so the two stay in lockstep; the remaining - * fields are generation-time enrichments (see `scripts/generate-docs.ts`). + * Public catalog entry: shared identity and authentication metadata plus + * descriptions, operations, triggers, and landing content. */ -export interface Integration { - type: BlockConfig['type'] - name: BlockConfig['name'] +export interface Integration extends IntegrationMetadata { description: BlockConfig['description'] longDescription: NonNullable category: BlockConfig['category'] integrationType: NonNullable - bgColor: BlockConfig['bgColor'] /** Tags sourced from the block's `*BlockMeta` export at generation time. */ tags?: IntegrationTag[] - /** URL slug derived from `name`. */ - slug: string /** Name of the React icon component (resolved client-side via `blockTypeToIconMap`). */ iconName: string /** Canonical docs URL for the integration. */ @@ -56,13 +51,6 @@ export interface Integration { /** Triggers enriched with details from the trigger registry. */ triggers: TriggerInfo[] triggerCount: number - /** Authentication mode inferred from `BlockConfig.subBlocks`. */ - authType: AuthType - /** - * OAuth service id from the block's `oauth-input` subBlock (a service key in - * `OAUTH_PROVIDERS`). Present exactly when `authType` is `'oauth'`. - */ - oauthServiceId?: string /** Hand-authored landing content baked in at generation time (see `landing-content.ts`). */ landingContent?: IntegrationLandingContent } diff --git a/apps/sim/lib/internal/slack/oauth.test.ts b/apps/sim/lib/internal/slack/oauth.test.ts index 9e4deeb48cf..a8d5d6cca6f 100644 --- a/apps/sim/lib/internal/slack/oauth.test.ts +++ b/apps/sim/lib/internal/slack/oauth.test.ts @@ -28,6 +28,19 @@ beforeEach(() => { fetchMock.mockReset().mockResolvedValue(Response.json(grant)) }) describe('Slack bot OAuth exchange', () => { + it('uses the registered default callback for Slack-initiated installs and discards personal grants', async () => { + fetchMock.mockResolvedValueOnce( + Response.json({ ...grant, authed_user: { access_token: 'personal-token' } }) + ) + expect( + await exchangeSlackBotAuthorization({ + clientId: 'client', + clientSecret: 'secret', + code: 'code', + }) + ).toEqual(grant) + expect(fetchMock.mock.calls[0][1].body.has('redirect_uri')).toBe(false) + }) it('exchanges a code with the same callback and client authentication', async () => { expect(await exchangeSlackBotAuthorization(input)).toEqual(grant) const [url, request] = fetchMock.mock.calls[0] diff --git a/apps/sim/lib/internal/slack/oauth.ts b/apps/sim/lib/internal/slack/oauth.ts index ced0697443a..8d1f1dcccb1 100644 --- a/apps/sim/lib/internal/slack/oauth.ts +++ b/apps/sim/lib/internal/slack/oauth.ts @@ -23,7 +23,7 @@ export async function exchangeSlackBotAuthorization(input: { clientId: string clientSecret: string code: string - redirectUri: string + redirectUri?: string }) { const response = await fetch('https://slack.com/api/oauth.v2.access', { method: 'POST', @@ -31,7 +31,10 @@ export async function exchangeSlackBotAuthorization(input: { Authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString('base64')}`, 'Content-Type': 'application/x-www-form-urlencoded', }, - body: new URLSearchParams({ code: input.code, redirect_uri: input.redirectUri }), + body: new URLSearchParams({ + code: input.code, + ...(input.redirectUri ? { redirect_uri: input.redirectUri } : {}), + }), signal: AbortSignal.timeout(10_000), }) const value = await readResponseJsonWithLimit(response, { diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 3d40f88e3e8..2316d269116 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -1875,6 +1875,10 @@ export async function listPendingInvitationsForEmail( return Promise.all(rows.map((row) => hydrateInvitation(row))) } +/** + * Pending grants for these workspaces. Terminal invitations were filtered on the client, so + * accepted and revoked rows — and the addresses on them — left the server for no reason. + */ export async function listInvitationsForWorkspaces(workspaceIds: string[]) { if (workspaceIds.length === 0) return [] return db @@ -1895,5 +1899,10 @@ export async function listInvitationsForWorkspaces(workspaceIds: string[]) { }) .from(invitationWorkspaceGrant) .innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId)) - .where(inArray(invitationWorkspaceGrant.workspaceId, workspaceIds)) + .where( + and( + inArray(invitationWorkspaceGrant.workspaceId, workspaceIds), + eq(invitation.status, 'pending') + ) + ) } diff --git a/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts b/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts index 6cbe10aeed6..e7f4b4afc69 100644 --- a/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts @@ -5,7 +5,6 @@ import { tmpdir } from 'node:os' import path from 'node:path' import type { DelegatedPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import { document, documentSecretProvenance, @@ -16,7 +15,6 @@ import { userTableRowSecretProvenance, userTableRows, workspace, - workspaceFileColumns, workspaceFiles, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' @@ -387,7 +385,7 @@ describe('execution archive durable provenance', () => { .set({ deletedAt: new Date() }) .where(eq(workspaceFiles.key, file.key)) } else { - await db.insert(withInsertColumns(workspaceFiles, workspaceFileColumns)).values({ + await db.insert(workspaceFiles).values({ id: generateId(), key: file.key, userId: ids.aliceId, diff --git a/apps/sim/lib/knowledge/__integration__/external-file-provenance.integration.ts b/apps/sim/lib/knowledge/__integration__/external-file-provenance.integration.ts index 0c0665585b9..df6b7ca6b22 100644 --- a/apps/sim/lib/knowledge/__integration__/external-file-provenance.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/external-file-provenance.integration.ts @@ -4,14 +4,7 @@ import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { db } from '@sim/db' -import { - knowledgeBase, - organization, - user, - workspace, - workspaceFileColumns, - workspaceFiles, -} from '@sim/db/schema' +import { knowledgeBase, organization, user, workspace, workspaceFiles } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' import { eq, inArray } from 'drizzle-orm' @@ -100,10 +93,7 @@ async function parse(ids: Fixture, filePath: string, headers?: Record { - const [record] = await db - .select(workspaceFileColumns) - .from(workspaceFiles) - .where(eq(workspaceFiles.key, file.key)) + const [record] = await db.select().from(workspaceFiles).where(eq(workspaceFiles.key, file.key)) if (!record || record.context !== 'execution') { throw new Error('Parser copy has no canonical execution metadata') } diff --git a/apps/sim/lib/knowledge/__integration__/gmail-member.integration.ts b/apps/sim/lib/knowledge/__integration__/gmail-member.integration.ts index a3abd7da45f..02bebeabca4 100644 --- a/apps/sim/lib/knowledge/__integration__/gmail-member.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/gmail-member.integration.ts @@ -114,6 +114,11 @@ describe('Gmail member ingestion and ACLs in PostgreSQL (provider fixtures)', () Response.json({ error: { code: 401, message: 'Invalid Credentials' } }, { status: 401 }) ) } + if (url.pathname === '/gmail/v1/users/me/profile') { + return Promise.resolve( + Response.json({ emailAddress: `mailbox-${member}@example.com`, historyId: '900' }) + ) + } if (url.pathname === '/gmail/v1/users/me/labels') { return Promise.resolve( Response.json({ labels: [{ id: 'INBOX', name: 'INBOX', type: 'system' }] }) @@ -411,7 +416,12 @@ describe('Gmail member ingestion and ACLs in PostgreSQL (provider fixtures)', () expect(new Set(own.map((row) => row.externalId))).toEqual( new Set([`member:${member.id}:shared-thread-id`, `member:${member.id}:private-${index}`]) ) - for (const row of own) expect(row.acl).toEqual([member.subjectToken]) + for (const row of own) { + expect(row.acl).toEqual([member.subjectToken]) + expect(new URL(row.sourceUrl!).searchParams.get('Email')).toBe( + `mailbox-${index}@example.com` + ) + } const observations = await db .select() .from(knowledgeDocumentObservation) @@ -424,6 +434,11 @@ describe('Gmail member ingestion and ACLs in PostgreSQL (provider fixtures)', () expect(new Set(results.map((row) => row.documentId))).toEqual( new Set(own.map((row) => row.id)) ) + for (const result of results) { + expect(new URL(result.sourceUrl!).searchParams.get('Email')).toBe( + `mailbox-${index}@example.com` + ) + } expect(results.map((row) => row.content).join('\n')).toContain( index === 0 ? 'Alice private reply' : 'Bob private reply' ) diff --git a/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts b/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts new file mode 100644 index 00000000000..dba85e74b3d --- /dev/null +++ b/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts @@ -0,0 +1,135 @@ +/** KB block retrieval against disposable PostgreSQL, using a workspace API-key identity. */ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { document, embedding, knowledgeBase, organization, user, workspace } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + createKnowledgeAclFixtureIds, + seedKnowledgeAclFixture, +} from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope' +import { retrieveKnowledgeSearch } from '@/lib/knowledge/search/queries' +import { embeddingVectorValues } from '@/lib/knowledge/vector-columns' + +describe('API-key KB block fan-out', () => { + const ids = createKnowledgeAclFixtureIds() + const bases = Array.from({ length: 18 }, () => ({ + id: generateId(), + visible: generateId(), + denied: generateId(), + excluded: generateId(), + })) + const principal: Principal = { + kind: 'workspace_api_key', + workspaceId: ids.workspaceId, + keyId: 'fixture-key', + } + const vector = [1, ...Array(1535).fill(0)] + const queryVector = { + vector: JSON.stringify(vector), + dimensions: 1536 as const, + model: 'text-embedding-3-small', + } + + beforeAll(async () => { + await seedKnowledgeAclFixture(ids, { connectorType: 'google_drive' }) + await db.insert(knowledgeBase).values( + bases.map((base, index) => ({ + id: base.id, + userId: ids.aliceId, + workspaceId: ids.workspaceId, + name: `KB block ${index}`, + })) + ) + await db.insert(document).values( + bases.flatMap((base) => + (['visible', 'denied', 'excluded'] as const).map((kind) => ({ + id: base[kind], + knowledgeBaseId: base.id, + filename: kind, + fileUrl: `https://fixture.invalid/${base[kind]}`, + fileSize: 12, + mimeType: 'text/plain', + processingStatus: 'completed', + acl: kind === 'denied' ? [`u:${ids.aliceId}@fixture.test`] : ['ws'], + userExcluded: kind === 'excluded', + })) + ) + ) + await db.insert(embedding).values( + bases.flatMap((base) => + (['visible', 'denied', 'excluded'] as const).map((kind) => ({ + id: generateId(), + documentId: base[kind], + knowledgeBaseId: base.id, + chunkIndex: 0, + chunkHash: base[kind], + content: `Fixture policy ${kind}`, + contentLength: 24, + tokenCount: 5, + startOffset: 0, + endOffset: 24, + tag1: 'policy', + ...embeddingVectorValues(1536, vector), + })) + ) + ) + }) + + afterAll(async () => { + await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) + await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + await db.$client.end() + }) + + it.each([false, true])( + 'completes 18 concurrent KB searches with access checks intact (tag filter: %s)', + async (withTags) => { + const previousDebug = db.$client.options.debug + const statements: string[] = [] + db.$client.options.debug = (_connection, query) => { + if (statements.length < 250) statements.push(query) + } + try { + const results = await Promise.all( + bases.map(async (base) => { + const accessProvider = createKnowledgeAccessProvider(principal, { + workspaceId: ids.workspaceId, + knowledgeBaseIds: [base.id], + }) + const access = await accessProvider.get() + expect(access.kind).toBe('workspace') + return retrieveKnowledgeSearch({ + knowledgeBaseIds: [base.id], + topK: 2, + access, + accessProvider, + searchMode: 'vector', + query: 'Find the fixture policy', + queryVector, + ...(withTags && { + structuredFilters: [ + { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'policy' }, + ], + }), + }) + }) + ) + for (const [index, result] of results.entries()) { + expect(result.retrieval).toEqual({ status: 'complete', timedOutLegs: [] }) + expect(result.rows.map((row) => row.documentId)).toEqual([bases[index].visible]) + expect(result.rows[0].knowledgeBaseId).toBe(bases[index].id) + expect(result.rows[0].distance).toBeCloseTo(0) + } + expect(statements.filter((query) => query.includes('statement_timeout'))).toHaveLength(36) + expect(statements.filter((query) => query.includes('+ 0'))).toHaveLength(18) + expect(statements.some((query) => query.includes('hnsw.iterative_scan'))).toBe(false) + } finally { + db.$client.options.debug = previousDebug + } + } + ) +}) diff --git a/apps/sim/lib/knowledge/__integration__/organization-mcp-search.integration.ts b/apps/sim/lib/knowledge/__integration__/organization-mcp-search.integration.ts index 0b7060df4c6..1f1c1808abd 100644 --- a/apps/sim/lib/knowledge/__integration__/organization-mcp-search.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/organization-mcp-search.integration.ts @@ -12,7 +12,6 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import { apiKey, document, @@ -26,8 +25,8 @@ import { oauthClient, oauthConsent, organization, - organizationColumns, organizationSearchIntegration, + organizationSearchMcpInvocation, rateLimitBucket, user, workspace, @@ -37,9 +36,19 @@ import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' import { and, eq, inArray } from 'drizzle-orm' import { NextRequest } from 'next/server' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -const fixtures = vi.hoisted(() => ({ storageRoot: '' })) +const fixtures = vi.hoisted(() => ({ + storageRoot: '', + afterResponse: [] as Array<() => Promise>, +})) +vi.mock('@/lib/core/utils/after-response', () => ({ + afterResponse: (task: () => Promise) => fixtures.afterResponse.push(task), +})) + +async function flushAfterResponse() { + for (const task of fixtures.afterResponse.splice(0)) await task() +} vi.mock('@/lib/uploads/core/setup.server', () => ({ get UPLOAD_DIR_SERVER() { return fixtures.storageRoot @@ -254,7 +263,7 @@ describe('organization Search MCP with real ingestion and current access', () => updatedAt: new Date(), })) ) - await db.insert(withInsertColumns(organization, organizationColumns)).values({ + await db.insert(organization).values({ id: otherOrganizationId, name: 'Other organization MCP fixture', slug: otherOrganizationId, @@ -403,6 +412,8 @@ describe('organization Search MCP with real ingestion and current access', () => bobOAuth = await connect(OAUTH_ACCESS_TOKEN_PREFIX + oauthTokens.bob, true) }) + afterEach(flushAfterResponse) + afterAll(async () => { await Promise.all(clients.map((client) => client.close())) await db.delete(oauthClient).where(eq(oauthClient.clientId, oauthClientId)) @@ -510,6 +521,74 @@ describe('organization Search MCP with real ingestion and current access', () => expect(await applicationSearch(bobPrincipal)).toEqual([]) }) + it('persists content-free per-client tool outcomes separately from search counters', async () => { + await db + .delete(organizationSearchMcpInvocation) + .where(eq(organizationSearchMcpInvocation.organizationId, organizationId)) + const clientName = 'MCP fixture client'.repeat(20) + await db + .update(oauthClient) + .set({ name: clientName }) + .where(eq(oauthClient.clientId, oauthClientId)) + try { + await aliceOAuth.listTools() + expect(fixtures.afterResponse).toHaveLength(0) + await search(aliceOAuth) + await value(aliceOAuth, 'read_document', { documentId }) + expect((await call(bob, 'read_document', { documentId })).isError).toBe(true) + expect(fixtures.afterResponse).toHaveLength(3) + await db + .update(oauthClient) + .set({ name: 'Renamed client' }) + .where(eq(oauthClient.clientId, oauthClientId)) + await flushAfterResponse() + const rows = await db + .select() + .from(organizationSearchMcpInvocation) + .where(eq(organizationSearchMcpInvocation.organizationId, organizationId)) + .orderBy(organizationSearchMcpInvocation.createdAt) + .limit(10) + expect(rows).toHaveLength(3) + expect(rows).toMatchObject([ + { + organizationId, + userId: aliceId, + authKind: 'oauth_access_token', + oauthClientId, + clientName: clientName.slice(0, 256), + toolName: 'search', + outcome: 'success', + }, + { + organizationId, + userId: aliceId, + authKind: 'oauth_access_token', + oauthClientId, + clientName: clientName.slice(0, 256), + toolName: 'read_document', + outcome: 'success', + }, + { + organizationId, + userId: bobId, + authKind: 'personal_api_key', + oauthClientId: null, + clientName: null, + toolName: 'read_document', + outcome: 'error', + }, + ]) + expect(rows.every((row) => row.durationMs >= 0)).toBe(true) + expect(JSON.stringify(rows)).not.toContain(documentId) + expect(JSON.stringify(rows)).not.toContain(oauthTokens.alice) + } finally { + await db + .update(oauthClient) + .set({ name: 'Search MCP OAuth fixture' }) + .where(eq(oauthClient.clientId, oauthClientId)) + } + }) + it('enforces current document and organization access on Search OAuth clients', async () => { expect((await aliceOAuth.listTools()).tools).toHaveLength(3) expect(await search(aliceOAuth)).toEqual(await search(alice)) diff --git a/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts b/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts index 5d03eab792b..0c89994ee4f 100644 --- a/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts @@ -9,6 +9,8 @@ import { embedding, knowledgeBase, knowledgeConnector, + knowledgeConnectorMember, + knowledgeDocumentObservation, member, organization, user, @@ -35,6 +37,7 @@ import { seedSearchReaderFixture } from '@/lib/knowledge/__integration__/seed-se import { createKnowledgeAclFixtureIds, seedKnowledgeAclFixture, + seedKnowledgeMemberFixture, } from '@/lib/knowledge/__integration__/seed-source-access-fixture' import { SearchBudget, @@ -51,7 +54,6 @@ vi.hoisted(() => { if (process.env.KNOWLEDGE_SEARCH_PERFORMANCE_TEST === 'true') { Object.assign(process.env, { OPENAI_API_KEY: 'isolated-embedding-http-fixture', - GEMINI_API_KEY: 'isolated-gemini-http-fixture', CONFLUENCE_CLIENT_ID: 'isolated-confluence-fixture-client', CONFLUENCE_CLIENT_SECRET: 'isolated-confluence-fixture-secret', }) @@ -60,10 +62,17 @@ vi.hoisted(() => { const externalFetch = globalThis.fetch const enabled = process.env.KNOWLEDGE_SEARCH_PERFORMANCE_TEST === 'true' +const batchSize = 1000 +const MIN_CHUNK_COUNT = 5000 const chunkCount = Number(process.env.KNOWLEDGE_SEARCH_PERFORMANCE_CHUNKS ?? 20_000) +const unrelatedChunkCount = Number( + process.env.KNOWLEDGE_SEARCH_PERFORMANCE_UNRELATED_CHUNKS ?? + Math.max(MIN_CHUNK_COUNT, Math.ceil(chunkCount / (2 * batchSize)) * batchSize) +) +const evictSharedBuffers = process.env.KNOWLEDGE_SEARCH_PERFORMANCE_EVICT_BUFFERS === 'true' const dimensions = 1536 +const candidateDimensions = 512 const chunksPerDocument = 4 -const batchSize = 1000 const logger = createLogger('SearchLatencyIntegration') const fixtureSchema = z.object({ aliceId: z.uuid(), @@ -81,7 +90,11 @@ function readFixtureReport(file: string) { /** Captured SQL plans include repeated high-dimensional query parameters. */ if (statSync(file).size > 64 * 1024 * 1024) throw new Error('Fixture report exceeds 64 MiB') return z - .object({ fixture: fixtureSchema, unrelatedFixture: fixtureSchema }) + .object({ + fixture: fixtureSchema, + unrelatedFixture: fixtureSchema, + method: z.object({ fixtureVersion: z.literal(2) }), + }) .parse(JSON.parse(readFileSync(file, 'utf8'))) } const reused = reuseFile ? readFixtureReport(reuseFile) : undefined @@ -90,7 +103,11 @@ const unrelated = reused?.unrelatedFixture ?? createKnowledgeAclFixtureIds() const organizationChatId = generateId() function topicVector(topic = 0) { const vector = Array.from({ length: dimensions }, (_, index) => - Math.sin((index + 1) * (topic + 1) * 12.9898) + Math.sin( + (((index * 137 + Math.floor(index / candidateDimensions) * 57) % candidateDimensions) + 1) * + (topic + 1) * + 12.9898 + ) ) const magnitude = Math.hypot(...vector) return vector.map((value) => value / magnitude) @@ -101,15 +118,23 @@ const report: Record = { fixture: ids, unrelatedFixture: unrelated, method: { + fixtureVersion: 2, chunkCount, + unrelatedChunkCount, dimensions, + candidateDimensions, chunksPerDocument, sql: 'Captured from the real Assistant tool; no hand-written search query', providers: 'Embedding and source-permission HTTP responses are controlled; internal search and authorization code is real', vectors: - 'Normalized topic clusters with deterministic dense noise; not semantic-quality evaluation', - cache: 'First and repeated samples; no claim of a cold operating-system cache', + 'Normalized 512-dimensional topic/noise geometry with permuted copies across 1536 dimensions; verifies prefix candidate ranking, not semantic embedding quality', + cache: evictSharedBuffers + ? 'Organization samples evict PostgreSQL shared buffers before each request; operating-system cache is not cleared' + : 'First and repeated samples; no claim of a cold operating-system cache', + layout: reused + ? 'Reused fixture; physical layout is inherited from its original report' + : 'Tenant batches interleaved; chunks permuted across document identities', }, } let capture = false @@ -127,8 +152,14 @@ interface CapturedQuery { interface ExplainNode { 'Node Type': string 'Actual Rows': number + 'Actual Loops': number + 'Plan Rows'?: number + 'Shared Hit Blocks'?: number + 'Shared Read Blocks'?: number 'Index Name'?: string 'Relation Name'?: string + 'Subplan Name'?: string + 'CTE Name'?: string Output?: string[] Plans?: ExplainNode[] } @@ -138,8 +169,14 @@ const explainNodeSchema: z.ZodType = z.lazy(() => .object({ 'Node Type': z.string(), 'Actual Rows': z.number(), + 'Actual Loops': z.number(), + 'Plan Rows': z.number().optional(), + 'Shared Hit Blocks': z.number().optional(), + 'Shared Read Blocks': z.number().optional(), 'Index Name': z.string().optional(), 'Relation Name': z.string().optional(), + 'Subplan Name': z.string().optional(), + 'CTE Name': z.string().optional(), Output: z.array(z.string()).optional(), Plans: z.array(explainNodeSchema).optional(), }) @@ -155,6 +192,56 @@ function assertCompactCandidates(node: ExplainNode) { for (const child of node.Plans ?? []) assertCompactCandidates(child) } +function explainNodes(node: ExplainNode): ExplainNode[] { + return [node, ...(node.Plans ?? []).flatMap(explainNodes)] +} + +/** Broad ranking must stop the ordered ANN scan instead of sorting every accessible chunk. */ +function assertIndexedCandidates(plan: ExplainNode, candidateLimit: number) { + const nodes = explainNodes(plan) + const initial = nodes.find((node) => node['Subplan Name'] === 'CTE initial_candidates') + expect(initial).toBeDefined() + const candidateNodes = explainNodes(initial!) + expect( + candidateNodes.some( + (node) => + node['Index Name'] === 'embedding_search_512_cosine_hnsw_idx' && node['Actual Loops'] > 0 + ) + ).toBe(true) + expect(candidateNodes.some((node) => node['Node Type'] === 'Sort')).toBe(false) + expect( + candidateNodes.some((node) => node['Index Name'] === 'embedding_search_document_lookup_idx') + ).toBe(false) + const filtered = nodes.find((node) => node['Subplan Name'] === 'CTE filtered_scores') + expect(filtered).toBeDefined() + expect(filtered!.Output).toHaveLength(3) + expect(filtered!.Output![2]).toContain('<=>') + if (initial!['Actual Rows'] >= candidateLimit) { + for (const node of nodes.filter( + (item) => + item['Subplan Name'] === 'CTE visible_search_documents' || + item['CTE Name'] === 'visible_search_documents' || + item['Subplan Name'] === 'CTE filtered_scores' || + item['CTE Name'] === 'filtered_scores' + )) { + expect(node['Actual Loops']).toBe(0) + } + } +} + +/** Small scopes must seek chunk metadata by document without reading the full vector projection. */ +function assertIndexedChunkProbe(node: ExplainNode): number { + let lookups = 0 + if (node['Relation Name'] === 'embedding_search') { + expect(['Index Scan', 'Index Only Scan']).toContain(node['Node Type']) + expect(node['Index Name']).toBe('embedding_search_document_lookup_idx') + expect((node.Output ?? []).join(' ')).not.toMatch(/(?:embedding_search\.)?(?:vector|binary)/) + lookups = node['Actual Loops'] + } + for (const child of node.Plans ?? []) lookups += assertIndexedChunkProbe(child) + return lookups +} + /** Keyword sort memory must scale with identities and scores, not the matched document text. */ function assertScalarKeywordSorts(node: ExplainNode) { if (node['Node Type'] === 'Sort') { @@ -168,12 +255,30 @@ function saveReport() { if (file) writeFileSync(file, JSON.stringify(report, null, 2), { mode: 0o600 }) } +/** Only the disposable fixture may evict shared buffers; the operating-system cache stays intact. */ +async function prepareOrganizationSample(label: string) { + if (!evictSharedBuffers) return + const [eviction] = await db.execute<{ buffers: number; evicted: number }>(sql` + WITH cached AS MATERIALIZED ( + SELECT bufferid FROM pg_buffercache + WHERE reldatabase = (SELECT oid FROM pg_database WHERE datname = current_database()) + ) SELECT count(*)::int AS buffers, + count(*) FILTER (WHERE pg_buffercache_evict(bufferid))::int AS evicted + FROM cached + `) + report[`${label}.sharedBufferEviction`] = eviction + saveReport() +} + const diagnosticSchema = z .object({ surface: z.enum(['dashboard', 'copilot']), outcome: z.enum(['success', 'partial']), elapsedMs: z.number(), vectorBudgetMs: z.number().positive(), + vectorCandidateDimensions: z.number().optional(), + vectorCandidateLimit: z.number().optional(), + vectorCandidateScan: z.enum(['planned', 'filtered']).optional(), retrievalStatus: z.enum(['complete', 'partial']), timedOutLegs: z.array(z.enum(['vector', 'keyword', 'tags'])), toolResultBytes: z.number().int().nonnegative().optional(), @@ -205,11 +310,12 @@ async function search( userId = ids.aliceId, query = 'Orion deployment', filters: WorkspaceSearchFilters = {}, - organizationScope = false + organizationScope = false, + topK = 15 ) { return resultSchema.parse( await searchWorkspaceServerTool.execute( - { query, topK: 15, ...filters }, + { query, topK, ...filters }, { userId, ...(organizationScope @@ -227,10 +333,15 @@ async function search( ) } -async function searchDashboard(query = 'Orion deployment') { +async function searchDashboard( + query = 'Orion deployment', + userId = ids.aliceId, + organizationScope = false, + topK = 15 +) { const authenticate = vi.spyOn(internalSessionAuth, 'authenticate').mockResolvedValue({ kind: 'session', - userId: ids.aliceId, + userId, sessionId: 'fixture-dashboard', }) try { @@ -239,9 +350,11 @@ async function searchDashboard(query = 'Orion deployment') { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - workspaceId: ids.workspaceId, + ...(organizationScope + ? { organizationId: ids.organizationId } + : { workspaceId: ids.workspaceId }), query, - topK: 15, + topK, }), }) ) @@ -267,7 +380,11 @@ function expectCompleteVectorSearch(diagnostics: z.infer ReturnType) { +async function sample( + label: string, + run: () => ReturnType, + options: { explain?: boolean } = {} +) { captured.length = 0 diagnosticLog?.mockClear() const start = performance.now() @@ -305,14 +422,30 @@ async function sample(label: string, run: () => ReturnType) { item.query.includes('FROM "embedding_keyword_search"')) && (item.query.includes('order by') || item.query.includes('limit') || + item.query.includes('CROSS JOIN LATERAL') || item.query.includes('WITH visible_search_documents') || item.query.includes('WITH scored_search_candidates') || item.query.includes('WITH visible_keyword_documents')) ) - const plans = [] - for (const query of searches) { + const plans: Array< + CapturedQuery & { + kind: 'keyword' | 'vector' | 'rerank' | 'probe' + plan: z.infer + } + > = [] + report[label] = { + milliseconds, + diagnostics, + queryCount: captured.length, + resultCount: result.data.results.length, + explainsDeferred: options.explain === false, + plans, + } + saveReport() + for (const query of options.explain === false ? [] : searches) { const plan = await db.$client.begin(async (tx) => { await tx.unsafe("SET LOCAL statement_timeout = '45s'") + await tx.unsafe('SET LOCAL jit = off') await tx.unsafe("SET LOCAL hnsw.iterative_scan = 'relaxed_order'") await tx.unsafe('SET LOCAL hnsw.max_scan_tuples = 20000') if ( @@ -329,9 +462,6 @@ async function sample(label: string, run: () => ReturnType) { ) }) const parsedPlan = explainSchema.parse(plan[0]['QUERY PLAN']) - if (query.query.includes('WITH visible_keyword_documents')) { - assertScalarKeywordSorts(parsedPlan[0].Plan) - } plans.push({ kind: query.query.includes('keyword_rank') ? 'keyword' @@ -346,15 +476,17 @@ async function sample(label: string, run: () => ReturnType) { parameters: query.parameters, plan: parsedPlan, }) + saveReport() + if (query.query.includes('WITH visible_search_documents')) { + expect(query.query).toContain('"embedding_search"."vector_512"') + expect(diagnostics.vectorCandidateDimensions).toBe(candidateDimensions) + expect(diagnostics.vectorCandidateLimit).toBeGreaterThan(0) + assertIndexedCandidates(parsedPlan[0].Plan, diagnostics.vectorCandidateLimit!) + } + if (query.query.includes('WITH visible_keyword_documents')) { + assertScalarKeywordSorts(parsedPlan[0].Plan) + } } - report[label] = { - milliseconds, - diagnostics, - queryCount: captured.length, - resultCount: result.data.results.length, - plans, - } - saveReport() logger.info(label, { milliseconds, queryCount: captured.length, @@ -366,13 +498,16 @@ async function sample(label: string, run: () => ReturnType) { describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpus', () => { beforeAll(async () => { if ( - !Number.isInteger(chunkCount) || - chunkCount < 10_000 || - chunkCount > 200_000 || - chunkCount % batchSize !== 0 + [chunkCount, unrelatedChunkCount].some( + (count) => + !Number.isInteger(count) || + count < MIN_CHUNK_COUNT || + count > 200_000 || + count % batchSize !== 0 + ) ) throw new Error( - 'KNOWLEDGE_SEARCH_PERFORMANCE_CHUNKS must be a multiple of 1000 from 10000 to 200000' + 'Search performance chunk counts must be multiples of 1000 from 5000 to 200000' ) vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input) @@ -385,33 +520,14 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu ? new Response(null, { status: 403 }) : Response.json({ type: 'known', accountId: ids.aliceId }) } - if ( - url === - 'https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents' - ) { - const body = z - .object({ - requests: z - .array( - z.object({ - content: z.object({ parts: z.array(z.object({ text: z.string() })).length(1) }), - }) - ) - .length(1), - }) - .parse(JSON.parse(String(init?.body))) - embeddingCalls++ - const text = body.requests[0].content.parts[0].text - const topic = Number(/^Topic (\d+) deployment$/.exec(text)?.[1] ?? 0) - return Response.json({ - embeddings: [{ values: topicVector(topic) }], - usageMetadata: { promptTokenCount: 4 }, - }) - } if (url !== 'https://api.openai.com/v1/embeddings') throw new Error(`Unexpected outbound request in search fixture: ${new URL(url).origin}`) const body = z - .object({ input: z.array(z.string()).length(1), encoding_format: z.literal('base64') }) + .object({ + input: z.array(z.string()).length(1), + encoding_format: z.literal('base64'), + model: z.literal('text-embedding-3-small'), + }) .parse(JSON.parse(String(init?.body))) embeddingCalls += body.input.length const bytes = Buffer.alloc(dimensions * 4) @@ -437,14 +553,14 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu const [size] = await db.execute<{ count: number }>( sql`SELECT count(*)::int AS count FROM embedding WHERE knowledge_base_id = ${fixture.knowledgeBaseId}` ) - expect(size.count).toBe(fixture === ids ? chunkCount : chunkCount / 2) + expect(size.count).toBe(fixture === ids ? chunkCount : unrelatedChunkCount) } await db .update(knowledgeBase) .set({ workspaceId: ids.workspaceId, organizationId: null, - embeddingModel: 'gemini-embedding-001', + embeddingModel: 'text-embedding-3-small', }) .where(eq(knowledgeBase.id, ids.knowledgeBaseId)) await db @@ -470,10 +586,10 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu } else { await seedKnowledgeAclFixture(ids, { connectorType: 'google_drive' }) await seedKnowledgeAclFixture(unrelated, { connectorType: 'google_drive' }) - /** These arbitrary dense vectors are not trained for prefix shortening. */ + /** The controlled geometry preserves prefix distances for the production 512-dimensional path. */ await db .update(knowledgeBase) - .set({ embeddingModel: 'gemini-embedding-001' }) + .set({ embeddingModel: 'text-embedding-3-small' }) .where(inArray(knowledgeBase.id, [ids.knowledgeBaseId, unrelated.knowledgeBaseId])) await db .update(knowledgeBase) @@ -487,7 +603,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu for (const index of indexes) await db.execute(sql`DROP INDEX ${sql.identifier(index.indexname)}`) for (const fixture of [ids, unrelated]) { - const count = fixture === ids ? chunkCount : chunkCount / 2 + const count = fixture === ids ? chunkCount : unrelatedChunkCount for (let first = 0; first < count / chunksPerDocument; first += batchSize) { const last = Math.min(first + batchSize, count / chunksPerDocument) - 1 await db.execute(sql`INSERT INTO document @@ -497,7 +613,11 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu ARRAY[${`u:${fixture.aliceId}@fixture.test`}]::text[], statement_timestamp() FROM generate_series(${first}::int, ${last}::int) n`) } - for (let first = 0; first < count; first += batchSize) { + } + for (let first = 0; first < Math.max(chunkCount, unrelatedChunkCount); first += batchSize) { + for (const fixture of [ids, unrelated]) { + const count = fixture === ids ? chunkCount : unrelatedChunkCount + if (first >= count) continue const last = Math.min(first + batchSize, count) - 1 await db.transaction(async (tx) => { await tx.execute(sql`SET LOCAL jit = off`) @@ -510,22 +630,37 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu 3000, 750, 0, 3000, l2_normalize(ARRAY(SELECT (sin(coordinate * (n % 32 + 1) * 12.9898) + 0.25 * sin(n::double precision * coordinate * 12.9898 + coordinate * 78.233))::real - FROM generate_series(1, ${dimensions}) coordinate)::vector(1536)) - FROM generate_series(${first}::int, ${last}::int) n`) + FROM ( + SELECT (((position - 1) * 137 + ((position - 1) / ${candidateDimensions}) * 57) + % ${candidateDimensions}) + 1 AS coordinate + FROM generate_series(1, ${dimensions}) position + ) coordinates)::vector(1536)) + FROM ( + SELECT (ordinal * 7919) % ${count} AS n + FROM generate_series(${first}::int, ${last}::int) ordinal + ) shuffled`) }) } - logger.info('Synthetic corpus loaded', { chunks: count }) } + logger.info('Synthetic corpora loaded', { chunkCount, unrelatedChunkCount }) for (const index of indexes) await db.execute(sql.raw(index.indexdef)) } await db.execute(sql`ANALYZE document`) await db.execute(sql`ANALYZE embedding`) await db.execute(sql`ANALYZE embedding_search`) await db.execute(sql`ANALYZE embedding_keyword_search`) + if (evictSharedBuffers) await db.execute(sql`CREATE EXTENSION IF NOT EXISTS pg_buffercache`) report.server = ( await db.execute(sql`SELECT version(), current_setting('work_mem') AS work_mem, (SELECT extversion FROM pg_extension WHERE extname = 'vector') AS pgvector`) )[0] + report.relations = await db.execute(sql` + SELECT relname, pg_relation_size(oid) AS bytes + FROM pg_class + WHERE relname IN ('embedding', 'embedding_search', 'document') + OR relname LIKE 'embedding_search%hnsw_idx' + ORDER BY relname + `) db.$client.options.debug = (_connection, query, parameters) => { if (capture && captured.length < 300) captured.push({ query, parameters: [...parameters] }) } @@ -565,6 +700,19 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu expect(settings.timeout).toBe('0') }) + it('disables compilation only inside deadline-bound search transactions', async () => { + const [before] = await db.execute<{ jit: string }>(sql`SELECT current_setting('jit') AS jit`) + for (const leg of ['vector', 'keyword', 'tags'] as const) { + const budget = new SearchBudget(leg, performance.now() + 2000) + const [inside] = await budget.query(`${leg}.sql`, (executor) => + executor.execute<{ jit: string }>(sql`SELECT current_setting('jit') AS jit`) + ) + expect(inside.jit).toBe('off') + } + const [settings] = await db.execute<{ jit: string }>(sql`SELECT current_setting('jit') AS jit`) + expect(settings.jit).toBe(before.jit) + }) + it('expires waiting for a saturated pool without executing abandoned work', async () => { let release!: () => void const released = new Promise((resolve) => { @@ -834,22 +982,31 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu }, 180_000) it('ranks a small permission scope by its bounded IDs without a corpus-wide vector probe', async () => { - const documentIds = [0, 8, 16].map((index) => `${ids.workspaceId}-doc-${index}`) + const lastTopicDocument = Math.floor((chunkCount / chunksPerDocument - 1) / 8) * 8 + const documentIds = [0, 8, 16].map( + (offset) => `${ids.workspaceId}-doc-${lastTopicDocument - offset}` + ) await db .update(document) .set({ acl: [`u:${ids.aliceId}@fixture.test`, `u:${ids.bobId}@fixture.test`] }) .where(inArray(document.id, documentIds)) try { - const { result, plans } = await sample('small-scope', () => search(ids.bobId)) - expect(result.data.results.length).toBeGreaterThan(0) - expect(result.data.results.every((row) => documentIds.includes(row.documentId))).toBe(true) - const probe = plans.filter((plan) => plan.kind === 'probe') - expect(probe).toHaveLength(1) - expect(probe[0].query).not.toContain('<=>') - expect(probe[0].plan[0].Plan['Actual Rows']).toBe(12) - const vector = plans.filter((plan) => plan.kind === 'rerank') - expect(vector).toHaveLength(1) - expect(vector[0].query).toContain('"embedding"."id" in') + for (const surface of ['copilot', 'dashboard'] as const) { + const { result, plans, diagnostics } = await sample(`small-scope.${surface}`, () => + surface === 'copilot' ? search(ids.bobId) : searchDashboard('Orion deployment', ids.bobId) + ) + expectCompleteVectorSearch(diagnostics) + expect(result.data.results.length).toBeGreaterThan(0) + expect(result.data.results.every((row) => documentIds.includes(row.documentId))).toBe(true) + const probe = plans.filter((plan) => plan.kind === 'probe') + expect(probe).toHaveLength(1) + expect(probe[0].query).not.toContain('<=>') + expect(probe[0].plan[0].Plan['Actual Rows']).toBe(12) + expect(assertIndexedChunkProbe(probe[0].plan[0].Plan)).toBe(documentIds.length) + const vector = plans.filter((plan) => plan.kind === 'rerank') + expect(vector).toHaveLength(1) + expect(vector[0].query).toContain('"embedding"."id" in') + } } finally { await db .update(document) @@ -858,6 +1015,141 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu } }, 180_000) + it.each([200, 396, 400, 1000, 2000])( + 'keeps a selective scope of %s chunks within both retrieval budgets', + async (count) => { + const documentCount = count / chunksPerDocument + const documentIds = Array.from( + { length: documentCount }, + (_, index) => `${ids.workspaceId}-doc-${chunkCount / chunksPerDocument - 1 - index}` + ) + await db + .update(document) + .set({ acl: [`u:${ids.aliceId}@fixture.test`, `u:${ids.bobId}@fixture.test`] }) + .where(inArray(document.id, documentIds)) + try { + for (const surface of ['copilot', 'dashboard'] as const) { + const { result, plans, diagnostics } = await sample( + `selective-${count}.${surface}`, + () => + surface === 'copilot' + ? search(ids.bobId) + : searchDashboard('Orion deployment', ids.bobId) + ) + expectCompleteVectorSearch(diagnostics) + expect(result.data.results.length).toBeGreaterThan(0) + expect(result.data.results.every((row) => documentIds.includes(row.documentId))).toBe( + true + ) + const probe = plans.find((plan) => plan.kind === 'probe')! + expect(probe.plan[0].Plan['Actual Rows']).toBe(Math.min(count, 400)) + expect(assertIndexedChunkProbe(probe.plan[0].Plan)).toBe(Math.min(documentCount, 100)) + expect(plans.filter((plan) => plan.kind === 'vector')).toHaveLength(count < 400 ? 0 : 1) + if (count > 400) { + const rerank = plans.find((plan) => plan.kind === 'rerank')! + const actual = await db.$client.unsafe(rerank.query, rerank.parameters).values() + const expected = await db.execute<{ id: string }>(sql`SELECT id FROM embedding + WHERE knowledge_base_id = ${ids.knowledgeBaseId} AND enabled + AND document_id IN (${sql.join( + documentIds.map((id) => sql`${id}`), + sql`, ` + )}) + ORDER BY (embedding <=> ${JSON.stringify(queryVector)}::vector) + 0, id + LIMIT ${actual.length}`) + const expectedIds = new Set(expected.map(({ id }) => id)) + const recall = actual.filter(([id]) => expectedIds.has(id)).length / expected.length + expect(recall).toBeGreaterThanOrEqual(0.95) + report[`recall.selective-${count}.${surface}`] = { + neighbors: expected.length, + recall, + candidateScan: diagnostics.vectorCandidateScan, + } + saveReport() + } + } + } finally { + await db + .update(document) + .set({ acl: [`u:${ids.aliceId}@fixture.test`] }) + .where(inArray(document.id, documentIds)) + } + }, + 180_000 + ) + + it('bounds member-observation searches and rejects suspended readers with current ACL checks', async () => { + const fixture = await seedKnowledgeMemberFixture(ids) + const [alice, bob] = fixture.members + const lastTopicDocument = Math.floor((chunkCount / chunksPerDocument - 1) / 8) * 8 + const documentIds = [0, 8, 16].map( + (offset) => `${ids.workspaceId}-doc-${lastTopicDocument - offset}` + ) + try { + await db + .update(document) + .set({ connectorId: fixture.connectorId, acl: [alice.subjectToken] }) + .where(eq(document.knowledgeBaseId, ids.knowledgeBaseId)) + await db.execute(sql`INSERT INTO knowledge_document_observation + (document_id, member_id, run_id) + SELECT id, ${alice.id}, ${fixture.runId} FROM document + WHERE knowledge_base_id = ${ids.knowledgeBaseId}`) + await db.insert(knowledgeDocumentObservation).values( + documentIds.map((documentId) => ({ + documentId, + memberId: bob.id, + runId: fixture.runId, + })) + ) + await db + .update(document) + .set({ acl: [alice.subjectToken, bob.subjectToken] }) + .where(inArray(document.id, documentIds)) + await db.execute(sql`ANALYZE document`) + await db.execute(sql`ANALYZE knowledge_document_observation`) + for (const surface of ['copilot', 'dashboard'] as const) { + const broad = await sample(`member-broad.${surface}`, () => + surface === 'copilot' ? search() : searchDashboard() + ) + expectCompleteVectorSearch(broad.diagnostics) + expect(broad.result.data.results).toHaveLength(15) + const broadProbe = broad.plans.find((plan) => plan.kind === 'probe')! + expect(broadProbe.plan[0].Plan['Actual Rows']).toBe(400) + expect(assertIndexedChunkProbe(broadProbe.plan[0].Plan)).toBe(400 / chunksPerDocument) + const { result, plans, diagnostics } = await sample(`member-scope.${surface}`, () => + surface === 'copilot' ? search(ids.bobId) : searchDashboard('Orion deployment', ids.bobId) + ) + expectCompleteVectorSearch(diagnostics) + expect(result.data.results.length).toBeGreaterThan(0) + expect(result.data.results.every((row) => documentIds.includes(row.documentId))).toBe(true) + const probe = plans.find((plan) => plan.kind === 'probe')! + expect(probe).toBeDefined() + expect(probe.query).toContain('knowledge_document_observation') + expect(assertIndexedChunkProbe(probe.plan[0].Plan)).toBe(documentIds.length) + } + await db + .update(knowledgeConnectorMember) + .set({ status: 'suspended' }) + .where(eq(knowledgeConnectorMember.id, bob.id)) + const denied = await sample('member-scope.suspended', () => search(ids.bobId)) + expectCompleteVectorSearch(denied.diagnostics) + expect(denied.result.data.results).toEqual([]) + } finally { + await db + .update(document) + .set({ connectorId: ids.connectorId, acl: [`u:${ids.aliceId}@fixture.test`] }) + .where(eq(document.knowledgeBaseId, ids.knowledgeBaseId)) + await db.delete(knowledgeConnector).where(eq(knowledgeConnector.id, fixture.connectorId)) + await db.delete(credential).where( + inArray( + credential.id, + fixture.members.map((member) => member.credentialId) + ) + ) + await db.delete(credentialGroup).where(eq(credentialGroup.id, fixture.groupId)) + await db.execute(sql`ANALYZE document`) + } + }, 180_000) + it('applies selective document scope and exclusion before ranking', async () => { const documentIds = [0, 8, 16, 24, 32].map((index) => `${ids.workspaceId}-doc-${index}`) await db.update(document).set({ userExcluded: true }).where(eq(document.id, documentIds[0])) @@ -894,7 +1186,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu it('runs two independent Assistant searches concurrently', async () => { diagnosticLog?.mockClear() const start = performance.now() - const results = await Promise.all([search(), search(ids.aliceId, 'Engineering operations')]) + const results = await Promise.all([search(), search(ids.aliceId, 'Topic 11 deployment')]) const completed = diagnosticLog!.mock.calls .filter(([message]) => message === 'Knowledge search completed') .map(([, metadata]) => diagnosticSchema.parse(metadata)) @@ -927,7 +1219,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu expect(restored.result.data.results).toHaveLength(15) }, 180_000) - it('uses the same indexed retrieval through a persisted private organization Assistant chat', async () => { + it('keeps organization searches complete with stale ACL estimates and concurrent requests', async () => { await db.insert(member).values({ id: generateId(), organizationId: ids.organizationId, @@ -948,17 +1240,72 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu .update(knowledgeConnector) .set({ connectorType: 'google_drive', credentialId: null, sourceConfig: {} }) .where(eq(knowledgeConnector.id, ids.connectorId)) - await db.execute( - sql`UPDATE document SET acl = ARRAY[${`u:${ids.aliceId}@fixture.test`}] WHERE knowledge_base_id = ${ids.knowledgeBaseId}` - ) - await db.execute(sql`ANALYZE document`) - const { result } = await sample('organization', () => - search(ids.aliceId, 'Orion deployment', {}, true) - ) - expect(result.data.results).toHaveLength(15) - expect(result.data.results.every((row) => row.knowledgeBaseId === ids.knowledgeBaseId)).toBe( - true - ) + /** Keep the deliberate tenfold visibility underestimate until the measured requests finish. */ + await db.execute(sql`ALTER TABLE document SET (autovacuum_enabled = false)`) + try { + await db.execute(sql`UPDATE document + SET acl = ARRAY[CASE WHEN external_id::int % 10 = 0 + THEN ${`u:${ids.aliceId}@fixture.test`} ELSE ${`u:${ids.bobId}@fixture.test`} END] + WHERE knowledge_base_id = ${ids.knowledgeBaseId}`) + await db.execute(sql`ANALYZE document`) + await db.execute(sql`UPDATE document SET acl = ARRAY[${`u:${ids.aliceId}@fixture.test`}] + WHERE knowledge_base_id = ${ids.knowledgeBaseId}`) + report.organizationVisibility = { + analyzedVisibleDocuments: chunkCount / chunksPerDocument / 10, + actualVisibleDocuments: chunkCount / chunksPerDocument, + unrelatedChunks: unrelatedChunkCount, + } + /** Capture latency samples before EXPLAIN ANALYZE can warm the candidate paths. */ + for (const surface of ['dashboard', 'copilot'] as const) { + const label = `organization.${surface}` + await prepareOrganizationSample(label) + const { result, diagnostics } = await sample( + label, + () => + surface === 'dashboard' + ? searchDashboard('Orion deployment', ids.aliceId, true, 20) + : search(ids.aliceId, 'Orion deployment', {}, true, 20), + { explain: false } + ) + expectCompleteVectorSearch(diagnostics) + expect(result.data.results).toHaveLength(20) + expect( + result.data.results.every((row) => row.knowledgeBaseId === ids.knowledgeBaseId) + ).toBe(true) + } + await prepareOrganizationSample('organization.concurrent') + diagnosticLog?.mockClear() + const started = performance.now() + const results = await Promise.all([ + search(ids.aliceId, 'Orion deployment', {}, true, 20), + search(ids.aliceId, 'Topic 11 deployment', {}, true, 20), + ]) + const diagnostics = diagnosticLog!.mock.calls + .filter(([message]) => message === 'Knowledge search completed') + .map(([, metadata]) => diagnosticSchema.parse(metadata)) + report['organization.concurrent'] = { + milliseconds: performance.now() - started, + resultCounts: results.map((result) => result.data.results.length), + diagnostics, + } + saveReport() + expect(diagnostics).toHaveLength(2) + for (const item of diagnostics) expectCompleteVectorSearch(item) + for (const result of results) { + expect(result.data.results).toHaveLength(20) + expect( + result.data.results.every((row) => row.knowledgeBaseId === ids.knowledgeBaseId) + ).toBe(true) + } + const planned = await sample('organization.plans', () => + search(ids.aliceId, 'Orion deployment', {}, true, 20) + ) + expectCompleteVectorSearch(planned.diagnostics) + expect(planned.plans.filter((plan) => plan.kind === 'vector')).toHaveLength(1) + } finally { + await db.execute(sql`ALTER TABLE document RESET (autovacuum_enabled)`) + await db.execute(sql`ANALYZE document`) + } }, 180_000) /** Opt in with local Sim and Go URLs; uses the real configured provider, billing adapter, and async resume protocol. */ it.skipIf(!process.env.KNOWLEDGE_SEARCH_ASSISTANT_URL)( diff --git a/apps/sim/lib/knowledge/__integration__/seed-source-access-fixture.ts b/apps/sim/lib/knowledge/__integration__/seed-source-access-fixture.ts index 22e90269614..3c0749defc0 100644 --- a/apps/sim/lib/knowledge/__integration__/seed-source-access-fixture.ts +++ b/apps/sim/lib/knowledge/__integration__/seed-source-access-fixture.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto' import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import { credential, credentialGroup, @@ -12,7 +11,6 @@ import { knowledgeExternalGroup, knowledgeExternalGroupMember, organization, - organizationColumns, permissions, user, workspace, @@ -71,7 +69,7 @@ export async function seedKnowledgeAclFixture( updatedAt: now, }, ]) - await db.insert(withInsertColumns(organization, organizationColumns)).values({ + await db.insert(organization).values({ id: ids.organizationId, name: 'ACL integration organization', slug: ids.organizationId, diff --git a/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts b/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts index 91e6301593a..740cfa9fc69 100644 --- a/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts @@ -1,12 +1,10 @@ /** Exercises real PostgreSQL locks and constraints using only isolated, explicitly cleaned fixtures. */ import type { OrganizationDelegatedPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import { copilotChats, credential, organization, - organizationColumns, outboxEvent, slackSearchInstallation, slackSearchTurn, @@ -74,7 +72,7 @@ describe('durable Slack Search turns in PostgreSQL', () => { })) ) await db - .insert(withInsertColumns(organization, organizationColumns)) + .insert(organization) .values({ id: organizationId, name: 'Slack queue fixture', slug: organizationId }) await db.insert(credential).values({ id: credentialId, diff --git a/apps/sim/lib/knowledge/__integration__/upload-read-provenance.integration.ts b/apps/sim/lib/knowledge/__integration__/upload-read-provenance.integration.ts index 381c32a1a2b..54db3536ac7 100644 --- a/apps/sim/lib/knowledge/__integration__/upload-read-provenance.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/upload-read-provenance.integration.ts @@ -10,7 +10,6 @@ import { organization, user, workspace, - workspaceFileColumns, workspaceFiles, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' @@ -79,10 +78,7 @@ async function seedUpload(provenance?: WorkspaceFileSecretProvenance) { 'text/plain', CONTENT.length ) - const [file] = await db - .select(workspaceFileColumns) - .from(workspaceFiles) - .where(eq(workspaceFiles.key, key)) + const [file] = await db.select().from(workspaceFiles).where(eq(workspaceFiles.key, key)) if (provenance) { await db.transaction((tx) => replaceWorkspaceFileSecretProvenanceInTx(tx, file.id, file.contentUpdatedAt, provenance) diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index b57dbaa3c20..918a76321fe 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -107,6 +107,15 @@ vi.mock('@/lib/credentials/application/organization-credentials', () => ({ })) vi.mock('@/lib/oauth/credential-service', () => ({ + ServiceAccountTokenError: class extends Error { + constructor( + readonly statusCode: number, + readonly errorDescription: string, + readonly errorCode?: string + ) { + super(errorDescription) + } + }, resolveCredentialTokenBundle: mocks.resolveTokenBundle, resolveOAuthAccountId: vi.fn(async () => null), getServiceAccountToken: vi.fn(), @@ -145,6 +154,7 @@ vi.mock('@/connectors/registry.server', () => ({ 'https://www.googleapis.com/auth/admin.directory.group.readonly', 'https://www.googleapis.com/auth/admin.directory.domain.readonly', ], + serviceAccountDelegationScopes: ['https://www.googleapis.com/auth/drive.readonly'], serviceAccountSubjectFieldId: 'adminEmail', }, validateConfig: mocks.validateConnectorConfig, @@ -167,11 +177,20 @@ import { updateKnowledgeConnectorDocuments, validateConnectorSourceConfig, } from '@/lib/knowledge/application/connectors' +import type { ConnectorAccessToken } from '@/lib/knowledge/connectors/access-token' import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_SEARCH_LENGTH } from '@/lib/knowledge/constants' +import { classifyKnowledgeFailure } from '@/lib/knowledge/orchestration/shared' +import { + getServiceAccountToken, + resolveOAuthAccountId, + ServiceAccountTokenError, +} from '@/lib/oauth/credential-service' import * as githubInstallation from '@/lib/oauth/github-installation' import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { confluenceConnectorMeta } from '@/connectors/confluence/meta' +import { gmailConnectorMeta } from '@/connectors/gmail/meta' +import { googleCalendarConnectorMeta } from '@/connectors/google-calendar/meta' import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta' const crossWorkspaceContext = { @@ -1624,6 +1643,240 @@ describe('organization connector credential authorization', () => { expect(mocks.resolveTokenBundle).not.toHaveBeenCalled() }) + it.each([googleDriveConnectorMeta, gmailConnectorMeta, googleCalendarConnectorMeta])( + 'projects $name token rejections as safe setup errors', + async ({ auth }) => { + mocks.resolveTokenBundle.mockRejectedValueOnce( + new ServiceAccountTokenError(401, 'private provider payload', 'unauthorized_client') + ) + const error = await resolveConnectorCredentialAccessToken({ ...input, auth }).catch( + (error: unknown) => error + ) + expect(error).toBeInstanceOf(OrchestrationError) + expect(internalOrchestrationErrorPolicy.project(error)).toMatchObject({ + status: 400, + body: { error: expect.stringContaining('(unauthorized_client)') }, + }) + expect((error as Error).message).toContain('numeric client ID') + expect((error as Error).message).toContain( + "exact domain-wide delegation scopes in this connector's service-account setup section" + ) + expect((error as Error).message).not.toContain('private provider payload') + expect(mocks.authorizeOrganizationCredentialUse).toHaveBeenCalledOnce() + } + ) + + it.each([ + [400, 'invalid_grant', 'JSON key'], + [ + 400, + 'invalid_scope', + "exact domain-wide delegation scopes in this connector's service-account setup section", + ], + [403, 'access_denied', 'API access policies'], + ])('classifies Google %s %s without exposing provider text', async (status, code, guidance) => { + mocks.resolveTokenBundle.mockRejectedValueOnce( + new ServiceAccountTokenError(status, 'private provider payload', code) + ) + const error = await resolveConnectorCredentialAccessToken(input).catch( + (error: unknown) => error + ) + expect(error).toMatchObject({ code: 'validation', message: expect.stringContaining(guidance) }) + expect((error as Error).message).not.toContain('private provider payload') + }) + + it.each([googleDriveConnectorMeta, gmailConnectorMeta, googleCalendarConnectorMeta])( + 'maps $name delegated token failures after directory authorization succeeds', + async ({ auth }) => { + vi.mocked(resolveOAuthAccountId).mockResolvedValueOnce({ + accountId: '', + usedCredentialTable: true, + credentialId: credential.id, + credentialType: 'service_account', + providerId: credential.providerId, + }) + vi.mocked(getServiceAccountToken).mockRejectedValueOnce( + new ServiceAccountTokenError(401, 'private delegated response', 'unauthorized_client') + ) + const resolved = await resolveConnectorCredentialAccessToken({ ...input, auth }) + expect(resolved?.accessToken).toBe('organization-token') + expect(getServiceAccountToken).not.toHaveBeenCalled() + if (!resolved?.getDelegatedAccessToken) throw new Error('Expected delegated token resolver') + await expect(resolved.getDelegatedAccessToken('member@example.com')).rejects.toMatchObject({ + code: 'validation', + message: expect.stringContaining('(unauthorized_client)'), + }) + expect(getServiceAccountToken).toHaveBeenCalledWith( + credential.id, + auth.mode === 'oauth' ? auth.serviceAccountDelegationScopes : undefined, + 'member@example.com' + ) + } + ) + + it('preserves successful delegated token reads and unexpected failures', async () => { + vi.mocked(resolveOAuthAccountId).mockResolvedValueOnce({ + accountId: '', + usedCredentialTable: true, + credentialId: credential.id, + credentialType: 'service_account', + providerId: credential.providerId, + }) + const resolved = await resolveConnectorCredentialAccessToken(input) + if (!resolved?.getDelegatedAccessToken) throw new Error('Expected delegated token resolver') + vi.mocked(getServiceAccountToken).mockResolvedValueOnce('delegated-token') + await expect(resolved.getDelegatedAccessToken('member@example.com')).resolves.toBe( + 'delegated-token' + ) + for (const error of [ + new ServiceAccountTokenError(401, 'private delegated response', 'unknown-code'), + new ServiceAccountTokenError(503, 'private delegated response', 'unauthorized_client'), + new TypeError('Network request failed'), + ]) { + vi.mocked(getServiceAccountToken).mockRejectedValueOnce(error) + await expect(resolved.getDelegatedAccessToken('member@example.com')).rejects.toBe(error) + } + }) + + it('passes safe delegated errors to configuration validation', async () => { + vi.mocked(resolveOAuthAccountId).mockResolvedValueOnce({ + accountId: '', + usedCredentialTable: true, + credentialId: credential.id, + credentialType: 'service_account', + providerId: credential.providerId, + }) + vi.mocked(getServiceAccountToken).mockRejectedValueOnce( + new ServiceAccountTokenError(401, 'private delegated response', 'unauthorized_client') + ) + mocks.validateConnectorConfig.mockImplementationOnce( + async (_token: string, _config: unknown, context: ConnectorAccessToken) => { + if (!context.getDelegatedAccessToken) throw new Error('Expected delegated token resolver') + try { + await context.getDelegatedAccessToken('member@example.com') + return { valid: true } + } catch (error) { + if (!(error instanceof Error)) throw error + return { valid: false, error: error.message } + } + } + ) + const rejection = await validateConnectorSourceConfig({ + principal, + organizationId: 'org', + actingUserId: principal.userId, + requestId: 'request', + sourceConfig: input.sourceConfig, + connector: { + connectorType: 'google_drive', + credentialId: credential.id, + encryptedApiKey: null, + accessMode: 'admin', + } as Parameters[0]['connector'], + }) + expect(rejection).toMatchObject({ + errorCode: 'validation', + message: expect.stringContaining('(unauthorized_client)'), + }) + expect(rejection?.message).not.toContain('private delegated response') + }) + + it.each([400, 401, 403])( + 'preserves unrecognized Google %s responses instead of assuming a configuration error', + async (status) => { + for (const code of [undefined, 'unknown-private-code', 'server_error']) { + const error = new ServiceAccountTokenError(status, 'private provider payload', code) + mocks.resolveTokenBundle.mockRejectedValueOnce(error) + await expect(resolveConnectorCredentialAccessToken(input)).rejects.toBe(error) + expect(internalOrchestrationErrorPolicy.project(error)).toBeNull() + } + } + ) + + it.each([429, 500, 503])( + 'preserves Google %s failures instead of blaming configuration', + async (status) => { + const error = new ServiceAccountTokenError(status, 'private provider payload', 'server_error') + mocks.resolveTokenBundle.mockRejectedValueOnce(error) + await expect(resolveConnectorCredentialAccessToken(input)).rejects.toBe(error) + expect(internalOrchestrationErrorPolicy.project(error)).toBeNull() + } + ) + + it('preserves unexpected token failures as internal errors', async () => { + const error = new TypeError('private network failure') + mocks.resolveTokenBundle.mockRejectedValueOnce(error) + await expect(resolveConnectorCredentialAccessToken(input)).rejects.toBe(error) + expect(internalOrchestrationErrorPolicy.project(error)).toBeNull() + }) + + it('keeps authorization errors actionable through connector creation orchestration', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(member, [{ role: 'admin' }]) + mocks.resolveKnowledgeBase.mockResolvedValue({ + organizationId: 'org', + knowledgeBaseId: 'org-index', + knowledgeBase: { id: 'org-index', name: 'Search', isSearchIndex: true }, + }) + mocks.resolveTokenBundle.mockRejectedValueOnce( + new ServiceAccountTokenError(401, 'private provider payload', 'unauthorized_client') + ) + mocks.createConnector.mockImplementationOnce( + async (createInput: { resolveAccessToken(id: string): Promise }) => { + try { + await createInput.resolveAccessToken(credential.id) + throw new Error('Unexpected successful token exchange') + } catch (error) { + return classifyKnowledgeFailure(error, 'request', 'Create connector') + } + } + ) + const error = await createKnowledgeConnector + .execute({ + principal, + input: { + knowledgeBaseId: 'org-index', + assertedOrganizationId: 'org', + connectorType: 'google_drive', + credentialId: credential.id, + accessMode: 'admin', + sourceConfig: input.sourceConfig, + syncIntervalMinutes: 60, + }, + }) + .catch((error: unknown) => error) + expect(internalOrchestrationErrorPolicy.project(error)).toMatchObject({ + status: 400, + body: { error: expect.stringContaining('(unauthorized_client)') }, + }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('returns an actionable error before saving a configuration edit', async () => { + mocks.resolveTokenBundle.mockRejectedValueOnce( + new ServiceAccountTokenError(401, 'private provider payload', 'unauthorized_client') + ) + await expect( + validateConnectorSourceConfig({ + principal, + organizationId: 'org', + actingUserId: principal.userId, + requestId: 'request', + sourceConfig: input.sourceConfig, + connector: { + connectorType: 'google_drive', + credentialId: credential.id, + encryptedApiKey: null, + accessMode: 'admin', + } as Parameters[0]['connector'], + }) + ).rejects.toMatchObject({ + code: 'validation', + message: expect.stringContaining('(unauthorized_client)'), + }) + expect(mocks.validateConnectorConfig).not.toHaveBeenCalled() + }) + it('does not mint a token after the credential creator leaves the organization', async () => { mocks.resolveTokenIdentity.mockResolvedValueOnce(null) await expect(resolveConnectorCredentialAccessToken(input)).resolves.toBeNull() diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index 990f91a282d..dee4bb40742 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -107,6 +107,7 @@ import { requireOrganizationSearchApproval } from '@/lib/knowledge/search/integr import { escapeLikePattern } from '@/lib/knowledge/tags/utils' import { isMemberSyncStatus } from '@/lib/knowledge/types' import { credentialProviderMatchesService, type ServiceProviderIdentity } from '@/lib/oauth' +import { ServiceAccountTokenError } from '@/lib/oauth/credential-service' import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' @@ -321,15 +322,57 @@ export async function resolveConnectorCredentialAccessToken(input: { }): Promise { const identity = await resolveAuthorizedConnectorCredentialIdentity(input) if (!identity) return null - const resolved = await resolveConnectorAccessToken({ + return resolveConnectorValidationAccessToken({ auth: input.auth, accessMode: input.accessMode, connector: { credentialId: input.credentialId, encryptedApiKey: null }, userId: identity.kind === 'oauth' ? identity.userId : input.actingUserId, requestId: input.requestId, sourceConfig: input.sourceConfig, - }).catch(rethrowGitHubInstallationSourceError) - return resolved + }) +} + +/** Exposes actionable credential refusals without returning raw provider payloads. */ +function rethrowConnectorCredentialError(error: unknown): never { + if (error instanceof ServiceAccountTokenError && [400, 401, 403].includes(error.statusCode)) { + let message: string + switch (error.errorCode) { + case 'unauthorized_client': + message = + "Google rejected service-account authorization (unauthorized_client). In Google Admin, authorize the JSON key's numeric client ID with the exact domain-wide delegation scopes in this connector's service-account setup section. Verify the delegated user's Workspace email and allow time for recent delegation changes to propagate." + break + case 'invalid_grant': + message = + "Google rejected the service-account grant (invalid_grant). Check that the JSON key is valid and the delegated user's primary Workspace email is correct." + break + case 'invalid_scope': + message = + "Google rejected the service-account scopes (invalid_scope). In Google Admin, authorize the exact domain-wide delegation scopes in this connector's service-account setup section." + break + case 'access_denied': + message = + 'Google denied service-account access (access_denied). Ask your Workspace administrator to check API access policies and domain-wide delegation.' + break + default: + throw error + } + throw new OrchestrationError('validation', message) + } + rethrowGitHubInstallationSourceError(error) +} + +/** Applies setup error handling to initial tokens and later delegated user probes. */ +async function resolveConnectorValidationAccessToken( + params: Parameters[0] +): Promise { + const resolved = await resolveConnectorAccessToken(params).catch(rethrowConnectorCredentialError) + const getDelegatedAccessToken = resolved?.getDelegatedAccessToken + if (!resolved || !getDelegatedAccessToken) return resolved + return { + ...resolved, + getDelegatedAccessToken: (subject) => + getDelegatedAccessToken(subject).catch(rethrowConnectorCredentialError), + } } export async function validateConnectorSourceConfig(input: { @@ -419,14 +462,14 @@ export async function validateConnectorSourceConfig(input: { if (identity.kind === 'oauth') tokenUserId = identity.userId } - const resolved = await resolveConnectorAccessToken({ + const resolved = await resolveConnectorValidationAccessToken({ auth: connectorConfig.auth, accessMode, connector: input.connector, userId: tokenUserId, requestId: input.requestId, sourceConfig: input.sourceConfig, - }).catch(rethrowGitHubInstallationSourceError) + }) if (!resolved) { return { message: 'Failed to refresh access token. Please reconnect your account.', diff --git a/apps/sim/lib/knowledge/application/github-setup.test.ts b/apps/sim/lib/knowledge/application/github-setup.test.ts index bd1d9939517..6d164a5ff8e 100644 --- a/apps/sim/lib/knowledge/application/github-setup.test.ts +++ b/apps/sim/lib/knowledge/application/github-setup.test.ts @@ -483,11 +483,11 @@ describe('GitHub setup reader OAuth continuation', () => { admin() await continueGitHubSearchSetup.execute({ principal, - input: { ...input, oauth: 'github_email_mismatch' }, + input: { ...input, oauth: 'github_email_unverified' }, }) await expect(status()).resolves.toMatchObject({ status: 'failed', - error: expect.stringContaining('verified secondary email'), + error: expect.stringContaining('verify your primary email address'), }) expect(m.connect).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/application/organization-search-overview.test.ts b/apps/sim/lib/knowledge/application/organization-search-overview.test.ts index 6bb20aa3796..2bf9db2a010 100644 --- a/apps/sim/lib/knowledge/application/organization-search-overview.test.ts +++ b/apps/sim/lib/knowledge/application/organization-search-overview.test.ts @@ -64,7 +64,9 @@ const health = { hasError: false, hasAccountError: false, hasDocumentError: false, + hasPermissionError: false, hasIndexing: false, + hasPendingSync: false, hasWaiting: false, hasUnstarted: false, } @@ -90,11 +92,25 @@ describe('organization Search administration overview', () => { mocks.availability.mockResolvedValue({ memberScoped, sourceMirrored: true }) const result = await readOrganizationSearchOverview.execute({ principal, input }) expect(result.providers).toEqual([ - { connectorType, approved: true, sourceCount: 0, status, issue: null, isSyncing: false }, + { + connectorType, + approved: true, + sourceCount: 0, + status, + issue: null, + isSyncing: false, + hasPendingSync: false, + }, ]) } ) it.each([ + { + hasPermissionError: true, + hasAccountError: false, + hasDocumentError: false, + issue: 'permission_sync_incomplete', + }, { hasAccountError: true, hasDocumentError: false, issue: 'account_sync_incomplete' }, { hasAccountError: false, hasDocumentError: true, issue: 'document_indexing_failed' }, { hasAccountError: false, hasDocumentError: false, issue: 'sync_failed' }, @@ -129,11 +145,34 @@ describe('organization Search administration overview', () => { status: 'needs_attention', issue: 'sync_failed', isSyncing: true, + hasPendingSync: false, }, ]) expect(organizationSearchOverviewSchema.parse(result)).toEqual(result) }) + it('includes member document and dispatch failures in provider health queries', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(knowledgeConnector, [{ ...health }]) + await readOrganizationSearchOverview.execute({ principal, input }) + const selection = dbChainMockFns.select.mock.calls.find(([fields]) => fields?.hasError)?.[0] + const { params } = renderFragment(selection?.hasError) + expect(params).toContain('knowledgeConnectorMemberSyncLog.docsFailed') + expect(params).toContain('knowledgeConnectorMemberSyncLog.processingDispatchFailed') + expect(params).not.toContain(undefined) + }) + + it('keeps unfinished work observable without presenting an idle worker as indexing', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(knowledgeConnector, [{ ...health, hasPendingSync: true, hasUnstarted: true }]) + const result = await readOrganizationSearchOverview.execute({ principal, input }) + expect(result.providers[0]).toMatchObject({ + status: 'needs_setup', + isSyncing: false, + hasPendingSync: true, + }) + }) + it.each(['admin', 'owner'])( 'allows a current %s and returns only operational facts', async (role) => { @@ -155,6 +194,7 @@ describe('organization Search administration overview', () => { status: 'active', issue: null, isSyncing: false, + hasPendingSync: false, }, { connectorType: 'gmail', @@ -163,6 +203,7 @@ describe('organization Search administration overview', () => { status: 'waiting_for_connections', issue: null, isSyncing: false, + hasPendingSync: false, }, { connectorType: 'github', @@ -171,6 +212,7 @@ describe('organization Search administration overview', () => { status: 'paused', issue: null, isSyncing: false, + hasPendingSync: false, }, ], }) @@ -223,6 +265,7 @@ describe('organization Search administration overview', () => { status: 'paused', issue: null, isSyncing: false, + hasPendingSync: false, }, ]) }) @@ -256,6 +299,7 @@ describe('organization Search administration overview', () => { status: 'paused', issue: null, isSyncing: false, + hasPendingSync: false, }, { connectorType: 'gmail', @@ -264,6 +308,7 @@ describe('organization Search administration overview', () => { status: 'paused', issue: null, isSyncing: false, + hasPendingSync: false, }, ]) }) diff --git a/apps/sim/lib/knowledge/application/organization-search-overview.ts b/apps/sim/lib/knowledge/application/organization-search-overview.ts index 97ebe9209cc..890fc5c5cb2 100644 --- a/apps/sim/lib/knowledge/application/organization-search-overview.ts +++ b/apps/sim/lib/knowledge/application/organization-search-overview.ts @@ -15,7 +15,10 @@ import { SOURCE_ACL_MAX_AGE_MS } from '@/lib/knowledge/access/freshness' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' -import { SOURCE_CONTENT_ERROR } from '@/lib/knowledge/connectors/sync-limits' +import { + SOURCE_CONTENT_ERROR, + SOURCE_PERMISSION_ERROR, +} from '@/lib/knowledge/connectors/sync-limits' import { MAX_SEARCH_SOURCE_PROVIDER_TYPES } from '@/lib/knowledge/constants' import { failedDocumentCondition } from '@/lib/knowledge/documents/processing-status' import { canConnectWithDefaults, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' @@ -30,7 +33,9 @@ interface ProviderHealth { hasError: boolean hasAccountError: boolean hasDocumentError: boolean + hasPermissionError: boolean hasIndexing: boolean + hasPendingSync: boolean hasWaiting: boolean hasUnstarted: boolean } @@ -158,7 +163,10 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ const latestMemberRunHasError = sql`coalesce(( SELECT ${knowledgeConnectorMemberSyncLog.status} = 'failed' OR (${knowledgeConnectorMemberSyncLog.status} = 'partial' AND ( - ${knowledgeConnectorMemberSyncLog.membersFailed} > 0 OR NOT ${continuing} + ${knowledgeConnectorMemberSyncLog.membersFailed} > 0 + OR ${knowledgeConnectorMemberSyncLog.docsFailed} > 0 + OR ${knowledgeConnectorMemberSyncLog.processingDispatchFailed} > 0 + OR NOT ${continuing} )) FROM ${knowledgeConnectorMemberSyncLog} WHERE ${knowledgeConnectorMemberSyncLog.connectorId} = ${knowledgeConnector.id} @@ -205,17 +213,24 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ ))`, hasAccountError: sql`bool_or(NOT ${paused} AND ${knowledgeConnector.accessMode} = 'members' AND ${hasMemberError})`, hasDocumentError: sql`bool_or(NOT ${paused} AND ${hasDocumentsInState(failedDocumentCondition())})`, + hasPermissionError: sql`bool_or(NOT ${paused} AND ${knowledgeConnector.lastSyncError} = ${SOURCE_PERMISSION_ERROR})`, hasIndexing: sql`bool_or(NOT ${paused} AND (${knowledgeConnector.accessMode} <> 'members' OR ${hasActiveMembers} OR ${knowledgeConnector.credentialId} IS NOT NULL) AND ( ${knowledgeConnector.status} IN ('pending', 'syncing') - OR ${continuing} OR ${hasDocumentsInState(inArray(document.processingStatus, ['pending', 'processing']))} + OR ${hasDocumentsInState(inArray(document.processingStatus, ['pending', 'processing']))} OR (${knowledgeConnector.accessMode} = 'members' AND ( - ${knowledgeConnector.memberSyncStatus} IN ('pending', 'running') OR ${hasMemberFirstListing} + ${knowledgeConnector.memberSyncStatus} IN ('pending', 'running') )) ))`, hasWaiting: sql`bool_or(NOT ${paused} AND ${knowledgeConnector.accessMode} = 'members' AND NOT ${hasActiveMembers})`, - hasUnstarted: sql`bool_or(NOT ${paused} AND ${knowledgeConnector.accessMode} = 'admin' AND ${knowledgeConnector.lastSyncAt} IS NULL)`, + hasPendingSync: sql`bool_or(NOT ${paused} AND ( + ${continuing} OR (${knowledgeConnector.accessMode} = 'members' AND ${hasMemberFirstListing}) + ))`, + hasUnstarted: sql`bool_or(NOT ${paused} AND ( + (${knowledgeConnector.accessMode} = 'admin' AND ${knowledgeConnector.lastSyncAt} IS NULL) + OR (${knowledgeConnector.accessMode} = 'members' AND ${hasMemberFirstListing}) + ))`, }) .from(knowledgeConnector) .innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)) @@ -276,11 +291,14 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ status === 'needs_attention' ? state?.hasAccountError ? ('account_sync_incomplete' as const) - : state?.hasDocumentError - ? ('document_indexing_failed' as const) - : ('sync_failed' as const) + : state?.hasPermissionError + ? ('permission_sync_incomplete' as const) + : state?.hasDocumentError + ? ('document_indexing_failed' as const) + : ('sync_failed' as const) : null, isSyncing: status !== 'paused' && Boolean(state?.hasIndexing), + hasPendingSync: status !== 'paused' && Boolean(state?.hasPendingSync), }, ] }), diff --git a/apps/sim/lib/knowledge/connectors/connector-error.test.ts b/apps/sim/lib/knowledge/connectors/connector-error.test.ts index a1d52f97540..69a995c95e6 100644 --- a/apps/sim/lib/knowledge/connectors/connector-error.test.ts +++ b/apps/sim/lib/knowledge/connectors/connector-error.test.ts @@ -3,6 +3,7 @@ import { DrizzleQueryError } from 'drizzle-orm/errors' import { describe, expect, it } from 'vitest' import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error' import { GoogleDriveApiError } from '@/connectors/google-drive/google-drive-errors' +import { ConnectorDirectoryError } from '@/connectors/source-error' describe('connector failure diagnostics', () => { it('retains the SQLSTATE while discarding SQL, bound values and driver detail', () => { @@ -85,6 +86,34 @@ describe('connector failure diagnostics', () => { expect(getConnectorFailureDiagnostic(error)?.message).not.toContain('access was denied') }) + it('reports a wrapped group-membership failure without suggesting file download permissions', () => { + const error = new Error('private outer message', { + cause: new ConnectorDirectoryError('private group detail', { + cause: new GoogleDriveApiError(403, ['forbidden'], 'directory.members.list'), + }), + }) + const diagnostic = getConnectorFailureDiagnostic(error) + expect(diagnostic).toMatchObject({ + phase: 'directory', + status: 403, + operation: 'directory.members.list', + reasons: ['forbidden'], + }) + expect(diagnostic?.message).toContain('Directory permission sync failed') + expect(diagnostic?.message).not.toContain('file access') + expect(JSON.stringify(diagnostic)).not.toContain('private') + }) + + it('keeps directory context when the failure has no HTTP status', () => { + expect( + getConnectorFailureDiagnostic(new ConnectorDirectoryError('private directory details')) + ).toMatchObject({ + category: 'directory', + phase: 'directory', + message: expect.stringContaining('Directory permission sync failed'), + }) + }) + it('does not infer status or permanence from a free-form message', () => { expect(getConnectorFailureDiagnostic(new Error('HTTP 403 permission denied'))).toBeNull() expect( diff --git a/apps/sim/lib/knowledge/connectors/connector-error.ts b/apps/sim/lib/knowledge/connectors/connector-error.ts index 7f8e4ac5179..737903da280 100644 --- a/apps/sim/lib/knowledge/connectors/connector-error.ts +++ b/apps/sim/lib/knowledge/connectors/connector-error.ts @@ -1,15 +1,19 @@ import { findCause, getPostgresErrorCode } from '@sim/utils/errors' import { DrizzleQueryError } from 'drizzle-orm/errors' import { + ConnectorDirectoryError, ConnectorSourceError, type ConnectorSourceFailureCategory, } from '@/connectors/source-error' export interface ConnectorFailureDiagnostic { - category: 'database' | ConnectorSourceFailureCategory | 'transport' + category: 'directory' | 'database' | ConnectorSourceFailureCategory | 'transport' message: string status?: number code?: string + operation?: string + reasons?: readonly string[] + phase?: 'directory' } const TRANSPORT_CODES = new Set([ @@ -30,7 +34,7 @@ const TRANSPORT_CODES = new Set([ * SQL, bound parameters, URLs and arbitrary exception messages never enter the * result. Unknown failures retain the caller's domain-specific fallback. */ -export function getConnectorFailureDiagnostic(error: unknown): ConnectorFailureDiagnostic | null { +function classifyFailure(error: unknown): ConnectorFailureDiagnostic | null { const code = getPostgresErrorCode(error) const databaseError = findCause( error, @@ -106,3 +110,36 @@ export function getConnectorFailureDiagnostic(error: unknown): ConnectorFailureD message: `Source content request was rejected (HTTP ${status}). Check the source's download restrictions and supported content.`, } } + +/** Preserves safe provider context and directory scope across wrapped failures. */ +export function getConnectorFailureDiagnostic(error: unknown): ConnectorFailureDiagnostic | null { + const diagnostic = classifyFailure(error) + const directoryError = findCause( + error, + (value): value is ConnectorDirectoryError => value instanceof ConnectorDirectoryError + ) + const sourceError = findCause( + error, + (value): value is ConnectorSourceError => value instanceof ConnectorSourceError + ) + const context = sourceError?.diagnostic + if (directoryError) { + const status = diagnostic?.status ? ` (HTTP ${diagnostic.status})` : '' + const code = diagnostic?.code ? ` Error code: ${diagnostic.code}.` : '' + const reason = context?.reasons.length ? ` Google reason: ${context.reasons.join(', ')}.` : '' + return { + ...diagnostic, + ...context, + category: diagnostic?.category ?? 'directory', + phase: 'directory', + message: `Directory permission sync failed${status}.${context ? ` Operation: ${context.operation}.` : ''}${reason}${code} Group membership could not be fully verified.`, + } + } + if (!diagnostic || !context) return diagnostic + const reason = context.reasons.length ? ` Google reason: ${context.reasons.join(', ')}.` : '' + return { + ...diagnostic, + ...context, + message: `Google request failed (HTTP ${diagnostic.status}). Operation: ${context.operation}.${reason}`, + } +} diff --git a/apps/sim/lib/knowledge/connectors/external-group-sync.test.ts b/apps/sim/lib/knowledge/connectors/external-group-sync.test.ts index 8dd68d764c0..5369a251407 100644 --- a/apps/sim/lib/knowledge/connectors/external-group-sync.test.ts +++ b/apps/sim/lib/knowledge/connectors/external-group-sync.test.ts @@ -3,6 +3,8 @@ */ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error' +import { GoogleDriveApiError } from '@/connectors/google-drive/google-drive-errors' import type { ConnectorDirectory } from '@/connectors/types' const { mockResolveTokenUserId, mockResolveToken, mockOpenDirectory, mockAvailability } = @@ -253,7 +255,8 @@ describe('refreshConnectorDirectory', () => { ) expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ - lastSyncError: 'Directory refresh failed: 403', + lastSyncError: + 'Directory refresh failed: Directory permission sync failed. Group membership could not be fully verified.', }) ) expect(dbChainMockFns.delete).not.toHaveBeenCalled() @@ -273,6 +276,31 @@ describe('refreshConnectorDirectory', () => { expect(dbChainMockFns.set.mock.calls.some(([value]) => 'lastSyncedAt' in value)).toBe(false) }) + it('persists the nested Google reason for scheduled directory failures', async () => { + queueTableRows(schemaMock.knowledgeConnector, [connectorRow()]) + const providerError = new GoogleDriveApiError(403, ['forbidden'], 'directory.members.list') + mockOpenDirectory.mockResolvedValue( + directory({ listGroupMembers: vi.fn().mockRejectedValue(providerError) }) + ) + + const failure = await refreshConnectorDirectory('connector-1', 'req-1').catch( + (error: unknown) => error + ) + expect(getConnectorFailureDiagnostic(failure)).toMatchObject({ + status: 403, + operation: 'directory.members.list', + reasons: ['forbidden'], + phase: 'directory', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + lastSyncError: + 'Directory refresh failed: Directory permission sync failed (HTTP 403). Operation: directory.members.list. Google reason: forbidden. Group membership could not be fully verified.', + }) + ) + expect(dbChainMockFns.set.mock.calls.some(([value]) => 'lastSyncedAt' in value)).toBe(false) + }) + it('clears a previous directory error after a successful refresh', async () => { queueTableRows(schemaMock.knowledgeConnector, [ connectorRow({ lastSyncError: 'Directory refresh failed: 403' }), @@ -295,6 +323,11 @@ describe('refreshConnectorDirectory', () => { syncContext: {}, accessToken: 'token', }).catch((error: unknown) => error) + expect(getConnectorFailureDiagnostic(failure)).toMatchObject({ + phase: 'directory', + status: 429, + category: 'rate_limit', + }) expect(getRetryAfterMs(failure)).toBe(60_000) expect(isRateLimitError(failure)).toBe(true) }) diff --git a/apps/sim/lib/knowledge/connectors/external-group-sync.ts b/apps/sim/lib/knowledge/connectors/external-group-sync.ts index 221c6802483..5ea76ba69c0 100644 --- a/apps/sim/lib/knowledge/connectors/external-group-sync.ts +++ b/apps/sim/lib/knowledge/connectors/external-group-sync.ts @@ -28,9 +28,11 @@ import { resolveConnectorTokenUserId, syncContextForToken, } from '@/lib/knowledge/connectors/access-token' +import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error' import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' import { isRateLimitError } from '@/lib/knowledge/documents/utils' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' +import { ConnectorDirectoryError } from '@/connectors/source-error' import type { ConnectorConfig, ConnectorDirectory, @@ -188,11 +190,13 @@ export async function syncExternalDirectoryGroups(input: { if (isRateLimitError(error)) throw error keptStale += 1 firstError ??= toError(error) + const diagnostic = getConnectorFailureDiagnostic(error) logger.warn('Keeping last-known-good membership for a group that failed to enumerate', { workspaceId, providerId, externalGroupId: group.id, - error: getErrorMessage(error), + error: diagnostic?.message ?? getErrorMessage(error), + diagnostic, }) continue } @@ -377,12 +381,16 @@ export async function refreshMirroredDirectory(input: { }) return result.skipped ? 'skipped' : 'refreshed' } catch (error) { + const diagnostic = getConnectorFailureDiagnostic(error) logger.error('Directory refresh failed; serving last-known-good group membership', { workspaceId, connector: connectorConfig.id, - error: getErrorMessage(error), + error: diagnostic?.message ?? getErrorMessage(error), + diagnostic, + }) + throw new ConnectorDirectoryError(`${DIRECTORY_ERROR_PREFIX}${getErrorMessage(error)}`, { + cause: error, }) - throw new Error(`${DIRECTORY_ERROR_PREFIX}${getErrorMessage(error)}`, { cause: error }) } } @@ -503,7 +511,10 @@ export async function refreshConnectorDirectory( } return outcome } catch (error) { - await recordError(getErrorMessage(error)) + const diagnostic = getConnectorFailureDiagnostic(error) + await recordError( + diagnostic ? `${DIRECTORY_ERROR_PREFIX}${diagnostic.message}` : getErrorMessage(error) + ) throw error } } diff --git a/apps/sim/lib/knowledge/connectors/listing-checkpoint.test.ts b/apps/sim/lib/knowledge/connectors/listing-checkpoint.test.ts index bbe904f130b..dd8f5df65e4 100644 --- a/apps/sim/lib/knowledge/connectors/listing-checkpoint.test.ts +++ b/apps/sim/lib/knowledge/connectors/listing-checkpoint.test.ts @@ -1,4 +1,6 @@ /** @vitest-environment node */ + +import { omit } from '@sim/utils/object' import { describe, expect, it, vi } from 'vitest' import { beginListingCheckpoint, @@ -204,7 +206,12 @@ describe('durable connector listing checkpoints', () => { ) it('restarts an expired provider cursor once with a new generation', async () => { - const f = fixture({ ...checkpoint(), cursor: 'expired', listedCount: 700 }) + const f = fixture({ + ...checkpoint(), + cursor: 'expired', + listedCount: 700, + permissionFailures: true, + }) const error = new Error('expired') const databaseTime = new Date('2026-09-08T10:00:00Z') const getGenerationStartedAt = vi.fn(async () => databaseTime) @@ -222,7 +229,7 @@ describe('durable connector listing checkpoints', () => { expect(result.generationId).not.toBe('cycle-1') expect(result.startedAt).toBe(databaseTime.toISOString()) expect(getGenerationStartedAt).toHaveBeenCalledOnce() - expect(result).toMatchObject({ complete: true, listedCount: 1 }) + expect(result).toMatchObject({ complete: true, listedCount: 1, permissionFailures: false }) expect(f.listDocuments.mock.calls[1][2]).toBeUndefined() expect(f.processPage.mock.calls[0][1].generationId).toBe(result.generationId) }) @@ -254,6 +261,11 @@ describe('durable connector listing checkpoints', () => { expect(f.listDocuments).not.toHaveBeenCalled() }) + it('resumes older checkpoints without inventing permission failures', () => { + const legacy = omit(checkpoint(), ['permissionFailures']) + expect(readListingCheckpoint(legacy, fingerprint)).toMatchObject({ permissionFailures: false }) + }) + it('rejects checkpoints from a changed configuration or malformed serialized value', () => { expect(readListingCheckpoint(checkpoint(), fingerprint)).toEqual(checkpoint()) expect( diff --git a/apps/sim/lib/knowledge/connectors/listing-checkpoint.ts b/apps/sim/lib/knowledge/connectors/listing-checkpoint.ts index f44c1feade5..7ca43030d82 100644 --- a/apps/sim/lib/knowledge/connectors/listing-checkpoint.ts +++ b/apps/sim/lib/knowledge/connectors/listing-checkpoint.ts @@ -21,6 +21,7 @@ const checkpointSchema = z.object({ listedCount: z.number().int().nonnegative(), unsafe: z.boolean(), contentFailures: z.boolean().default(false), + permissionFailures: z.boolean().default(false), changeCursor: z .string() .max(512 * 1024) @@ -66,6 +67,7 @@ export function beginListingCheckpoint(input: { listedCount: 0, unsafe: false, contentFailures: false, + permissionFailures: false, changeCursor: input.changeCursor ?? null, incrementalSince: input.incrementalSince?.toISOString() ?? null, forceRehydrate: input.forceRehydrate ?? false, @@ -132,6 +134,7 @@ export async function runResumableListing(input: { listedCount: 0, unsafe: false, contentFailures: false, + permissionFailures: false, } await input.saveCheckpoint(checkpoint) cursors.clear() diff --git a/apps/sim/lib/knowledge/connectors/member-sync-engine.integration.test.ts b/apps/sim/lib/knowledge/connectors/member-sync-engine.integration.test.ts index 140e5c4e7d3..8f79f52ee6a 100644 --- a/apps/sim/lib/knowledge/connectors/member-sync-engine.integration.test.ts +++ b/apps/sim/lib/knowledge/connectors/member-sync-engine.integration.test.ts @@ -565,6 +565,9 @@ describe('member engine with a dedicated content credential', () => { mocks.get.mockRejectedValueOnce(new Error('Download interrupted')) const result = await run() expect(result.docsFailed).toBe(1) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'partial', docsFailed: 1, processingDispatchFailed: 0 }) + ) expect(mocks.add).not.toHaveBeenCalled() expect(dbChainMockFns.set.mock.calls.some(([value]) => value.lastSyncAt instanceof Date)).toBe( false @@ -579,6 +582,16 @@ describe('member engine with a dedicated content credential', () => { expect(mocks.get.mock.calls[0][0]).toBe('service-token') }) + it('records processing dispatch failures separately from document failures', async () => { + const run = arrange() + mocks.dispatch.mockResolvedValue({ accepted: 0, failed: 1 }) + const result = await run() + expect(result.processingDispatch.failed).toBe(1) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'partial', docsFailed: 0, processingDispatchFailed: 1 }) + ) + }) + it('keeps an interrupted forced crawl due instead of retaining its previous fresh watermark', async () => { const run = arrange({ contentFresh: true, forceContentRefresh: true }) mocks.list diff --git a/apps/sim/lib/knowledge/connectors/member-sync-engine.ts b/apps/sim/lib/knowledge/connectors/member-sync-engine.ts index 3357161f6d8..addef3346e4 100644 --- a/apps/sim/lib/knowledge/connectors/member-sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/member-sync-engine.ts @@ -1498,6 +1498,8 @@ async function completeMemberSync( membersCompleted: result.membersCompleted, membersIncomplete: result.membersIncomplete, membersFailed: result.membersFailed, + docsFailed: result.docsFailed, + processingDispatchFailed: result.processingDispatch.failed, docsListed: result.docsListed, docsAdded: result.docsAdded, docsUpdated: result.docsUpdated, @@ -1549,6 +1551,8 @@ async function failMemberSyncLog(runId: string, result: MemberSyncResult, errorM membersCompleted: result.membersCompleted, membersIncomplete: result.membersIncomplete, membersFailed: result.membersFailed, + docsFailed: result.docsFailed, + processingDispatchFailed: result.processingDispatch.failed, docsListed: result.docsListed, docsAdded: result.docsAdded, docsUpdated: result.docsUpdated, diff --git a/apps/sim/lib/knowledge/connectors/sync-content-pass.test.ts b/apps/sim/lib/knowledge/connectors/sync-content-pass.test.ts index d4638298b69..798459d4d88 100644 --- a/apps/sim/lib/knowledge/connectors/sync-content-pass.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-content-pass.test.ts @@ -13,7 +13,10 @@ import { type ListingCheckpoint, } from '@/lib/knowledge/connectors/listing-checkpoint' import { runConnectorContentPass } from '@/lib/knowledge/connectors/sync-content-pass' -import { SOURCE_CONTENT_ERROR } from '@/lib/knowledge/connectors/sync-limits' +import { + SOURCE_CONTENT_ERROR, + SOURCE_PERMISSION_ERROR, +} from '@/lib/knowledge/connectors/sync-limits' import { stillHoldsSyncLock } from '@/lib/knowledge/connectors/sync-lock' import { confluenceConnector } from '@/connectors/confluence/confluence' import type { ExternalDocument, SyncResult } from '@/connectors/types' @@ -105,6 +108,7 @@ beforeEach(() => { hydrationVersion = undefined sourceBody = { value: '' } mocks.hardDelete.mockResolvedValue(0) + mocks.onPage.mockReset() mocks.upload.mockImplementation(async ({ customKey }: { customKey: string }) => ({ key: customKey, path: `/api/files/serve/${encodeURIComponent(customKey)}`, @@ -381,6 +385,45 @@ function contentWrite(): Record { } describe('content pass checkpoint intent', () => { + it('persists unresolved permissions independently of successful content processing', async () => { + sourceBody = { value: '

Current content

' } + mocks.onPage.mockResolvedValue({ permissionsIncomplete: true }) + const { pass, result } = await runPass({ access: 'admin' }) + expect(pass).toMatchObject({ + complete: true, + holdNotice: SOURCE_PERMISSION_ERROR, + checkpoint: { permissionFailures: true, contentFailures: false }, + }) + expect(result.docsFailed).toBe(0) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + listingCheckpoint: expect.objectContaining({ permissionFailures: true }), + }) + ) + }) + + it('does not erase an earlier worker permission failure when later pages verify successfully', async () => { + const checkpoint = { + ...beginListingCheckpoint({ + fingerprint: 'a'.repeat(64), + generationId: 'prior', + startedAt: new Date(0), + }), + permissionFailures: true, + } + mocks.onPage.mockResolvedValue({ permissionsIncomplete: false }) + const { pass } = await runPass({ checkpoint, access: 'admin' }) + expect(pass.holdNotice).toBe(SOURCE_PERMISSION_ERROR) + expect(pass.checkpoint.permissionFailures).toBe(true) + }) + + it('clears permission failure evidence for a newly verified crawl', async () => { + mocks.onPage.mockResolvedValue({ permissionsIncomplete: false }) + const { pass } = await runPass({ access: 'admin' }) + expect(pass.checkpoint.permissionFailures).toBe(false) + expect(pass.holdNotice).toBeNull() + }) + it('uses the database clock for a new generation despite a different worker clock', async () => { const databaseTime = new Date('2026-09-08T10:00:00Z') sourceBody = { value: '

Current content

' } diff --git a/apps/sim/lib/knowledge/connectors/sync-content-pass.ts b/apps/sim/lib/knowledge/connectors/sync-content-pass.ts index 2031682028f..7ef42e94c5e 100644 --- a/apps/sim/lib/knowledge/connectors/sync-content-pass.ts +++ b/apps/sim/lib/knowledge/connectors/sync-content-pass.ts @@ -10,7 +10,10 @@ import { readListingCheckpoint, runResumableListing, } from '@/lib/knowledge/connectors/listing-checkpoint' -import { SOURCE_CONTENT_ERROR } from '@/lib/knowledge/connectors/sync-limits' +import { + SOURCE_CONTENT_ERROR, + SOURCE_PERMISSION_ERROR, +} from '@/lib/knowledge/connectors/sync-limits' import { assertSyncLeaseHeldInTx, type SyncRunLease } from '@/lib/knowledge/connectors/sync-lock' import { type KnowledgeBaseOwner, @@ -57,7 +60,10 @@ interface ContentPassInput { forceRehydrate: boolean fullSync?: boolean deadlineAt: number - onPage?: (documents: ExternalDocument[], generationStartedAt: Date) => Promise + onPage?: ( + documents: ExternalDocument[], + generationStartedAt: Date + ) => Promise<{ permissionsIncomplete: boolean } | undefined> } /** One durable content cycle shared by content-owned and member-visibility connectors. */ @@ -172,7 +178,8 @@ export async function runConnectorContentPass(input: ContentPassInput) { }, }) if (!finished) return false - await input.onPage?.(documents, startedAt) + const pageOutcome = await input.onPage?.(documents, startedAt) + if (pageOutcome?.permissionsIncomplete) cycle.permissionFailures = true await withLease(async (tx) => { const verified = externalIds.filter((id) => !state.failedExternalIds.has(id)) for (let offset = 0; offset < verified.length; offset += 500) { @@ -204,7 +211,9 @@ export async function runConnectorContentPass(input: ContentPassInput) { return { checkpoint, complete: checkpoint.complete && reconciliation.finished, - holdNotice: reconciliation.notice ?? (checkpoint.contentFailures ? SOURCE_CONTENT_ERROR : null), + holdNotice: checkpoint.permissionFailures + ? SOURCE_PERMISSION_ERROR + : (reconciliation.notice ?? (checkpoint.contentFailures ? SOURCE_CONTENT_ERROR : null)), hydratedCount, } } diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index dc11c0d278d..ea8dc8c500a 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -2182,45 +2182,54 @@ describe('completeSuccessfulSync', () => { expect(dbChainMockFns.set).not.toHaveBeenCalled() }) - it('retains an earlier worker content failure when the final page has no errors', async () => { - const { completeSuccessfulSync } = await import('@/lib/knowledge/connectors/sync-engine') - queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) - queueTableRows(schemaMock.knowledgeConnector, [{ id: 'c-1' }]) - queueTableRows(schemaMock.document, [{ count: 4 }]) - dbChainMockFns.returning - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ id: 'log-1' }]) - .mockResolvedValueOnce([{ id: 'c-1' }]) + it.each(['contentFailures', 'permissionFailures'] as const)( + 'retains an earlier worker %s when the final page has no errors', + async (failure) => { + const { completeSuccessfulSync } = await import('@/lib/knowledge/connectors/sync-engine') + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + queueTableRows(schemaMock.knowledgeConnector, [{ id: 'c-1' }]) + queueTableRows(schemaMock.document, [{ count: 4 }]) + dbChainMockFns.returning + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'log-1' }]) + .mockResolvedValueOnce([{ id: 'c-1' }]) - expect( - await completeSuccessfulSync( - 'c-1', - 'kb-1', - 'log-1', - 60, - { ...RESULT, docsFailed: 0 }, - 'retry', - { - complete: true, - checkpoint: { - unsafe: false, - contentFailures: true, - startedAt: '2026-09-04T00:00:00Z', - listedCount: 4, - }, - } + expect( + await completeSuccessfulSync( + 'c-1', + 'kb-1', + 'log-1', + 60, + { ...RESULT, docsFailed: 0 }, + 'retry', + { + complete: true, + checkpoint: { + unsafe: false, + [failure]: true, + startedAt: '2026-09-04T00:00:00Z', + listedCount: 4, + }, + } + ) + ).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'partial', docsFailed: 0, listedCount: 4 }) ) - ).toBe(true) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ status: 'partial', docsFailed: 0, listedCount: 4 }) - ) - const connectorUpdate = dbChainMockFns.set.mock.calls.find( - (call) => (call[0] as Record | undefined)?.status === 'active' - )?.[0] as Record - expect(connectorUpdate).not.toHaveProperty('lastSyncAt') - expect(connectorUpdate.listingCheckpoint).toBeNull() - expect((connectorUpdate.nextSyncAt as Date).getTime()).toBeGreaterThan(Date.now() + 50 * 60_000) - }) + const connectorUpdate = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record | undefined)?.status === 'active' + )?.[0] as Record + expect(connectorUpdate).not.toHaveProperty('lastSyncAt') + expect(connectorUpdate.lastSyncError).toBe('retry') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'partial', errorMessage: 'retry' }) + ) + expect(connectorUpdate.listingCheckpoint).toBeNull() + expect((connectorUpdate.nextSyncAt as Date).getTime()).toBeGreaterThan( + Date.now() + 50 * 60_000 + ) + } + ) it('records a held listing as a completed sync whose watermark advances', async () => { const { completeSuccessfulSync } = await import('@/lib/knowledge/connectors/sync-engine') @@ -2283,6 +2292,64 @@ describe('completeSuccessfulSync', () => { expect.objectContaining({ status: 'active' }) ) }) + + it.each([ + { complete: true, holdNotice: null }, + { complete: false, holdNotice: null }, + { complete: false, holdNotice: 'Permissions could not be verified' }, + ])( + 'retains dispatch failures without changing listing recovery: %j', + async ({ complete, holdNotice }) => { + const { completeSuccessfulSync } = await import('@/lib/knowledge/connectors/sync-engine') + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb-1' }]) + queueTableRows(schemaMock.knowledgeConnector, [{ id: 'c-1' }]) + queueTableRows(schemaMock.document, [{ count: 4 }]) + dbChainMockFns.returning + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'log-1' }]) + .mockResolvedValueOnce([{ id: 'c-1' }]) + + await expect( + completeSuccessfulSync( + 'c-1', + 'kb-1', + 'log-1', + 60, + { + ...RESULT, + docsFailed: 0, + processingDispatch: { requested: 1, accepted: 0, failed: 1 }, + }, + holdNotice, + { + complete, + checkpoint: { unsafe: false, startedAt: '2026-09-04T00:00:00Z', listedCount: 4 }, + } + ) + ).resolves.toBe(true) + + const notice = + holdNotice ?? + 'Some documents could not be queued for indexing. They will be retried automatically.' + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'partial', + errorMessage: notice, + listedCount: complete ? 4 : null, + }) + ) + const update = dbChainMockFns.set.mock.calls.find(([value]) => value.status === 'active')?.[0] + expect(update).toMatchObject({ + lastSyncError: notice, + consecutiveFailures: 0, + syncLockToken: null, + }) + if (complete) expect(update.lastSyncAt).toEqual(new Date('2026-09-04T00:00:00Z')) + else expect(update).not.toHaveProperty('lastSyncAt') + if (complete) expect(update.nextSyncAt.getTime()).toBeGreaterThan(Date.now() + 50 * 60_000) + else expect(update.nextSyncAt.getTime()).toBeLessThanOrEqual(Date.now()) + } + ) }) describe('stillHoldsSyncLock', () => { @@ -2719,6 +2786,45 @@ describe('executeSync heartbeats during the listing phase', () => { } ) + it.each([ + { acl: undefined, incomplete: true }, + { acl: ['invalid-token'], incomplete: true }, + { acl: [], incomplete: false }, + { acl: ['u:reader@example.com'], incomplete: false }, + ])( + 'reports rejected mirrored permissions without rejecting valid grants: %j', + async ({ acl, incomplete }) => { + const contentPass = await import('@/lib/knowledge/connectors/sync-content-pass') + primeSyncUpToListing() + dbChainMockFns.returning.mockReset() + dbChainMockFns.returning.mockResolvedValueOnce([{ ...CONNECTOR, accessMode: 'admin' }]) + let permissionResult: { permissionsIncomplete: boolean } | undefined + const pass = vi + .spyOn(contentPass, 'runConnectorContentPass') + .mockImplementation(async (input) => { + permissionResult = await input.onPage?.( + [{ externalId: 'page-1', title: 'Page', content: 'Body', mimeType: 'text/plain', acl }], + new Date() + ) + throw new Error('Stopped after permission persistence') + }) + try { + const result = await executeSync('c-1', { + billingAttribution: { workspaceId: 'ws-1' } as never, + }) + expect(result.error).toBe('Stopped after permission persistence') + expect(permissionResult).toEqual({ permissionsIncomplete: incomplete }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + acl: incomplete ? [] : acl, + }) + ) + } finally { + pass.mockRestore() + } + } + ) + it('beats between pages and abandons the run when the lock was reclaimed', async () => { const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine') const { SYNC_LOCK_HEARTBEAT_INTERVAL_MS } = await import( diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 78bec1bf562..dccaf32d775 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -122,7 +122,7 @@ async function applySourceMirroredAcls(input: { ownedExternalIds: readonly (string | null)[] lease?: SyncRunLease generationStartedAt: Date -}): Promise { +}): Promise<{ permissionsIncomplete: boolean }> { const { connectorId, connectorConfig, externalDocs } = input /** @@ -174,6 +174,7 @@ async function applySourceMirroredAcls(input: { } ) } + return { permissionsIncomplete: unattributed > 0 || written.rejected > 0 } } /** Whether an automatic connector sync may begin from this persisted state. */ @@ -296,6 +297,7 @@ export interface ContentPassOutcome { checkpoint: { unsafe: boolean contentFailures?: boolean + permissionFailures?: boolean startedAt: string listedCount: number incrementalSince?: string | null @@ -303,17 +305,18 @@ export interface ContentPassOutcome { } /** - * A content pass is incomplete when the listing has not reached the end of the - * source (the generation resumes on the next run) or a source read failed (the - * next pass replays it). `checkpoint.unsafe` is deliberately not part of this: - * it means "do not infer deletions from this listing" and is honored by the - * deletion hold in `reconcileCompletedListing`. A held pass is still a - * completed sync whose watermark advances. + * A deletion hold alone does not make a sync incomplete: `checkpoint.unsafe` + * prevents deletion reconciliation, but an otherwise successful crawl may + * still advance its watermark. */ export function isContentPassIncomplete( contentPass: Pick ): boolean { - return !contentPass.complete || contentPass.checkpoint.contentFailures === true + return ( + !contentPass.complete || + contentPass.checkpoint.contentFailures === true || + contentPass.checkpoint.permissionFailures === true + ) } /** @@ -332,6 +335,12 @@ export async function completeSuccessfulSync( reconciliationHoldNotice: string | null, contentPass?: ContentPassOutcome ): Promise { + const processingDispatchFailed = result.processingDispatch.failed > 0 + const completionNotice = + reconciliationHoldNotice ?? + (processingDispatchFailed + ? 'Some documents could not be queued for indexing. They will be retried automatically.' + : null) try { return await db.transaction(async (tx) => { const [lockedKnowledgeBase] = await tx @@ -379,7 +388,10 @@ export async function completeSuccessfulSync( const [closedLog] = await tx .update(knowledgeConnectorSyncLog) .set({ - status: contentPass && isContentPassIncomplete(contentPass) ? 'partial' : 'completed', + status: + processingDispatchFailed || (contentPass && isContentPassIncomplete(contentPass)) + ? 'partial' + : 'completed', completedAt: now, listedCount: contentPass?.complete ? contentPass.checkpoint.incrementalSince @@ -392,6 +404,7 @@ export async function completeSuccessfulSync( docsUnchanged: result.docsUnchanged, docsSkipped: result.docsSkipped, docsFailed: result.docsFailed, + errorMessage: completionNotice, }) .where( and( @@ -409,7 +422,7 @@ export async function completeSuccessfulSync( now, actualDocCount, contentPass && !contentPass.complete ? now : calculateNextSyncTime(syncIntervalMinutes), - reconciliationHoldNotice, + completionNotice, result.docsFailed === 0 && (!contentPass || !isContentPassIncomplete(contentPass)) ), /** Restored above under this same lock, or hidden by the admin pass before the ACLs it wrote. */ @@ -1110,7 +1123,7 @@ export async function executeSync( onPage: mirrored ? async (externalDocs, generationStartedAt) => { await directoryRefreshed - await applySourceMirroredAcls({ + return applySourceMirroredAcls({ connectorId, connectorConfig, sourceConfig, diff --git a/apps/sim/lib/knowledge/connectors/sync-limits.ts b/apps/sim/lib/knowledge/connectors/sync-limits.ts index 15aa207b839..0332a659513 100644 --- a/apps/sim/lib/knowledge/connectors/sync-limits.ts +++ b/apps/sim/lib/knowledge/connectors/sync-limits.ts @@ -111,6 +111,9 @@ export const MEMBER_TOMBSTONE_PURGE_DAYS = 7 /** Hard deletes one members-mode run may perform; bounds the blast radius of a bad run. */ export const MEMBER_PURGE_MAX_PER_RUN = 1000 +export const SOURCE_PERMISSION_ERROR = + 'Some document permissions could not be verified. Documents without verified access stay hidden from search.' + /** Source downloads are retried by connector listing, never by parsing the retained file again. */ export const SOURCE_CONTENT_ERROR = 'Source content could not be refreshed. The connector will retry at its next scheduled sync.' diff --git a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts index 24b77c321fa..73ff94ee27d 100644 --- a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts @@ -11,6 +11,7 @@ import { schemaMock, setEnvFlags, } from '@sim/testing' +import { DrizzleQueryError } from 'drizzle-orm/errors' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -20,6 +21,7 @@ const { mockGetBoundWorkspaceFileSecretProvenanceByMetadata, mockGetEmbeddingModelInfo, mockGetFileMetadataByKeys, + mockLogError, mockProcessDocument, mockTrigger, } = vi.hoisted(() => ({ @@ -29,10 +31,16 @@ const { mockGetBoundWorkspaceFileSecretProvenanceByMetadata: vi.fn(), mockGetEmbeddingModelInfo: vi.fn(), mockGetFileMetadataByKeys: vi.fn(), + mockLogError: vi.fn(), mockProcessDocument: vi.fn(), mockTrigger: vi.fn(), })) +vi.mock('@sim/logger', async () => { + const { createMockLogger, loggerMock } = await import('@sim/testing/mocks/logger.mock') + return { ...loggerMock, createLogger: () => ({ ...createMockLogger(), error: mockLogError }) } +}) + vi.mock('@trigger.dev/sdk', () => ({ tasks: { batchTrigger: mockBatchTrigger, trigger: mockTrigger }, })) @@ -693,6 +701,110 @@ describe('processDocumentAsync write guards', () => { expect(guardForStatusWrite('failed')).toBeDefined() }) + it('stores bounded database diagnostics while retaining the original error for retry classification', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([PERSISTED_CONTEXT]) + .mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW]) + .mockResolvedValueOnce([{ id: 'document-1' }]) + mockGetFileMetadataByKeys.mockResolvedValue([SOURCE_BINDING]) + mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue( + new Map([[SOURCE_BINDING.id, { status: 'exact', entries: [] }]]) + ) + const databaseError = new DrizzleQueryError( + 'insert private SQL', + ['private bound content'], + Object.assign(new Error('private driver detail'), { code: '57014' }) + ) + mockProcessDocument.mockRejectedValueOnce(databaseError) + const onClaimed = vi.fn() + + await expect( + processDocumentAsync( + 'knowledge-base-1', + 'document-1', + { + filename: 'a.pdf', + fileUrl: 'https://example.com/a.pdf', + fileSize: 1, + mimeType: 'text/plain', + }, + {}, + BILLING_ATTRIBUTION, + 'request-1', + { chargedAtDispatch: true, onClaimed } + ) + ).rejects.toBe(databaseError) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + processingStatus: 'failed', + processingError: 'Database request failed (SQLSTATE 57014).', + }) + ) + expect(onClaimed).toHaveBeenCalledTimes(1) + expect(guardForStatusWrite('processing')).toBeDefined() + expect(guardForStatusWrite('failed')).toBeDefined() + }) + + it('records the failed embedding batch without exposing SQL, content or vectors', async () => { + armProviderSource() + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'document-1' }]) + mockProcessDocument.mockResolvedValueOnce({ + chunks: [{ text: 'private-content', metadata: { startIndex: 0, endIndex: 15 } }], + metadata: { chunkCount: 1, tokenCount: 3, characterCount: 15 }, + }) + mockGenerateEmbeddings.mockResolvedValueOnce({ + embeddings: [[0.123456789]], + billableTokens: 0, + modelName: 'text-embedding-3-small', + pricingId: 'text-embedding-3-small', + }) + const databaseError = new DrizzleQueryError( + 'insert private SQL', + ['private-content', [0.123456789]], + Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' }) + ) + dbChainMockFns.values.mockRejectedValueOnce(databaseError) + + await expect( + processDocumentAsync( + 'knowledge-base-1', + 'document-1', + { + filename: 'a.txt', + fileUrl: 'https://example.com/a.txt', + fileSize: 15, + mimeType: 'text/plain', + }, + {}, + BILLING_ATTRIBUTION + ) + ).rejects.toBe(databaseError) + + expect(mockLogError).toHaveBeenCalledWith('[document-1] Failed to insert embedding batch', { + knowledgeBaseId: 'knowledge-base-1', + operation: 'embedding.insert', + batchNumber: 1, + batchSize: 1, + totalChunks: 1, + embeddingModel: 'text-embedding-3-small', + embeddingDimensions: 1536, + elapsedMs: expect.any(Number), + diagnostic: { + category: 'database', + code: '57014', + message: 'Database request failed (SQLSTATE 57014).', + }, + }) + const logs = JSON.stringify(mockLogError.mock.calls) + expect(logs).not.toContain('private') + expect(logs).not.toContain('0.123456789') + expect(guardForStatusWrite('failed')).toBeDefined() + expect( + dbChainMockFns.set.mock.calls.some(([value]) => value.processingStatus === 'completed') + ).toBe(false) + }) + it('accepts a legacy queuedAt-only payload only while the row has no token', async () => { dbChainMockFns.limit .mockResolvedValueOnce([PERSISTED_CONTEXT]) @@ -1441,6 +1553,34 @@ describe('in-process quota continuation dispatch', () => { ) }) + it('redacts database query details in the in-process worker without changing acceptance', async () => { + const databaseError = new DrizzleQueryError( + 'insert private-query', + ['private-parameter'], + Object.assign(new Error('private-driver-message'), { code: '57014' }) + ) + mockGenerateEmbeddings.mockRejectedValue(databaseError) + + await expect( + processDocumentsWithQueue( + [queuedDocument], + 'knowledge-base-1', + {}, + 'request-1', + BILLING_ATTRIBUTION + ) + ).resolves.toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) + + expect(mockLogError).toHaveBeenCalledWith( + '[request-1] In-process document processing failed', + expect.objectContaining({ + error: 'Database request failed (SQLSTATE 57014).', + diagnostic: expect.objectContaining({ category: 'database', code: '57014' }), + }) + ) + expect(JSON.stringify(mockLogError.mock.calls)).not.toContain('private-') + }) + it('resumes an OCR-throttled regular KB from the durable outbox to a completed index', async () => { mockProcessDocument.mockRejectedValueOnce( new ProviderCapacityDeferredError('rate_limit', { retryAfterMs: 600_000 }) diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index a0691b43220..b285dd68d09 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -1350,10 +1350,11 @@ async function parseHttpFile( access.signal?.throwIfAborted() /** Prefer what we actually downloaded over what the document is *called*. */ - const extension = - resolveStoredArtifactExtension(fileUrl) ?? resolveParserExtension(filename, mimeType) + const storedExtension = resolveStoredArtifactExtension(fileUrl) + const extension = storedExtension ?? resolveParserExtension(filename, mimeType) const result = await parseBuffer(buffer, extension, { signal: access.signal, + textMode: storedExtension === 'txt' ? 'literal' : undefined, pdfTextMode: extension === 'pdf' ? 'complete' : undefined, }) return result diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 4b5d9cf2761..50699895060 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -80,6 +80,7 @@ import { MAX_KNOWLEDGE_ACCESS_CANDIDATES, SYSTEM_ACCESS_SCOPE, } from '@/lib/knowledge/access/types' +import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error' import { assertSyncLeaseHeldInTx, type SyncWriteLease } from '@/lib/knowledge/connectors/sync-lock' import { documentConnectorIsActive } from '@/lib/knowledge/documents/connector-lifecycle' import { @@ -1388,9 +1389,11 @@ async function dispatchInProcess( const message = processingClaimed ? 'In-process document processing failed' : 'In-process document dispatch failed before claiming the document' + const diagnostic = getConnectorFailureDiagnostic(error) logger.error(`[${requestId}] ${message}`, { documentId: p.documentId, - error: getErrorMessage(error), + error: diagnostic?.message ?? getErrorMessage(error), + ...(diagnostic ? { diagnostic } : {}), }) return processingClaimed } @@ -1886,9 +1889,25 @@ export async function processDocumentAsync( } logger.info(`[${documentId}] Inserting ${embeddingRecords.length} embeddings`) - for (const batch of batches) { + for (const [batchIndex, batch] of batches.entries()) { signal.throwIfAborted() - await tx.insert(embedding).values(batch) + const insertStartedAt = Date.now() + try { + await tx.insert(embedding).values(batch) + } catch (error) { + logger.error(`[${documentId}] Failed to insert embedding batch`, { + knowledgeBaseId, + operation: 'embedding.insert', + batchNumber: batchIndex + 1, + batchSize: batch.length, + totalChunks: embeddingRecords.length, + embeddingModel: kbEmbeddingModel, + embeddingDimensions: kbEmbedding.dimensions, + elapsedMs: Date.now() - insertStartedAt, + diagnostic: getConnectorFailureDiagnostic(error), + }) + throw error + } } const provenanceRecords = embeddingRecords.flatMap((record, index) => { const provenance = chunkProvenances[index] @@ -2060,15 +2079,19 @@ export async function processDocumentAsync( const providerContinuationExhausted = recordedError instanceof ProviderCapacityContinuationExhaustedError const quotaContinuationFailed = quotaContinuationAttempted && !deferredUntil + const failureDiagnostic = getConnectorFailureDiagnostic(recordedError) const errorMessage = byokCredentialRejected ? BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE : embeddingQuotaExhausted ? quotaContinuationFailed ? getErrorMessage(recordedError, 'Embedding quota continuation dispatch failed') : EMBEDDING_QUOTA_EXHAUSTED_MESSAGE - : getErrorMessage(recordedError, 'Unknown error') + : failureDiagnostic?.category === 'database' + ? failureDiagnostic.message + : getErrorMessage(recordedError, 'Unknown error') const logContext = { errorType: toError(recordedError).name, + ...(failureDiagnostic ? { diagnostic: failureDiagnostic } : {}), knowledgeBaseId, mimeType: docData.mimeType, fileSize: docData.fileSize, diff --git a/apps/sim/lib/knowledge/documents/stored-artifact-extension.test.ts b/apps/sim/lib/knowledge/documents/stored-artifact-extension.test.ts index b30446cd547..18267da5b2a 100644 --- a/apps/sim/lib/knowledge/documents/stored-artifact-extension.test.ts +++ b/apps/sim/lib/knowledge/documents/stored-artifact-extension.test.ts @@ -9,7 +9,13 @@ * SharePoint PDFs with `Invalid PDF structure.` and silently double-wrapped * every spreadsheet, which "succeeded" because SheetJS accepts almost anything. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +const { mockDownload } = vi.hoisted(() => ({ mockDownload: vi.fn() })) + +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ downloadFileFromUrl: mockDownload })) + +import { processDocument } from '@/lib/knowledge/documents/document-processor' import { resolveStoredArtifactExtension } from '@/lib/knowledge/documents/parser-extension' const CONNECTOR_PDF_URL = @@ -86,3 +92,55 @@ describe('resolveStoredArtifactExtension', () => { expect(resolveStoredArtifactExtension('/api/files/serve/s3/kb%2F1-a-Report.PDF')).toBe('pdf') }) }) + +describe('stored text document processing', () => { + const source = ` + + + + Application shell + + + +
+` + + it('indexes the source of an HTML shell stored as connector text', async () => { + mockDownload.mockResolvedValue(Buffer.from(source)) + + const result = await processDocument( + '/api/files/serve/s3/kb%2Ffixture-index.html.txt?context=knowledge-base', + 'index.html', + 'text/plain' + ) + + expect(result.chunks).toHaveLength(1) + expect(result.chunks[0].text).toContain('') + expect(result.chunks[0].text).toContain('
') + expect(result.metadata.characterCount).toBe(source.length) + }) + + it('keeps an actual HTML document on rendered-text extraction', async () => { + mockDownload.mockResolvedValue(Buffer.from(source)) + + await expect( + processDocument( + '/api/files/serve/s3/kb%2Ffixture-page.html?context=knowledge-base', + 'page.html', + 'text/html' + ) + ).rejects.toMatchObject({ code: 'no_extractable_text' }) + }) + + it('still rejects a stored text artifact containing only whitespace', async () => { + mockDownload.mockResolvedValue(Buffer.from(' \n\t ')) + + await expect( + processDocument( + '/api/files/serve/s3/kb%2Ffixture-blank.txt?context=knowledge-base', + 'blank.txt', + 'text/plain' + ) + ).rejects.toMatchObject({ code: 'no_extractable_text' }) + }) +}) diff --git a/apps/sim/lib/knowledge/mcp/activity.test.ts b/apps/sim/lib/knowledge/mcp/activity.test.ts new file mode 100644 index 00000000000..0da06d56a05 --- /dev/null +++ b/apps/sim/lib/knowledge/mcp/activity.test.ts @@ -0,0 +1,87 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + values: vi.fn(), + insert: vi.fn(), + execute: vi.fn(), + transaction: vi.fn(), +})) +vi.mock('@sim/db', () => ({ db: { transaction: mocks.transaction } })) + +import { + recordOrganizationSearchMcpActivity, + type SearchMcpActivityInput, +} from '@/lib/knowledge/mcp/activity' + +const activity: SearchMcpActivityInput = { + organizationId: 'org', + userId: 'actor', + authKind: 'personal_api_key', + oauthClientId: null, + clientName: null, + toolName: 'read_document', + outcome: 'success', + durationMs: 42, + createdAt: new Date('2026-01-01T00:00:00Z'), +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.insert.mockReturnValue({ values: mocks.values }) + mocks.values.mockResolvedValue(undefined) + mocks.execute.mockResolvedValue(undefined) + mocks.transaction.mockImplementation((callback) => + callback({ execute: mocks.execute, insert: mocks.insert }) + ) +}) + +describe('persistent MCP activity', () => { + it('stores an API-key call without inventing an application name', async () => { + await recordOrganizationSearchMcpActivity(activity) + expect(mocks.values).toHaveBeenCalledExactlyOnceWith({ + id: expect.any(String), + ...activity, + clientName: null, + }) + }) + + it('only persists the allowlisted metadata when extra content is present', async () => { + const input = { + ...activity, + query: 'private question', + content: 'private document', + token: 'private token', + } + await recordOrganizationSearchMcpActivity(input) + expect(mocks.values).toHaveBeenCalledExactlyOnceWith({ + id: expect.any(String), + ...activity, + clientName: null, + }) + }) + + it('sets the transaction deadline before attempting the insert', async () => { + const ready = Promise.withResolvers() + mocks.execute.mockReturnValueOnce(ready.promise) + const recording = recordOrganizationSearchMcpActivity(activity) + expect(mocks.insert).not.toHaveBeenCalled() + expect(JSON.stringify(mocks.execute.mock.calls[0])).toContain( + "SET LOCAL statement_timeout = '2s'" + ) + ready.resolve() + await recording + expect(mocks.insert).toHaveBeenCalledOnce() + }) + + it('does not insert when the deadline could not be established', async () => { + mocks.execute.mockRejectedValueOnce(new Error('unavailable')) + await expect(recordOrganizationSearchMcpActivity(activity)).resolves.toBeUndefined() + expect(mocks.insert).not.toHaveBeenCalled() + }) + + it('does not propagate storage failures into the request lifecycle', async () => { + mocks.values.mockRejectedValueOnce(new Error('offline')) + await expect(recordOrganizationSearchMcpActivity(activity)).resolves.toBeUndefined() + }) +}) diff --git a/apps/sim/lib/knowledge/mcp/activity.ts b/apps/sim/lib/knowledge/mcp/activity.ts new file mode 100644 index 00000000000..92ae188431b --- /dev/null +++ b/apps/sim/lib/knowledge/mcp/activity.ts @@ -0,0 +1,48 @@ +import { db } from '@sim/db' +import { organizationSearchMcpInvocation } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { sql } from 'drizzle-orm' + +const logger = createLogger('OrganizationSearchMcpActivity') + +export type SearchMcpActivityInput = Pick< + typeof organizationSearchMcpInvocation.$inferInsert, + | 'organizationId' + | 'userId' + | 'authKind' + | 'oauthClientId' + | 'clientName' + | 'toolName' + | 'outcome' + | 'durationMs' + | 'createdAt' +> + +/** Stores content-free metadata from an admitted MCP request, independently of tool success. */ +export async function recordOrganizationSearchMcpActivity( + input: SearchMcpActivityInput +): Promise { + try { + await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = '2s'`) + await tx.insert(organizationSearchMcpInvocation).values({ + id: generateId(), + organizationId: input.organizationId, + userId: input.userId, + authKind: input.authKind, + oauthClientId: input.oauthClientId, + clientName: input.clientName, + toolName: input.toolName, + outcome: input.outcome, + durationMs: input.durationMs, + createdAt: input.createdAt, + }) + }) + } catch (error) { + logger.warn('Failed to record organization Search MCP activity', { + error: getErrorMessage(error), + }) + } +} diff --git a/apps/sim/lib/knowledge/mcp/server.test.ts b/apps/sim/lib/knowledge/mcp/server.test.ts index 180f55fd121..c36904b77c1 100644 --- a/apps/sim/lib/knowledge/mcp/server.test.ts +++ b/apps/sim/lib/knowledge/mcp/server.test.ts @@ -1,5 +1,6 @@ /** @vitest-environment node */ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { createMockLogger } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,6 +13,16 @@ const mocks = vi.hoisted(() => ({ read: vi.fn(), chat: vi.fn(), rateLimit: vi.fn(), + info: vi.fn(), + afterResponse: vi.fn<(task: () => Promise) => void>(), + recordActivity: vi.fn(), +})) +vi.mock('@/lib/core/utils/after-response', () => ({ afterResponse: mocks.afterResponse })) +vi.mock('@/lib/knowledge/mcp/activity', () => ({ + recordOrganizationSearchMcpActivity: mocks.recordActivity, +})) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ ...createMockLogger(), info: mocks.info }), })) vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ McpServer: class { @@ -71,7 +82,7 @@ function payload(result: CallToolResult): unknown { beforeEach(() => { vi.clearAllMocks() mocks.tools.clear() - mocks.rateLimit.mockResolvedValue(null) + mocks.rateLimit.mockReset().mockResolvedValue(null) mocks.search.mockResolvedValue({ results: [] }) mocks.read.mockResolvedValue({ knowledgeBaseId: 'index-1', @@ -314,3 +325,134 @@ describe('organization chat', () => { }) }) }) + +describe('MCP tool completion records', () => { + it('schedules only metadata after the response, independently of analytics storage latency', async () => { + createKnowledgeMcpServer({ + organizationId: 'org-1', + searchIndexId: 'index-1', + request, + auth: { + ...auth, + keyType: 'oauth_access_token', + principal: { + kind: 'oauth_access_token', + userId: 'oauth-person', + clientId: 'registered-client', + clientName: 'Registered app', + tokenId: 'private-token-id', + scopes: ['search:read'], + expiresAt: new Date(Date.now() + 60000), + }, + }, + }) + const response = await call('search', { query: 'private question' }) + expect(response.isError).not.toBe(true) + expect(mocks.recordActivity).not.toHaveBeenCalled() + expect(mocks.afterResponse).toHaveBeenCalledOnce() + await mocks.afterResponse.mock.calls[0][0]() + expect(mocks.recordActivity).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-1', + userId: 'oauth-person', + authKind: 'oauth_access_token', + oauthClientId: 'registered-client', + clientName: 'Registered app', + toolName: 'search', + outcome: 'success', + durationMs: expect.any(Number), + createdAt: expect.any(Date), + }) + }) + + it.each([ + ['search', { query: 'private query', topK: 10 }, 'knowledge.search'], + ['read_document', { documentId: 'doc-1' }, 'knowledge.documents.read'], + ['chat', { query: 'private question' }, 'knowledge.chat'], + ] as const)('records one content-free completion for %s', async (toolName, input, operation) => { + create() + await call(toolName, input) + expect(mocks.info).toHaveBeenCalledExactlyOnceWith('Knowledge MCP tool completed', { + toolName, + operation, + organizationId: 'org-1', + userId: 'person-1', + outcome: 'success', + durationMs: expect.any(Number), + }) + }) + + it('records a returned tool error as an error even though the HTTP transport can succeed', async () => { + create() + const result = await call('read_document', {}) + expect(result.isError).toBe(true) + expect(mocks.info).toHaveBeenCalledExactlyOnceWith( + 'Knowledge MCP tool completed', + expect.objectContaining({ toolName: 'read_document', outcome: 'error' }) + ) + }) + + it('records an authorization failure without including the query or error message', async () => { + create() + mocks.search.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Private denial reason')) + await call('search', { query: 'private query' }) + expect(mocks.info).toHaveBeenCalledExactlyOnceWith('Knowledge MCP tool completed', { + toolName: 'search', + operation: 'knowledge.search', + organizationId: 'org-1', + userId: 'person-1', + outcome: 'error', + durationMs: expect.any(Number), + }) + }) + + it('distinguishes rate limiting from an executed tool', async () => { + create() + mocks.rateLimit.mockResolvedValueOnce(new Response(null, { status: 429 })) + await call('search', { query: 'private query' }) + expect(mocks.search).not.toHaveBeenCalled() + expect(mocks.info).toHaveBeenCalledExactlyOnceWith( + 'Knowledge MCP tool completed', + expect.objectContaining({ outcome: 'rate_limited' }) + ) + await mocks.afterResponse.mock.calls[0][0]() + expect(mocks.recordActivity).toHaveBeenCalledWith( + expect.objectContaining({ + authKind: 'personal_api_key', + oauthClientId: null, + outcome: 'rate_limited', + }) + ) + }) + + it.each(['search', 'read_document', 'chat'])( + 'records cancelled %s calls without executing the operation', + async (toolName) => { + create() + mocks.rateLimit.mockResolvedValueOnce(new Response(null, { status: 429 })) + await call(toolName, { query: 'private query', documentId: 'doc-1' }, AbortSignal.abort()) + expect(mocks.rateLimit).not.toHaveBeenCalled() + expect(mocks.search).not.toHaveBeenCalled() + expect(mocks.read).not.toHaveBeenCalled() + expect(mocks.chat).not.toHaveBeenCalled() + expect(mocks.info).toHaveBeenCalledExactlyOnceWith( + 'Knowledge MCP tool completed', + expect.objectContaining({ toolName, outcome: 'cancelled' }) + ) + } + ) + + it('records cancellation during rate-limit admission instead of an exhausted bucket', async () => { + create() + const controller = new AbortController() + mocks.rateLimit.mockImplementationOnce(async () => { + controller.abort() + return new Response(null, { status: 429 }) + }) + await call('search', { query: 'private query' }, controller.signal) + expect(mocks.search).not.toHaveBeenCalled() + await mocks.afterResponse.mock.calls[0][0]() + expect(mocks.recordActivity).toHaveBeenCalledWith( + expect.objectContaining({ outcome: 'cancelled' }) + ) + }) +}) diff --git a/apps/sim/lib/knowledge/mcp/server.ts b/apps/sim/lib/knowledge/mcp/server.ts index 477ca7fb744..226fece5474 100644 --- a/apps/sim/lib/knowledge/mcp/server.ts +++ b/apps/sim/lib/knowledge/mcp/server.ts @@ -1,5 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { isPlainRecord } from '@sim/utils/object' import { parseRetryAfter } from '@sim/utils/retry' @@ -13,11 +14,16 @@ import type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-aut import { v2RateLimits } from '@/lib/api/server/routes/v2-json-route' import type { ApplicationOperation } from '@/lib/core/application' import type { ResourceScope } from '@/lib/core/resource-scope' +import { afterResponse } from '@/lib/core/utils/after-response' import { getBaseUrl } from '@/lib/core/utils/urls' import { organizationSearchChatOperation } from '@/lib/knowledge/application/chat-operations' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { readIndexedKnowledgeDocument } from '@/lib/knowledge/application/read-indexed-document' import { searchKnowledge } from '@/lib/knowledge/application/search' +import { + recordOrganizationSearchMcpActivity, + type SearchMcpActivityInput, +} from '@/lib/knowledge/mcp/activity' import { createKnowledgeDocumentCitation } from '@/lib/knowledge/search/citation' import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' @@ -68,12 +74,20 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe const server = new McpServer({ name: 'Sim Search', version: '1.0.0' }) async function execute( + toolName: SearchMcpActivityInput['toolName'], operation: ApplicationOperation, - run: (registry: ResolvedSecretTraceRegistry) => Promise + toolSignal: AbortSignal, + run: (registry: ResolvedSecretTraceRegistry, signal: AbortSignal) => Promise ): Promise { + const startedAt = performance.now() + const signal = AbortSignal.any([request.signal, toolSignal]) + let outcome: SearchMcpActivityInput['outcome'] = 'error' try { + signal.throwIfAborted() const limited = await v2RateLimits.publicApi.enforce(request, auth, operation) + signal.throwIfAborted() if (limited) { + outcome = 'rate_limited' const retryAfter = parseRetryAfter( limited.headers.get('Retry-After'), Number.MAX_SAFE_INTEGER @@ -84,9 +98,11 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe : `API rate limit exceeded. Retry in ${Math.ceil(retryAfter / 1000)} seconds.` ) } - request.signal.throwIfAborted() - return await run(new ResolvedSecretTraceRegistry()) + const result = await run(new ResolvedSecretTraceRegistry(), signal) + outcome = signal.aborted ? 'cancelled' : result.isError ? 'error' : 'success' + return result } catch (error) { + if (signal.aborted) outcome = 'cancelled' const response = v2CaughtOrchestrationError(error) if (response) { const body: unknown = await response.json() @@ -100,6 +116,27 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe } logger.error('Knowledge MCP operation failed', { operation: operation.id, error }) return toolError('Unable to complete this operation. Please try again.') + } finally { + const activity: SearchMcpActivityInput = { + toolName, + organizationId, + userId: resolvePrincipalSubjectUserId(principal) ?? null, + authKind: principal.kind, + oauthClientId: principal.kind === 'oauth_access_token' ? principal.clientId : null, + clientName: principal.kind === 'oauth_access_token' ? (principal.clientName ?? null) : null, + outcome, + durationMs: Math.round(performance.now() - startedAt), + createdAt: new Date(), + } + logger.info('Knowledge MCP tool completed', { + toolName, + operation: operation.id, + organizationId, + userId: activity.userId, + outcome, + durationMs: activity.durationMs, + }) + afterResponse(() => recordOrganizationSearchMcpActivity(activity)) } } @@ -113,7 +150,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe annotations: READ_ONLY, }, async ({ query, topK, ...filters }, extra) => - execute(knowledgeOperations.search, async (registry) => { + execute('search', knowledgeOperations.search, extra.signal, async (registry, signal) => { if (!searchIndexId) { return projectResult( { @@ -133,7 +170,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe filters, resultSecretRegistry: registry, surface: 'mcp', - signal: AbortSignal.any([request.signal, extra.signal]), + signal, }, request, }) @@ -171,40 +208,43 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe annotations: READ_ONLY, }, async (input, extra) => - execute(knowledgeOperations.readDocument, async (registry) => { - const signal = AbortSignal.any([request.signal, extra.signal]) - signal.throwIfAborted() - if (!input.url && !input.documentId) return toolError('Document not found') - const result = await readIndexedKnowledgeDocument.execute({ - principal, - input: { - organizationId, - target: input.url - ? { kind: 'url', url: input.url } - : { kind: 'id', documentId: input.documentId! }, - limit: input.limit, - offset: input.offset, - aroundChunkIndex: input.aroundChunkIndex, - resultSecretRegistry: registry, - signal, - }, - request, - }) - const { knowledgeBaseId, ...document } = result - return projectResult( - { - ...document, - ...createKnowledgeDocumentCitation({ - scope, - knowledgeBaseId, - documentId: result.documentId, - sourceUrl: result.sourceUrl, - baseUrl: getBaseUrl(), - }), - }, - registry - ) - }) + execute( + 'read_document', + knowledgeOperations.readDocument, + extra.signal, + async (registry, signal) => { + if (!input.url && !input.documentId) return toolError('Document not found') + const result = await readIndexedKnowledgeDocument.execute({ + principal, + input: { + organizationId, + target: input.url + ? { kind: 'url', url: input.url } + : { kind: 'id', documentId: input.documentId! }, + limit: input.limit, + offset: input.offset, + aroundChunkIndex: input.aroundChunkIndex, + resultSecretRegistry: registry, + signal, + }, + request, + }) + const { knowledgeBaseId, ...document } = result + return projectResult( + { + ...document, + ...createKnowledgeDocumentCitation({ + scope, + knowledgeBaseId, + documentId: result.documentId, + sourceUrl: result.sourceUrl, + baseUrl: getBaseUrl(), + }), + }, + registry + ) + } + ) ) server.registerTool( @@ -222,9 +262,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe }, }, async ({ query, ...filters }, extra) => - execute(organizationSearchChatOperation, async (registry) => { - const signal = AbortSignal.any([request.signal, extra.signal]) - signal.throwIfAborted() + execute('chat', organizationSearchChatOperation, extra.signal, async (registry, signal) => { const { organizationSearchChat } = await import('@/lib/knowledge/application/chat') const result = await organizationSearchChat.execute({ principal, diff --git a/apps/sim/lib/knowledge/reranker.test.ts b/apps/sim/lib/knowledge/reranker.test.ts index 75ea03141a2..420bb229833 100644 --- a/apps/sim/lib/knowledge/reranker.test.ts +++ b/apps/sim/lib/knowledge/reranker.test.ts @@ -2,6 +2,8 @@ * @vitest-environment node */ import { setupGlobalFetchMock } from '@sim/testing/mocks' +import { setEnv } from '@sim/testing/mocks/env.mock' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AtomicAdmissionOptions, @@ -13,6 +15,8 @@ const admission = vi.hoisted(() => ({ setCooldown: vi.fn(), cooldowns: new Map(), })) +const { getBYOKKey } = vi.hoisted(() => ({ getBYOKKey: vi.fn() })) +vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey })) vi.mock('@/lib/core/rate-limiter/storage/factory', () => ({ createStorageAdapter: () => ({ consumeTokensAtomically: admission.consume, @@ -31,6 +35,15 @@ const envSnapshot = { ...env } describe('Knowledge reranker model boundary', () => { beforeEach(() => { vi.clearAllMocks() + setEnvFlags({ isHosted: true }) + getBYOKKey.mockResolvedValue(null) + setEnv({ + KB_CONFIG_RERANK_REQUESTS_PER_MINUTE: undefined, + KB_CONFIG_HOSTED_RERANK_REQUESTS_PER_MINUTE: undefined, + COHERE_API_KEY_1: undefined, + COHERE_API_KEY_2: undefined, + COHERE_API_KEY_3: undefined, + }) admission.cooldowns.clear() admission.consume.mockImplementation( async (_reservations: readonly TokenBucketReservation[], options: AtomicAdmissionOptions) => { @@ -55,11 +68,59 @@ describe('Knowledge reranker model boundary', () => { afterEach(() => { vi.useRealTimers() + resetEnvFlagsMock() vi.unstubAllGlobals() for (const key of Object.keys(env)) delete (env as Record)[key] Object.assign(env, envSnapshot) }) + it.each([ + { hosted: true, source: 'env', expectedKey: 'cohere-key', burst: 16, refill: 10 }, + { hosted: true, source: 'rotation', expectedKey: 'rotating-key', burst: 16, refill: 10 }, + { hosted: true, source: 'workspace', expectedKey: 'byok-key', burst: 2, refill: 1 }, + { hosted: true, source: 'organization', expectedKey: 'byok-key', burst: 2, refill: 1 }, + { hosted: false, source: 'user', expectedKey: 'user-key', burst: 2, refill: 1 }, + { hosted: false, source: 'env', expectedKey: 'cohere-key', burst: 2, refill: 1 }, + { hosted: false, source: 'rotation', expectedKey: 'rotating-key', burst: 2, refill: 1 }, + { hosted: false, source: 'workspace', expectedKey: 'byok-key', burst: 2, refill: 1 }, + { hosted: false, source: 'organization', expectedKey: 'byok-key', burst: 2, refill: 1 }, + ])('uses the $source credential budget on hosted=$hosted', async (fixture) => { + setEnvFlags({ isHosted: fixture.hosted }) + const isBYOK = fixture.source === 'workspace' || fixture.source === 'organization' + if (isBYOK) { + getBYOKKey.mockResolvedValue({ apiKey: 'byok-key', scope: fixture.source, isBYOK: true }) + } + if (fixture.source === 'rotation') { + setEnv({ COHERE_API_KEY: undefined, COHERE_API_KEY_1: 'rotating-key' }) + } + const result = await rerank('query', [{ id: 'one', text: 'content' }], { + model: 'rerank-v4.0-fast', + workspaceId: 'fixture-workspace', + apiKey: fixture.hosted || fixture.source === 'user' ? 'user-key' : undefined, + }) + expect(result.isBYOK).toBe(isBYOK) + expect(fetch).toHaveBeenCalledWith( + 'https://api.cohere.com/v2/rerank', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: `Bearer ${fixture.expectedKey}` }), + }) + ) + expect(admission.consume.mock.calls[0][0]).toMatchObject([ + { config: { maxTokens: fixture.burst, refillRate: fixture.refill, refillIntervalMs: 1000 } }, + ]) + if (fixture.source === 'user') expect(getBYOKKey).not.toHaveBeenCalled() + else expect(getBYOKKey).toHaveBeenCalledWith('fixture-workspace', 'cohere') + }) + + it('fails before admission when no credential is configured', async () => { + setEnv({ COHERE_API_KEY: undefined }) + await expect( + rerank('query', [{ id: 'one', text: 'content' }], { model: 'rerank-v4.0-fast' }) + ).rejects.toThrow('No Cohere API key configured') + expect(admission.consume).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + }) + it('projects query and documents at egress while returning the original item', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'encrypted-token' }, @@ -132,10 +193,16 @@ describe('Knowledge reranker model boundary', () => { vi.mocked(fetch).mockResolvedValueOnce( new Response('{}', { status: 429, headers: { 'Retry-After': '2' } }) ) - const first = rerank('first', [{ id: 'one', text: 'content' }], { model: 'rerank-v4.0-fast' }) + const first = rerank('first', [{ id: 'one', text: 'content' }], { + model: 'rerank-v4.0-fast', + workspaceId: 'fixture-workspace-one', + }) await vi.advanceTimersByTimeAsync(0) expect(admission.setCooldown).toHaveBeenCalledOnce() - const second = rerank('second', [{ id: 'two', text: 'content' }], { model: 'rerank-v4.0-fast' }) + const second = rerank('second', [{ id: 'two', text: 'content' }], { + model: 'rerank-v4.0-fast', + workspaceId: 'fixture-workspace-two', + }) await vi.advanceTimersByTimeAsync(1999) expect(fetch).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) @@ -147,7 +214,7 @@ describe('Knowledge reranker model boundary', () => { expect(new Set(reservations.map((item) => item.key)).size).toBe(1) expect(reservations[0].key).toMatch(/^provider:rerank:cohere:[a-f0-9]{64}:requests$/) expect(reservations[0].key).not.toContain('cohere-key') - expect(reservations[0].config.refillRate).toBe(1) + expect(reservations[0].config).toMatchObject({ maxTokens: 16, refillRate: 10 }) }) it('bounds repeated 429s to four attempts with no timer left behind', async () => { diff --git a/apps/sim/lib/knowledge/reranker.ts b/apps/sim/lib/knowledge/reranker.ts index 56440caa2dc..085f8da7d47 100644 --- a/apps/sim/lib/knowledge/reranker.ts +++ b/apps/sim/lib/knowledge/reranker.ts @@ -68,7 +68,7 @@ class RerankAPIError extends Error { async function resolveCohereKey( workspaceId?: string | null, userApiKey?: string -): Promise<{ apiKey: string; isBYOK: boolean }> { +): Promise<{ apiKey: string; isBYOK: boolean; isHostedCredential: boolean }> { /** * Mirrors the agent block hosted-key pattern (`injectHostedKeyIfNeeded`): * on self-hosted the user-supplied key from the block field flows through @@ -76,20 +76,20 @@ async function resolveCohereKey( * platform env, so any user-supplied value is ignored. */ if (!isHosted && userApiKey) { - return { apiKey: userApiKey, isBYOK: false } + return { apiKey: userApiKey, isBYOK: false, isHostedCredential: false } } if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'cohere') if (byokResult) { logger.info('Using BYOK key for Cohere reranker', { scope: byokResult.scope }) - return { apiKey: byokResult.apiKey, isBYOK: true } + return { apiKey: byokResult.apiKey, isBYOK: true, isHostedCredential: false } } } if (env.COHERE_API_KEY) { - return { apiKey: env.COHERE_API_KEY, isBYOK: false } + return { apiKey: env.COHERE_API_KEY, isBYOK: false, isHostedCredential: isHosted } } try { - return { apiKey: getRotatingApiKey('cohere'), isBYOK: false } + return { apiKey: getRotatingApiKey('cohere'), isBYOK: false, isHostedCredential: isHosted } } catch { throw new Error( 'No Cohere API key configured. Set COHERE_API_KEY_1/2/3 (rotation) or COHERE_API_KEY.' @@ -135,7 +135,10 @@ export async function rerank( throw new Error(`Unsupported reranker model: ${options.model}`) } - const { apiKey, isBYOK } = await resolveCohereKey(options.workspaceId, options.apiKey) + const { apiKey, isBYOK, isHostedCredential } = await resolveCohereKey( + options.workspaceId, + options.apiKey + ) const cappedItems = items.length > MAX_DOCUMENTS_PER_RERANK ? items.slice(0, MAX_DOCUMENTS_PER_RERANK) : items if (items.length > MAX_DOCUMENTS_PER_RERANK) { @@ -155,6 +158,7 @@ export async function rerank( async (signal) => { await waitForProviderAdmission({ ...identity, + isHostedCredential, signal, maxWaitMs: Math.max(0, deadlineAt - Date.now()), }) diff --git a/apps/sim/lib/knowledge/search/budget.ts b/apps/sim/lib/knowledge/search/budget.ts index 8011733d926..a80279ec3e1 100644 --- a/apps/sim/lib/knowledge/search/budget.ts +++ b/apps/sim/lib/knowledge/search/budget.ts @@ -85,7 +85,10 @@ export class SearchBudget { if (expired) throw new SearchDeadlineError() recordSearchStageDuration(`${this.leg}.connection_acquire`, performance.now() - started) const timeout = String(this.remaining()) - await tx.execute(sql`SELECT set_config('statement_timeout', ${timeout}, true)`) + /** Interactive retrieval cannot amortize compilation of the access predicates. */ + await tx.execute( + sql`SELECT set_config('statement_timeout', ${timeout}, true), set_config('jit', 'off', true)` + ) this.remaining() return measureSearchStage(stage, () => run(tx)) }) diff --git a/apps/sim/lib/knowledge/search/citation.test.ts b/apps/sim/lib/knowledge/search/citation.test.ts index 982760bce31..e5977bff14c 100644 --- a/apps/sim/lib/knowledge/search/citation.test.ts +++ b/apps/sim/lib/knowledge/search/citation.test.ts @@ -21,6 +21,12 @@ describe('knowledge citations', () => { }) }) + it('preserves Gmail mailbox selection and thread targeting', () => { + const sourceUrl = + 'https://accounts.google.com/AccountChooser?Email=alice%2Bwork%40example.com&continue=https%3A%2F%2Fmail.google.com%2Fmail%2F%3Fauthuser%3Dalice%252Bwork%2540example.com%23all%2F19a3f0123456789' + expect(createKnowledgeDocumentCitation({ ...input, sourceUrl }).citationUrl).toBe(sourceUrl) + }) + it.each([ null, '', diff --git a/apps/sim/lib/knowledge/search/queries.test.ts b/apps/sim/lib/knowledge/search/queries.test.ts index 0dda4659143..b7a5b9ebabe 100644 --- a/apps/sim/lib/knowledge/search/queries.test.ts +++ b/apps/sim/lib/knowledge/search/queries.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { db } from '@sim/db' import { dbChainMockFns, hasMockCondition, @@ -15,7 +16,12 @@ import { WORKSPACE_ACCESS_TOKENS, } from '@/lib/knowledge/access/types' import { buildTagFilterCondition } from '@/lib/knowledge/documents/tag-filter' -import { SearchBudget } from '@/lib/knowledge/search/budget' +import { + SearchBudget, + SearchDeadlineError, + type SearchExecutor, +} from '@/lib/knowledge/search/budget' +import type { SearchStage } from '@/lib/knowledge/search/diagnostics' import { executeKeywordSearch, getStructuredTagFilters, @@ -310,7 +316,174 @@ describe('getStructuredTagFilters', () => { }) }) +describe('KB block vector retrieval', () => { + const params: SearchParams = { + knowledgeBaseIds: ['kb-small'], + topK: 2, + access: { kind: 'workspace', tokens: WORKSPACE_ACCESS_TOKENS }, + queryVector: { vector: '[0.1,0.2]', dimensions: 1536, model: 'text-embedding-3-small' }, + distanceThreshold: 1, + } + + beforeEach(() => resetDbChainMock()) + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it.each([handleVectorOnlySearch, handleTagAndVectorSearch])( + 'does not acquire a connection or start SQL after the KB retrieval deadline', + async (search) => { + const budget = new SearchBudget('vector', performance.now() - 1) + expect( + await search({ + ...params, + budget, + structuredFilters: [ + { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'release' }, + ], + }) + ).toEqual([]) + expect(budget.timedOut).toBe(true) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + } + ) + + it('ranks all candidates in a small KB exactly instead of traversing the shared vector index', async () => { + queueTableRows(schemaMock.embedding, [{ id: 'near' }, { id: 'far' }]) + queueTableRows(schemaMock.embedding, [ + { id: 'far', distance: 0.2 }, + { id: 'near', distance: 0.1 }, + ]) + const rows = await handleVectorOnlySearch(params) + expect(rows.map((row) => row.id)).toEqual(['near', 'far']) + expect(dbChainMockFns.execute).not.toHaveBeenCalled() + expect(Object.keys(dbChainMockFns.select.mock.calls[0][0])).toEqual(['id']) + expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 201) + expect(render(dbChainMockFns.orderBy.mock.calls[0][0]).sql).toContain('+ 0') + expect( + hasMockCondition( + dbChainMockFns.where.mock.calls[1][0], + (node) => + node.type === 'inArray' && + node.column === schemaMock.embedding.id && + JSON.stringify(node.values) === JSON.stringify(['near', 'far']) + ) + ).toBe(true) + }) + + it.each([1, 200, 201])( + 'reports a %i-candidate SQL timeout as partial, not an empty complete result', + async (count) => { + queueTableRows( + schemaMock.embedding, + Array.from({ length: count }, (_, index) => ({ id: `candidate-${index}` })) + ) + dbChainMockFns.orderBy + .mockImplementationOnce(dbChainMockFns.orderBy.getMockImplementation()!) + .mockRejectedValueOnce(new Error('Statement canceled', { cause: { code: '57014' } })) + const result = await retrieveKnowledgeSearch({ + ...params, + query: 'fixture policy', + searchMode: 'vector', + }) + expect(result).toEqual({ + rows: [], + retrieval: { status: 'partial', timedOutLegs: ['vector'] }, + }) + const usesAnn = dbChainMockFns.execute.mock.calls.some(([statement]) => + render(statement).sql.includes('hnsw.iterative_scan') + ) + expect(usesAnn).toBe(count > 200) + } + ) + + it('does not convert an unexpected ranking error into partial retrieval', async () => { + queueTableRows(schemaMock.embedding, [{ id: 'candidate' }]) + const failure = new Error('Connection lost', { cause: { code: '08006' } }) + dbChainMockFns.orderBy + .mockImplementationOnce(dbChainMockFns.orderBy.getMockImplementation()!) + .mockRejectedValueOnce(failure) + await expect( + retrieveKnowledgeSearch({ ...params, query: 'fixture policy', searchMode: 'vector' }) + ).rejects.toBe(failure) + }) + + it('reports incomplete retrieval for 18 expired pool waiters without starting their SQL later', async () => { + vi.useFakeTimers() + const release: Array<() => void> = [] + const transactions: Array> = [] + vi.spyOn(db, 'transaction').mockImplementation((callback) => { + const transaction = new Promise((resolve) => release.push(resolve)).then(() => + callback(db as never) + ) + transactions.push(transaction) + return transaction as ReturnType + }) + const pending = Promise.all( + Array.from({ length: 18 }, (_, index) => + retrieveKnowledgeSearch({ + ...params, + knowledgeBaseIds: [`kb-${index}`], + query: 'fixture policy', + searchMode: 'vector', + vectorBudgetMs: 50, + }) + ) + ) + await vi.advanceTimersByTimeAsync(60) + const results = await pending + expect(results).toHaveLength(18) + for (const result of results) { + expect(result).toEqual({ + rows: [], + retrieval: { status: 'partial', timedOutLegs: ['vector'] }, + }) + } + for (const resume of release) resume() + const settled = await Promise.allSettled(transactions) + expect(settled).toHaveLength(18) + for (const transaction of settled) { + expect(transaction.status).toBe('rejected') + if (transaction.status === 'rejected') + expect(transaction.reason).toBeInstanceOf(SearchDeadlineError) + } + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.execute).not.toHaveBeenCalled() + }) + + it.each([1, 201])( + 'shares the remaining SQL budget between the probe and %i-candidate ranking', + async (count) => { + vi.spyOn(performance, 'now').mockReturnValue(0) + queueTableRows( + schemaMock.embedding, + Array.from({ length: count }, (_, index) => ({ id: `candidate-${index}` })) + ) + const query = SearchBudget.prototype.query + vi.spyOn(SearchBudget.prototype, 'query').mockImplementation(async function ( + this: SearchBudget, + stage: SearchStage, + run: (executor: SearchExecutor) => PromiseLike + ) { + const runQuery: SearchBudget['query'] = query.bind(this) + const result = await runQuery(stage, run) + if (stage === 'vector.probe') vi.spyOn(performance, 'now').mockReturnValue(30) + return result + }) + await handleVectorOnlySearch({ ...params, budget: new SearchBudget('vector', 100) }) + const timeouts = dbChainMockFns.execute.mock.calls + .map(([statement]) => render(statement)) + .filter((statement) => statement.sql.includes('statement_timeout')) + .map((statement) => statement.params[0]) + expect(timeouts).toEqual(count === 1 ? ['100', '70'] : ['100', '70', '70']) + } + ) +}) + describe('vector scan settings', () => { + const largeProbe = Array.from({ length: 201 }, (_, index) => ({ id: `probe-${index}` })) const params: SearchParams = { knowledgeBaseIds: ['kb-small'], topK: 2, @@ -321,13 +494,14 @@ describe('vector scan settings', () => { beforeEach(() => { resetDbChainMock() + queueTableRows(schemaMock.embedding, largeProbe) }) afterEach(() => { vi.useRealTimers() }) - it('tunes a small workspace search before querying its KB scope, preserving distance ordering', async () => { + it('tunes an overflowing KB scope without limiting ANN to the probe prefix', async () => { queueTableRows(schemaMock.embedding, [ { id: 'far', distance: 0.2 }, { id: 'near', distance: 0.1 }, @@ -341,11 +515,11 @@ describe('vector scan settings', () => { params: ['20000'], }) expect(dbChainMockFns.execute.mock.invocationCallOrder[0]).toBeLessThan( - dbChainMockFns.select.mock.invocationCallOrder[0] + dbChainMockFns.select.mock.invocationCallOrder[1] ) expect( hasMockCondition( - dbChainMockFns.where.mock.calls[0][0], + dbChainMockFns.where.mock.calls[1][0], (node) => node.type === 'inArray' && node.column === schemaMock.embedding.knowledgeBaseId && @@ -354,10 +528,16 @@ describe('vector scan settings', () => { ) ).toBe(true) expect(dbChainMockFns.limit).toHaveBeenCalledWith(2) - expect(dbChainMockFns.limit).toHaveBeenCalledOnce() - expect(Object.keys(dbChainMockFns.select.mock.calls[0][0])).toEqual(['id', 'distance']) - const ranked = dbChainMockFns.from.mock.calls[1][0] - expect(dbChainMockFns.select.mock.calls[1][0].distance).toBe(ranked.distance) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2) + expect( + hasMockCondition( + dbChainMockFns.where.mock.calls[1][0], + (node) => node.type === 'inArray' && node.column === schemaMock.embedding.id + ) + ).toBe(false) + expect(Object.keys(dbChainMockFns.select.mock.calls[1][0])).toEqual(['id', 'distance']) + const ranked = dbChainMockFns.from.mock.calls[2][0] + expect(dbChainMockFns.select.mock.calls[2][0].distance).toBe(ranked.distance) expect(dbChainMockFns.innerJoin).toHaveBeenCalledWith( schemaMock.embedding, expect.objectContaining({ @@ -367,20 +547,22 @@ describe('vector scan settings', () => { }) ) expect(dbChainMockFns.orderBy).toHaveBeenLastCalledWith(ranked.distance) - expect(dbChainMockFns.limit.mock.invocationCallOrder[0]).toBeLessThan( - dbChainMockFns.select.mock.invocationCallOrder[1] + expect(dbChainMockFns.limit.mock.invocationCallOrder[1]).toBeLessThan( + dbChainMockFns.select.mock.invocationCallOrder[2] ) }) - it('shares one local configuration across all KB vector legs and trims their sorted merge', async () => { + it('tunes each KB leg and trims their sorted merge', async () => { const knowledgeBaseIds = ['kb-1', 'kb-2', 'kb-3', 'kb-4', 'kb-5'] - for (let index = 0; index < knowledgeBaseIds.length; index++) + for (let index = 0; index < knowledgeBaseIds.length; index++) { + if (index > 0) queueTableRows(schemaMock.embedding, largeProbe) queueTableRows(schemaMock.embedding, [{ id: `row-${index}`, distance: (5 - index) / 10 }]) + } const rows = await handleVectorOnlySearch({ ...params, knowledgeBaseIds }) expect(rows.map((row) => row.id)).toEqual(['row-4', 'row-3']) - expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() - expect(dbChainMockFns.execute).toHaveBeenCalledOnce() - expect(dbChainMockFns.select).toHaveBeenCalledTimes(10) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(5) + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(5) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(15) for (const kbId of knowledgeBaseIds) expect( dbChainMockFns.where.mock.calls.some(([condition]) => @@ -432,20 +614,23 @@ describe('vector scan settings', () => { queueTableRows(schemaMock.embedding, [{ id: 'fallback', distance: 0.1 }]) expect((await handleVectorOnlySearch(params)).map((row) => row.id)).toEqual(['fallback']) expect(dbChainMockFns.execute).toHaveBeenCalledOnce() - expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) await handleVectorOnlySearch(params) expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1) + queueTableRows(schemaMock.embedding, largeProbe) await handleVectorOnlySearch(params) expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(2) expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) }) - it('propagates an unrelated settings failure without issuing a query or disabling later tuning', async () => { + it('propagates an unrelated settings failure without ranking or disabling later tuning', async () => { const failure = { code: '08006', message: 'Connection lost' } dbChainMockFns.execute.mockRejectedValueOnce(failure) await expect(handleVectorOnlySearch(params)).rejects.toBe(failure) - expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.select).toHaveBeenCalledOnce() + expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() + queueTableRows(schemaMock.embedding, largeProbe) await handleVectorOnlySearch(params) expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) }) @@ -456,7 +641,8 @@ describe('vector scan settings', () => { .mockImplementationOnce(dbChainMockFns.orderBy.getMockImplementation()!) .mockRejectedValueOnce(failure) await expect(handleVectorOnlySearch(params)).rejects.toBe(failure) - expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) + queueTableRows(schemaMock.embedding, largeProbe) await handleVectorOnlySearch(params) expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) }) @@ -477,9 +663,10 @@ describe('workspace search filters before ranking', () => { } beforeEach(() => resetDbChainMock()) - function expectScopeOnEveryQuery() { - expect(dbChainMockFns.where).toHaveBeenCalled() - for (const [condition] of dbChainMockFns.where.mock.calls) { + function expectScopeOnEveryQuery(skipIdentityProbe = false) { + const queries = dbChainMockFns.where.mock.calls.slice(skipIdentityProbe ? 1 : 0) + expect(queries.length).toBeGreaterThan(0) + for (const [condition] of queries) { expect( hasMockCondition( condition, @@ -512,13 +699,15 @@ describe('workspace search filters before ranking', () => { it.each([handleVectorOnlySearch, handleTagOnlySearch, handleTagAndVectorSearch])( 'applies the full document scope to vector and tag searches', async (search) => { + const hasIdentityProbe = search !== handleTagOnlySearch + if (hasIdentityProbe) queueTableRows(schemaMock.embedding, [{ id: 'candidate' }]) await search({ ...params, structuredFilters: [ { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'launch' }, ], }) - expectScopeOnEveryQuery() + expectScopeOnEveryQuery(hasIdentityProbe) } ) @@ -573,6 +762,7 @@ describe('live repository authorization follows ranked candidates', () => { structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'release' }], } + const probePages: Array> = [] const candidatePages: Array> = [] const rerankPages: Array>> = [] const keywordPages: Array>> = [] @@ -585,17 +775,20 @@ describe('live repository authorization follows ranked candidates', () => { beforeEach(() => { resetDbChainMock() + probePages.length = 0 candidatePages.length = 0 rerankPages.length = 0 keywordPages.length = 0 dbChainMockFns.execute.mockImplementation(async (query) => - render(query).sql.includes('WITH visible_search_documents') - ? (candidatePages.shift() ?? []) - : render(query).sql.includes('WITH scored_search_candidates') - ? (rerankPages.shift() ?? []) - : render(query).sql.includes('WITH visible_keyword_documents') - ? (keywordPages.shift() ?? []) - : [] + render(query).sql.includes('SELECT scoped_chunk.id') + ? (probePages.shift() ?? []) + : render(query).sql.includes('WITH visible_search_documents') + ? (candidatePages.shift() ?? []) + : render(query).sql.includes('WITH scored_search_candidates') + ? (rerankPages.shift() ?? []) + : render(query).sql.includes('WITH visible_keyword_documents') + ? (keywordPages.shift() ?? []) + : [] ) getForConnectors.mockReset().mockResolvedValue(allowed) }) @@ -603,9 +796,8 @@ describe('live repository authorization follows ranked candidates', () => { afterEach(() => vi.useRealTimers()) it('bounds broad vector ranking before metadata and reorders relaxed candidates before trimming', async () => { - queueTableRows( - schemaMock.embeddingSearch, - Array.from({ length: 200 }, (_, index) => candidate(`probe-${index}`, 'allowed-source')) + probePages.push( + Array.from({ length: 400 }, (_, index) => candidate(`probe-${index}`, 'allowed-source')) ) queueCandidates(Array.from({ length: 400 }, (_, index) => ({ id: `candidate-${index}` }))) queueRerank([ @@ -627,6 +819,8 @@ describe('live repository authorization follows ranked candidates', () => { render(query).sql.includes('WITH visible_search_documents') )![0] expect(render(candidateQuery).sql).toContain('MATERIALIZED') + expect(render(candidateQuery).sql).toContain('CROSS JOIN LATERAL') + expect(render(candidateQuery).sql).toContain('LIMIT 1') expect(JSON.stringify(candidateQuery)).toContain('required_clause') expect(JSON.stringify(candidateQuery)).toContain('subvector') const rankQuery = dbChainMockFns.execute.mock.calls.find(([query]) => @@ -640,16 +834,18 @@ describe('live repository authorization follows ranked candidates', () => { }) it('finishes empty scopes after the bounded probe without scanning HNSW or calling providers', async () => { - queueTableRows(schemaMock.embeddingSearch, []) + probePages.push([]) expect(await handleVectorOnlySearch({ ...params, structuredFilters: undefined })).toEqual([]) - expect(dbChainMockFns.select).toHaveBeenCalledOnce() - expect(dbChainMockFns.limit).toHaveBeenCalledExactlyOnceWith(200) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + const probe = render(dbChainMockFns.execute.mock.calls[0][0]) + expect(probe.sql).toContain('CROSS JOIN LATERAL') + expect(probe.params.filter((value) => value === 400)).toHaveLength(2) expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() expect(getForConnectors).not.toHaveBeenCalled() }) it('reads vectors only for the bounded IDs when a broad scope has few candidates', async () => { - queueTableRows(schemaMock.embeddingSearch, [candidate('selected', 'allowed-source')]) + probePages.push([candidate('selected', 'allowed-source')]) queueTableRows(schemaMock.embedding, [candidate('selected', 'allowed-source')]) queueTableRows(schemaMock.embedding, [ { id: 'selected', content: 'Verified small scope', distance: 0.1 }, @@ -657,11 +853,13 @@ describe('live repository authorization follows ranked candidates', () => { expect(await handleVectorOnlySearch({ ...params, structuredFilters: undefined })).toEqual([ { id: 'selected', content: 'Verified small scope', distance: 0.1 }, ]) - expect(Object.keys(dbChainMockFns.select.mock.calls[0][0])).toEqual(['id']) - expect(JSON.stringify(dbChainMockFns.where.mock.calls[0][0])).not.toContain('<=>') + const probe = dbChainMockFns.execute.mock.calls[0][0] + expect(render(probe).sql).toContain('SELECT scoped_chunk.id') + expect(render(probe).sql).not.toContain('<=>') + expect(JSON.stringify(probe)).toContain('required_clause') expect( hasMockCondition( - dbChainMockFns.where.mock.calls[1][0], + dbChainMockFns.where.mock.calls[0][0], (node) => node.type === 'inArray' && node.column === schemaMock.embedding.id && @@ -673,11 +871,34 @@ describe('live repository authorization follows ranked candidates', () => { expect(getForConnectors).toHaveBeenCalledExactlyOnceWith(['allowed-source'], undefined) }) + it.each([199, 200, 399])( + 'ranks an exhausted scope of %s chunks once without repeating candidate search', + async (count) => { + const probe = Array.from({ length: count }, (_, index) => ({ id: `chunk-${index}` })) + probePages.push(probe) + queueTableRows(schemaMock.embedding, [candidate('chunk-0', 'allowed-source')]) + queueTableRows(schemaMock.embedding, [ + { id: 'chunk-0', content: 'Authorized passage', distance: 0.1 }, + ]) + const rows = await handleVectorOnlySearch({ ...params, structuredFilters: undefined }) + expect(rows.map((row) => row.id)).toEqual(['chunk-0']) + expect(dbChainMockFns.execute).toHaveBeenCalledOnce() + expect( + hasMockCondition( + dbChainMockFns.where.mock.calls[0][0], + (node) => + node.type === 'inArray' && + node.column === schemaMock.embedding.id && + Array.isArray(node.values) && + node.values.length === count + ) + ).toBe(true) + expect(dbChainMockFns.orderBy).toHaveBeenCalledOnce() + } + ) + it('scans the filtered projection when ANN cannot fill its limit', async () => { - queueTableRows( - schemaMock.embeddingSearch, - Array.from({ length: 200 }, (_, index) => ({ id: `probe-${index}` })) - ) + probePages.push(Array.from({ length: 400 }, (_, index) => ({ id: `probe-${index}` }))) queueCandidates([{ id: 'selected' }], 1) queueRerank([candidate('selected', 'allowed-source')]) queueTableRows(schemaMock.embedding, [ @@ -691,21 +912,23 @@ describe('live repository authorization follows ranked candidates', () => { )![0] expect(render(candidateQuery).sql).toContain('UNION ALL') expect(render(candidateQuery).sql).toContain('+ 0') + expect(render(candidateQuery).sql).toContain('filtered_scores AS MATERIALIZED') + expect(render(candidateQuery).sql).toContain('ORDER BY filtered_scores.distance + 0') expect(JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)![0])).toContain( 'github_read_grant' ) }) it('advances past candidate pages that hydrate no current readable content', async () => { - const probe = Array.from({ length: 200 }, (_, index) => ({ id: `probe-${index}` })) + const probe = Array.from({ length: 400 }, (_, index) => ({ id: `probe-${index}` })) const identities = Array.from({ length: 400 }, (_, index) => ({ id: `candidate-${index}` })) - queueTableRows(schemaMock.embeddingSearch, probe) + probePages.push(probe) queueCandidates(identities) queueRerank( Array.from({ length: 20 }, (_, index) => candidate(`candidate-${index}`, 'allowed-source')) ) queueTableRows(schemaMock.embedding, []) - queueTableRows(schemaMock.embeddingSearch, probe) + probePages.push(probe) queueCandidates(identities) queueRerank([candidate('selected', 'allowed-source')]) queueTableRows(schemaMock.embedding, [ @@ -722,17 +945,17 @@ describe('live repository authorization follows ranked candidates', () => { }) it('sorts hydrated candidates across pages by their original-vector distance', async () => { - const probe = Array.from({ length: 200 }, (_, index) => + const probe = Array.from({ length: 400 }, (_, index) => candidate(`probe-${index}`, 'allowed-source') ) - queueTableRows(schemaMock.embeddingSearch, probe) + probePages.push(probe) queueCandidates(Array.from({ length: 400 }, (_, index) => ({ id: `candidate-${index}` }))) queueRerank([ { ...candidate('far', 'allowed-source'), distance: 0.7 }, ...Array.from({ length: 19 }, (_, index) => candidate(`hidden-${index}`, 'allowed-source')), ]) queueTableRows(schemaMock.embedding, [{ id: 'far', content: 'Far result', distance: 0.7 }]) - queueTableRows(schemaMock.embeddingSearch, probe) + probePages.push(probe) queueCandidates(Array.from({ length: 400 }, (_, index) => ({ id: `candidate-${index}` }))) queueRerank([ candidate('near', 'allowed-source'), diff --git a/apps/sim/lib/knowledge/search/queries.ts b/apps/sim/lib/knowledge/search/queries.ts index bf541a2fd08..e67a126fa5e 100644 --- a/apps/sim/lib/knowledge/search/queries.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -56,6 +56,7 @@ const CANDIDATE_HNSW_SCAN_MEM_MULTIPLIER = '2' const MIN_VECTOR_RERANK_CANDIDATES = 400 const MAX_VECTOR_RERANK_CANDIDATES = 1600 const VECTOR_RERANK_OVERSAMPLING = 8 +const MAX_EXACT_KB_VECTOR_CANDIDATES = 200 /** How long to stop trying the iterative-scan settings after the server rejected them. */ const HNSW_SETTINGS_UNSUPPORTED_RETRY_MS = 10 * 60 * 1000 @@ -777,43 +778,95 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise - selectRankedVectorResults( - executor, - distance, - [ - kbScope, - ...getVisibilityConditions(access, params.filters), - sql`${distance} < ${distanceThreshold}`, - ], - limit - ) - /** * A relaxed-order iterative scan may hand rows back slightly out of distance * order, so both paths re-sort in memory before trimming to `topK`. */ if (strategy.useParallel) { const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5 - const allResults = await withVectorScanSettings(async (executor) => { - const parallelResults = await Promise.all( - knowledgeBaseIds.map((kbId) => - vectorLeg(executor, eq(embedding.knowledgeBaseId, kbId), parallelLimit) - ) + const allResults: SearchResult[] = [] + /** Keep one active KB leg per request so multi-base searches cannot monopolize the pool. */ + for (const kbId of knowledgeBaseIds) { + allResults.push( + ...(await selectScopedVectorResults( + params, + distance, + eq(embedding.knowledgeBaseId, kbId), + parallelLimit + )) ) - return parallelResults.flat() - }) + if (params.budget?.timedOut) break + } return allResults.sort((a, b) => a.distance - b.distance).slice(0, topK) } - const rows = await withVectorScanSettings((executor) => - vectorLeg(executor, inArray(embedding.knowledgeBaseId, knowledgeBaseIds), topK) + const rows = await selectScopedVectorResults( + params, + distance, + inArray(embedding.knowledgeBaseId, knowledgeBaseIds), + topK ) return rows.sort((a, b) => a.distance - b.distance) } +/** + * KB runs without a human subject still need bounded small-scope ranking. Probe only chunk + * identities, then reapply every access and visibility predicate before ranking and hydration. + * An overflowing probe selects ANN over the whole scope, never a truncated candidate prefix. + */ +async function selectScopedVectorResults( + params: SearchParams, + distance: SQL, + kbScope: SQL | undefined, + limit: number, + tagConditions: (SQL | undefined)[] = [] +): Promise { + try { + const probe = await runSearchQuery(params.budget, 'vector.probe', (executor) => + executor + .select({ id: embedding.id }) + .from(embedding) + .where(and(kbScope, eq(embedding.enabled, true), ...tagConditions)) + .limit(MAX_EXACT_KB_VECTOR_CANDIDATES + 1) + ) + if (probe.length === 0) return [] + const conditions = [ + kbScope, + ...getVisibilityConditions(params.access, params.filters), + ...tagConditions, + sql`${distance} < ${params.distanceThreshold}`, + ] + if (probe.length <= MAX_EXACT_KB_VECTOR_CANDIDATES) { + annotateSearchDiagnostics({ vectorRanking: 'exact' }) + return await runSearchQuery(params.budget, 'vector.exact', (executor) => + selectRankedVectorResults( + executor, + distance, + [ + ...conditions, + inArray( + embedding.id, + probe.map((candidate) => candidate.id) + ), + ], + limit, + true + ) + ) + } + return await withVectorScanSettings( + (executor) => selectRankedVectorResults(executor, distance, conditions, limit), + params.budget + ) + } catch (error) { + if (!params.budget?.isTimeout(error)) throw error + return [] + } +} + /** * Bound ANN traversal and rerank a small candidate pool against the original vectors. - * Materialized document identities let PostgreSQL filter before computing distances. + * Nearest-neighbor traversal drives document visibility lookups, avoiding a sort of + * every visible chunk when the planner underestimates the caller's accessible corpus. * An underfilled index scan expands to a filtered scan within the same statement snapshot. * Live source authorization and content hydration still run after candidate ranking. */ @@ -860,10 +913,6 @@ async function selectLiveVectorResults( ), excludeSearchSources(excludedSources), ] - const candidateVisibility = [ - eq(embeddingSearch.enabled, true), - ...candidateDocumentVisibility, - ] /** Explicitly filtered scopes use exact ordering instead of HNSW traversal. */ const exactPage = async (candidateIds?: string[]) => { annotateSearchDiagnostics({ vectorRanking: 'exact' }) @@ -888,22 +937,29 @@ async function selectLiveVectorResults( if (params.filters?.documentIds?.length || params.structuredFilters?.length) { return exactPage() } - /** Probe visibility without vector reads; revoked scopes must not detoast the corpus. */ + /** + * Enumerate bounded chunk identities from visible documents. The lateral limit keeps + * the probe on document-indexed lookups instead of hashing the entire vector projection. + * An exhausted probe fits in the rerank pool and needs only one exact ranking pass. + */ const probe = await runSearchQuery(params.budget, 'vector.probe', (executor) => - executor - .select({ id: embeddingSearch.id }) - .from(embeddingSearch) - .innerJoin(document, eq(document.id, embeddingSearch.documentId)) - .where( - and( + executor.execute<{ id: string }>(sql` + SELECT scoped_chunk.id FROM ${document} + CROSS JOIN LATERAL ( + SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch} + WHERE ${and( + eq(embeddingSearch.documentId, document.id), inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds), - ...candidateVisibility - ) - ) - .limit(LIVE_SEARCH_PAGE_SIZE) + eq(embeddingSearch.enabled, true) + )} + LIMIT ${candidateLimit} + ) AS scoped_chunk + WHERE ${and(...candidateDocumentVisibility)} + LIMIT ${candidateLimit} + `) ) if (probe.length === 0) return { candidates: [], nextOffset: offset } - if (probe.length < LIVE_SEARCH_PAGE_SIZE) { + if (probe.length < candidateLimit) { return exactPage(probe.map((candidate) => candidate.id)) } annotateSearchDiagnostics({ @@ -916,13 +972,12 @@ async function selectLiveVectorResults( queryVector.model ), }) - const candidateConditions = and( - inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds), - eq(embeddingSearch.enabled, true), - sql`${embeddingSearch.documentId} IN (SELECT id FROM visible_search_documents)`, - sql`EXISTS (SELECT 1 FROM visible_search_documents)` - ) - /** Materialize only document identities, so neither the hash table nor ACL checks carry vectors. */ + /** + * LIMIT keeps document authorization downstream of vector traversal, with a primary-key + * lookup per candidate. Only an underfilled ANN scan materializes the visible document set. + * Its exact fallback scores the compact projection once, then joins scalar distances to + * visible identities; it cannot turn into a random vector lookup for every document. + */ const identities = await withVectorScanSettings( (executor) => executor.execute<{ id: string; initial_count: number }>(sql` @@ -931,16 +986,32 @@ async function selectLiveVectorResults( WHERE ${and(...candidateDocumentVisibility)} ), initial_candidates AS MATERIALIZED ( SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch} - WHERE ${candidateConditions} + CROSS JOIN LATERAL ( + SELECT 1 FROM ${document} + WHERE ${and(eq(document.id, embeddingSearch.documentId), ...candidateDocumentVisibility)} + LIMIT 1 + ) AS visible + WHERE ${and( + inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds), + eq(embeddingSearch.enabled, true) + )} ORDER BY ${candidateDistance} LIMIT ${candidateLimit} + ), filtered_scores AS MATERIALIZED ( + SELECT ${embeddingSearch.id} AS id, ${embeddingSearch.documentId} AS document_id, + ${candidateDistance} AS distance FROM ${embeddingSearch} + WHERE ${and( + inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds), + eq(embeddingSearch.enabled, true) + )} + AND (SELECT count(*) FROM initial_candidates) < ${candidateLimit} ), candidates AS ( SELECT id FROM initial_candidates WHERE (SELECT count(*) FROM initial_candidates) >= ${candidateLimit} UNION ALL ( - SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch} - WHERE ${candidateConditions} - AND (SELECT count(*) FROM initial_candidates) < ${candidateLimit} - ORDER BY (${candidateDistance}) + 0, ${embeddingSearch.id} + SELECT filtered_scores.id FROM filtered_scores + INNER JOIN visible_search_documents ON visible_search_documents.id = filtered_scores.document_id + WHERE (SELECT count(*) FROM initial_candidates) < ${candidateLimit} + ORDER BY filtered_scores.distance + 0, filtered_scores.id LIMIT ${candidateLimit} ) ) SELECT id, (SELECT count(*)::int FROM initial_candidates) AS initial_count FROM candidates @@ -1004,14 +1075,15 @@ function selectRankedVectorResults( executor: SearchExecutor, distance: SQL, conditions: (SQL | undefined)[], - limit: number + limit: number, + exact = false ) { const ranked = executor .select({ id: embedding.id, distance: distance.as('distance') }) .from(embedding) .innerJoin(document, eq(embedding.documentId, document.id)) .where(and(...conditions)) - .orderBy(distance) + .orderBy(exact ? sql`(${distance}) + 0` : distance) .limit(limit) .as('ranked_embeddings') @@ -1298,18 +1370,12 @@ export async function handleTagAndVectorSearch(params: SearchParams): Promise - selectRankedVectorResults( - executor, - distance, - [ - inArray(embedding.knowledgeBaseId, knowledgeBaseIds), - ...getVisibilityConditions(access, params.filters), - ...tagFilterConditions, - sql`${distance} < ${distanceThreshold}`, - ], - topK - ) + const rows = await selectScopedVectorResults( + params, + distance, + inArray(embedding.knowledgeBaseId, knowledgeBaseIds), + topK, + tagFilterConditions ) return rows.sort((a, b) => a.distance - b.distance) } diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 45d53d4764c..10900148baf 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -1,11 +1,9 @@ import { db, dbFor } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' import { organization, usageLog, user as userTable, workflow, - workflowExecutionLogColumns, workflowExecutionLogs, workspace, } from '@sim/db/schema' @@ -653,7 +651,7 @@ export class ExecutionLogger implements IExecutionLoggerService { // Check if execution log already exists (idempotency check) const existingLog = await execDb - .select(workflowExecutionLogColumns) + .select() .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.executionId, executionId)) .limit(1) @@ -699,7 +697,7 @@ export class ExecutionLogger implements IExecutionLoggerService { const startTime = new Date() const [workflowLog] = await execDb - .insert(withInsertColumns(workflowExecutionLogs, workflowExecutionLogColumns)) + .insert(workflowExecutionLogs) .values({ id: generateId(), workflowId, @@ -724,7 +722,7 @@ export class ExecutionLogger implements IExecutionLoggerService { traceSpanCount: 0, }, }) - .returning(workflowExecutionLogColumns) + .returning() execLog.debug('Created workflow log', { logId: workflowLog.id }) @@ -958,7 +956,7 @@ export class ExecutionLogger implements IExecutionLoggerService { execLog.debug('Completing workflow execution', { isResume }) const [existingLog] = await execDb - .select(workflowExecutionLogColumns) + .select() .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.executionId, executionId)) .limit(1) @@ -1206,11 +1204,11 @@ export class ExecutionLogger implements IExecutionLoggerService { : sql`${workflowExecutionLogs.status} != 'cancelled'` ) ) - .returning(workflowExecutionLogColumns) + .returning() if (!log) { const [currentLog] = await tx - .select(workflowExecutionLogColumns) + .select() .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.executionId, executionId)) .limit(1) @@ -1445,7 +1443,7 @@ export class ExecutionLogger implements IExecutionLoggerService { async getWorkflowExecution(executionId: string): Promise { const [workflowLog] = await execDb - .select(workflowExecutionLogColumns) + .select() .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.executionId, executionId)) .limit(1) diff --git a/apps/sim/lib/mcp/application/managed-auth-provider.ts b/apps/sim/lib/mcp/application/managed-auth-provider.ts index cf631231024..93b7dc1e062 100644 --- a/apps/sim/lib/mcp/application/managed-auth-provider.ts +++ b/apps/sim/lib/mcp/application/managed-auth-provider.ts @@ -15,12 +15,15 @@ export async function loadManagedMcpAuthProvider( ): Promise { const current = await loadManagedMcpRuntimeCredential(credentialId, workspaceId) if (current.scope.kind === 'organization') { - await requireOrganizationAccountsWorkspaceAccess({ - workspaceId, - workspaceOrganizationId: current.scope.organizationId, - organizationId: current.scope.organizationId, - credentialGroupId: current.credentialGroupId, - }) + await requireOrganizationAccountsWorkspaceAccess( + { + workspaceId, + workspaceOrganizationId: current.scope.organizationId, + organizationId: current.scope.organizationId, + credentialGroupId: current.credentialGroupId, + }, + current.credentialType + ) } const clientRow = await getOrCreateOauthRow({ mcpServerId: current.mcpServerId, diff --git a/apps/sim/lib/mcp/application/managed-connections.test.ts b/apps/sim/lib/mcp/application/managed-connections.test.ts index b3b22e831ea..9a85b92e116 100644 --- a/apps/sim/lib/mcp/application/managed-connections.test.ts +++ b/apps/sim/lib/mcp/application/managed-connections.test.ts @@ -30,6 +30,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.permission, })) +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import { listManagedMcpConnectionsUseCase } from '@/lib/mcp/application/managed-connections' const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } @@ -60,7 +61,11 @@ describe('managed MCP connection catalog', () => { billedAccountUserId: 'owner-1', }) mocks.permission.mockResolvedValue('read') - mocks.requireAccess.mockResolvedValue(undefined) + mocks.requireAccess.mockResolvedValue( + buildOrganizationAccountAccessPolicy('group-1', [ + { workspaceId: 'workspace-1', access: { mode: 'all' } }, + ]) + ) }) it('uses organization ownership and workspace access before exposing credential operations', async () => { diff --git a/apps/sim/lib/mcp/application/managed-connections.ts b/apps/sim/lib/mcp/application/managed-connections.ts index 19f7815f2a4..c3e26b82102 100644 --- a/apps/sim/lib/mcp/application/managed-connections.ts +++ b/apps/sim/lib/mcp/application/managed-connections.ts @@ -4,9 +4,13 @@ import { and, asc, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { requireOrganizationAccountsWorkspaceAccess } from '@/lib/credential-groups/application/organization-workspace-access' +import { organizationAccountPolicyAllowsWorkspace } from '@/lib/credential-groups/application/workspace-access-policy' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' -import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { + getManagedMcpConnector, + MANAGED_MCP_CONNECTOR_IDS, +} from '@/lib/credential-groups/managed-mcp-connectors' import { resolveMcpWorkspaceContext } from '@/lib/mcp/application/context' import { mcpServerOperations } from '@/lib/mcp/application/operations' import type { McpToolSchema } from '@/lib/mcp/types' @@ -48,14 +52,19 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase organizationId, }) if (!group) return { servers: [], tools: [] } - await requireOrganizationAccountsWorkspaceAccess({ + const policy = await requireOrganizationAccountsWorkspaceAccess({ ...context, organizationId, credentialGroupId: group.credentialGroupId, }) + const allowedConnectorIds = MANAGED_MCP_CONNECTOR_IDS.filter((id) => + organizationAccountPolicyAllowsWorkspace(policy, context.workspaceId, `mcp:${id}`) + ) + if (!allowedConnectorIds.length) return { servers: [], tools: [] } const managedCatalogScope = () => and( eq(credential.organizationId, organizationId), + inArray(mcpServers.managedConnectorId, allowedConnectorIds), eq(credentialGroup.id, group.credentialGroupId), eq(credential.mcpOauthConfigVersion, mcpServers.oauthConfigVersion), eq(credential.type, 'managed_mcp'), diff --git a/apps/sim/lib/mcp/client.test.ts b/apps/sim/lib/mcp/client.test.ts index bb7784bdbed..74025097399 100644 --- a/apps/sim/lib/mcp/client.test.ts +++ b/apps/sim/lib/mcp/client.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockLogger, mockSdkConnect, mockSdkListTools, mockPinnedClose } = vi.hoisted(() => ({ mockLogger: { @@ -100,6 +100,10 @@ describe('McpClient notification handler', () => { vi.mocked(getMaxExecutionTimeout).mockReturnValue(30_000) }) + afterEach(() => { + vi.useRealTimers() + }) + it('preserves authorization-required errors raised by a locked credential reload', async () => { const error = new McpOauthAuthorizationRequiredError('server-1', 'Test Server') mockSdkConnect.mockRejectedValueOnce(error) @@ -199,6 +203,7 @@ describe('McpClient notification handler', () => { }) it('clamps a configured tools/list timeout to the absolute discovery ceiling', async () => { + vi.useFakeTimers() vi.mocked(getMaxExecutionTimeout).mockReturnValue(120_000) const client = new McpClient({ config: { ...createConfig(), timeout: 300_000 }, @@ -210,7 +215,7 @@ describe('McpClient notification handler', () => { expect(mockSdkListTools).toHaveBeenCalledWith( undefined, - expect.objectContaining({ timeout: 60_000, maxTotalTimeout: expect.any(Number) }) + expect.objectContaining({ timeout: 60_000, maxTotalTimeout: 60_000 }) ) }) diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index d28450cba11..a9f10ec759f 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -80,6 +80,7 @@ import { getServiceAccountToken, refreshTokenIfNeeded, resolveCredentialTokenBundle, + ServiceAccountTokenError, } from '@/lib/oauth/credential-service' import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' @@ -453,4 +454,75 @@ describe('Google service-account token minting', () => { expect(mocks.decryptSecret).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledTimes(1) }) + + it('retains the Google error code for actionable setup failures', async () => { + queueTableRows(credential, [row]) + fetchMock.mockResolvedValueOnce( + Response.json( + { error: 'unauthorized_client', error_description: RAW_PROVIDER_ERROR }, + { status: 401 } + ) + ) + const error = await getServiceAccountToken( + 'credential-1', + [driveScope], + 'admin@example.com' + ).catch((error: unknown) => error) + expect(error).toBeInstanceOf(ServiceAccountTokenError) + expect(error).toMatchObject({ + statusCode: 401, + errorCode: 'unauthorized_client', + errorDescription: RAW_PROVIDER_ERROR, + }) + }) + + it('keeps token errors private for selectors', async () => { + queueTableRows(credential, [row]) + fetchMock.mockResolvedValueOnce( + Response.json( + { error: 'unauthorized_client', error_description: RAW_PROVIDER_ERROR }, + { status: 401 } + ) + ) + await expect( + getServiceAccountToken('credential-1', [driveScope], 'admin@example.com', { + privacyMode: 'selector', + }) + ).rejects.toMatchObject({ + statusCode: 401, + errorCode: undefined, + errorDescription: 'Token exchange failed: 401', + }) + expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain(RAW_PROVIDER_ERROR) + }) + + it.each([ + 'Unavailable', + 'null', + '{"error":42,"error_description":{}}', + '{"error_description":""}', + ])('handles malformed provider errors without losing the HTTP status: %s', async (body) => { + queueTableRows(credential, [row]) + fetchMock.mockResolvedValueOnce(new Response(body, { status: 503 })) + await expect(getServiceAccountToken('credential-1', [driveScope])).rejects.toMatchObject({ + statusCode: 503, + errorCode: undefined, + errorDescription: 'Token exchange failed: 503', + }) + }) + + it('continues to hide invalid-signature details', async () => { + queueTableRows(credential, [row]) + fetchMock.mockResolvedValueOnce( + Response.json( + { error: 'invalid_grant', error_description: 'Invalid signature: private key details' }, + { status: 400 } + ) + ) + await expect(getServiceAccountToken('credential-1', [driveScope])).rejects.toMatchObject({ + statusCode: 400, + errorCode: 'invalid_grant', + errorDescription: 'Invalid account credentials.', + }) + }) }) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 33181593f2d..7cfee32eaea 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -86,7 +86,8 @@ function privateCredentialIdentity(namespace: string, value: string): string { export class ServiceAccountTokenError extends Error { constructor( public readonly statusCode: number, - public readonly errorDescription: string + public readonly errorDescription: string, + public readonly errorCode?: string ) { super(errorDescription) this.name = 'ServiceAccountTokenError' @@ -296,22 +297,32 @@ export async function getServiceAccountToken( ...(options?.privacyMode === 'selector' ? {} : { body: errorBody }), }) let description = `Token exchange failed: ${response.status}` + let errorCode: string | undefined if (options?.privacyMode !== 'selector') { try { - const parsed = JSON.parse(errorBody) as { error_description?: string } - if (parsed.error_description) { - const raw = parsed.error_description - if (raw.includes('SignatureException') || raw.includes('Invalid signature')) { - description = 'Invalid account credentials.' - } else { - description = raw + const parsed: unknown = JSON.parse(errorBody) + if (typeof parsed === 'object' && parsed !== null) { + if ('error' in parsed && typeof parsed.error === 'string') { + errorCode = parsed.error + } + if ( + 'error_description' in parsed && + typeof parsed.error_description === 'string' && + parsed.error_description.length > 0 + ) { + const raw = parsed.error_description + if (raw.includes('SignatureException') || raw.includes('Invalid signature')) { + description = 'Invalid account credentials.' + } else { + description = raw + } } } } catch { - // use default description + /** Retain the status-based description when Google returns a non-JSON error. */ } } - throw new ServiceAccountTokenError(response.status, description) + throw new ServiceAccountTokenError(response.status, description, errorCode) } const tokenData = (await response.json()) as { access_token: string } diff --git a/apps/sim/lib/oauth/github-repositories.test.ts b/apps/sim/lib/oauth/github-repositories.test.ts index c2820879cc1..4fa8568523e 100644 --- a/apps/sim/lib/oauth/github-repositories.test.ts +++ b/apps/sim/lib/oauth/github-repositories.test.ts @@ -154,7 +154,7 @@ describe('GitHub identity verification', () => { }) }) - it('allows an invitation to match a verified secondary work email', async () => { + it('uses the verified primary email for a managed connection without an invitation email', async () => { vi.stubGlobal( 'fetch', vi @@ -167,44 +167,40 @@ describe('GitHub identity verification', () => { policy.verifyIdentity({ tokens: { accessToken: 'ghu_access' }, clientId: 'app-client', - expectedEmail: 'Work@Example.com', }) - ).resolves.toMatchObject({ email: work.email, providerSubjectId: '1234' }) + ).resolves.toMatchObject({ email: primary.email, providerSubjectId: '1234' }) }) it('checks later email pages without following provider-supplied destinations', async () => { const fetchMock = vi .fn() .mockResolvedValueOnce(response(user)) - .mockResolvedValueOnce(response(Array.from({ length: 100 }, () => primary))) - .mockResolvedValueOnce(response([work])) + .mockResolvedValueOnce(response(Array.from({ length: 100 }, () => work))) + .mockResolvedValueOnce(response([primary])) vi.stubGlobal('fetch', fetchMock) - await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).resolves.toMatchObject( - { - email: work.email, - } - ) + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).resolves.toMatchObject({ + email: primary.email, + }) expect(fetchMock.mock.calls[2]![0]).toBe( 'https://api.github.com/user/emails?per_page=100&page=2' ) }) - it.each([{ emails: [primary] }, { emails: [{ ...work, verified: false }] }, { emails: [] }])( - 'refuses absent or unverified invited email $emails', - async ({ emails }) => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValueOnce(response(user)).mockResolvedValueOnce(response(emails)) - ) - await expect( - verifyGitHubRepositoriesIdentity('ghu_access', work.email) - ).rejects.toMatchObject({ - name: 'OAuthIdentityVerificationError', - reason: 'email_mismatch', - stage: 'emails', - }) - } - ) + it.each([ + { emails: [work] }, + { emails: [{ ...primary, verified: false }, work] }, + { emails: [] }, + ])('refuses absent or unverified primary email $emails', async ({ emails }) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce(response(user)).mockResolvedValueOnce(response(emails)) + ) + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ + name: 'OAuthIdentityVerificationError', + reason: 'email_unverified', + stage: 'emails', + }) + }) it('rejects a bot identity', async () => { const fetchMock = vi.fn().mockResolvedValue(response({ ...user, type: 'Bot' })) @@ -225,7 +221,7 @@ describe('GitHub identity verification', () => { }) }) - it('distinguishes denied email-read permission from a verified email mismatch', async () => { + it('distinguishes denied email-read permission from an unverified email', async () => { vi.stubGlobal( 'fetch', vi @@ -233,7 +229,7 @@ describe('GitHub identity verification', () => { .mockResolvedValueOnce(response(user)) .mockResolvedValueOnce(response({ message: 'Resource not accessible by integration' }, 403)) ) - await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).rejects.toMatchObject({ + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ reason: 'email_access_denied', stage: 'emails', httpStatus: 403, @@ -261,9 +257,7 @@ describe('GitHub identity verification', () => { }) ) ) - await expect( - verifyGitHubRepositoriesIdentity('ghu_access', work.email) - ).rejects.toMatchObject({ + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ reason: 'rate_limited', stage: 'emails', httpStatus: error.status, @@ -273,7 +267,7 @@ describe('GitHub identity verification', () => { it('reports GitHub service failure without retaining its response body', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({ message: work.email }, 503))) - const failure = await verifyGitHubRepositoriesIdentity('ghu_access', work.email).catch( + const failure = await verifyGitHubRepositoriesIdentity('ghu_access').catch( (error: unknown) => error ) expect(failure).toBeInstanceOf(OAuthIdentityVerificationError) @@ -287,7 +281,7 @@ describe('GitHub identity verification', () => { it('sanitizes network failures instead of retaining a transport error', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('transport failed ghu_access'))) - const failure = await verifyGitHubRepositoriesIdentity('ghu_access', work.email).catch( + const failure = await verifyGitHubRepositoriesIdentity('ghu_access').catch( (error: unknown) => error ) expect(failure).toMatchObject({ reason: 'provider_unavailable', stage: 'profile' }) @@ -295,7 +289,7 @@ describe('GitHub identity verification', () => { expect(failure).not.toHaveProperty('cause') }) - it('distinguishes an invalid email response from a verified email mismatch', async () => { + it('distinguishes an invalid email response from an unverified email', async () => { vi.stubGlobal( 'fetch', vi @@ -310,19 +304,19 @@ describe('GitHub identity verification', () => { ]) ) ) - await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).rejects.toMatchObject({ + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ reason: 'invalid_response', stage: 'emails', }) }) - it('does not claim a mismatch when the bounded email scan cannot finish', async () => { + it('does not claim an unverified email when the bounded email scan cannot finish', async () => { const fetchMock = vi.fn().mockResolvedValueOnce(response(user)) for (let page = 0; page < 10; page++) { - fetchMock.mockResolvedValueOnce(response(Array.from({ length: 100 }, () => primary))) + fetchMock.mockResolvedValueOnce(response(Array.from({ length: 100 }, () => work))) } vi.stubGlobal('fetch', fetchMock) - await expect(verifyGitHubRepositoriesIdentity('ghu_access', work.email)).rejects.toMatchObject({ + await expect(verifyGitHubRepositoriesIdentity('ghu_access')).rejects.toMatchObject({ reason: 'invalid_response', stage: 'emails', }) diff --git a/apps/sim/lib/oauth/github-repositories.ts b/apps/sim/lib/oauth/github-repositories.ts index 93c9a6361e5..320c2658bd0 100644 --- a/apps/sim/lib/oauth/github-repositories.ts +++ b/apps/sim/lib/oauth/github-repositories.ts @@ -44,12 +44,9 @@ export function parseGitHubRepositoriesTokenResponse(value: unknown) { /** * Reads provider-attested identity; a public profile email never establishes ownership. - * A managed invitation may match a verified work address even when it is not primary. + * Both managed and ordinary connections use the account's verified primary email. */ -export async function verifyGitHubRepositoriesIdentity( - accessToken: string, - expectedEmail?: string -) { +export async function verifyGitHubRepositoriesIdentity(accessToken: string) { if (!accessToken.startsWith('ghu_')) { throw new OAuthIdentityVerificationError('provider_rejected', 'token') } @@ -120,7 +117,6 @@ export async function verifyGitHubRepositoriesIdentity( throw new OAuthIdentityVerificationError('invalid_response', 'profile') } const user = parsedUser.data - const normalizedEmail = expectedEmail?.trim().toLowerCase() for (let page = 1; page <= MAX_EMAIL_PAGES; page++) { const parsedEmails = emailsSchema.safeParse( await get(`/user/emails?per_page=${EMAIL_PAGE_SIZE}&page=${page}`, 'emails') @@ -129,11 +125,7 @@ export async function verifyGitHubRepositoriesIdentity( throw new OAuthIdentityVerificationError('invalid_response', 'emails') } const emails = parsedEmails.data - const matching = emails.find( - (entry) => - entry.verified && - (normalizedEmail ? entry.email.toLowerCase() === normalizedEmail : entry.primary) - ) + const matching = emails.find((entry) => entry.verified && entry.primary) if (matching) { return { providerSubjectId: String(user.id), @@ -146,7 +138,7 @@ export async function verifyGitHubRepositoriesIdentity( } } if (emails.length < EMAIL_PAGE_SIZE) { - throw new OAuthIdentityVerificationError('email_mismatch', 'emails') + throw new OAuthIdentityVerificationError('email_unverified', 'emails') } } throw new OAuthIdentityVerificationError('invalid_response', 'emails') diff --git a/apps/sim/lib/oauth/identity-error.ts b/apps/sim/lib/oauth/identity-error.ts index 8fbac5f2a1c..bb344223007 100644 --- a/apps/sim/lib/oauth/identity-error.ts +++ b/apps/sim/lib/oauth/identity-error.ts @@ -1,5 +1,5 @@ export type OAuthIdentityFailureReason = - | 'email_mismatch' + | 'email_unverified' | 'email_access_denied' | 'provider_rejected' | 'rate_limited' diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 1d6891b9d6c..8ab9ddcd9ba 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -11,6 +11,7 @@ import { CalComIcon, ClaudeIcon, ClickUpIcon, + CodaIcon, ConfluenceIcon, DocuSignIcon, DropboxIcon, @@ -1319,6 +1320,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'hubspot', }, + coda: { + name: 'Coda', + icon: CodaIcon, + services: { + coda: { + name: 'Coda', + description: 'Read and write Coda docs, pages, and tables.', + providerId: 'coda', + serviceAccountProviderId: 'coda-service-account', + icon: CodaIcon, + baseProviderIcon: CodaIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'coda', + }, harmonic: { name: 'Harmonic', icon: HarmonicIcon, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index b97bc4fd9e8..393fcf13d7f 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -83,6 +83,7 @@ export type OAuthProvider = | 'quickbooks' | 'hubspot' | 'harmonic' + | 'coda' | 'salesforce' | 'linkedin' | 'instagram' @@ -145,6 +146,7 @@ export type OAuthService = | 'quickbooks' | 'hubspot' | 'harmonic' + | 'coda' | 'salesforce' | 'linkedin' | 'instagram' diff --git a/apps/sim/lib/organizations/surface.test.ts b/apps/sim/lib/organizations/surface.test.ts index 4a622a29d61..1a74e24ae06 100644 --- a/apps/sim/lib/organizations/surface.test.ts +++ b/apps/sim/lib/organizations/surface.test.ts @@ -16,6 +16,8 @@ vi.mock('@/lib/credential-groups/scoped-availability', () => ({ vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfigForOrganization: mockPermissionConfig, + /** The nav lists Access Control on the regime; these tests drive it from the plan knob. */ + isOrganizationPermissionRegimeActive: mockEnterprisePlan, })) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mockEnterprisePlan, diff --git a/apps/sim/lib/organizations/surface.ts b/apps/sim/lib/organizations/surface.ts index ee2c3283c5a..399a97520b0 100644 --- a/apps/sim/lib/organizations/surface.ts +++ b/apps/sim/lib/organizations/surface.ts @@ -18,7 +18,10 @@ import { } from '@/lib/knowledge/access/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' -import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' +import { + getUserPermissionConfigForOrganization, + isOrganizationPermissionRegimeActive, +} from '@/lib/permission-groups/resolve.server' export interface OrganizationSurfaceOrganization { id: string @@ -77,19 +80,36 @@ async function resolveOrganizationSurfaceContext( if (!row) return null const deployment = getDeploymentShape() - const [config, [{ memberCount }], connectedAccountsAvailable, searchAccess, hasEnterprisePlan] = - await Promise.all([ - getUserPermissionConfigForOrganization(organizationId), - db - .select({ memberCount: count() }) - .from(member) - .where(eq(member.organizationId, organizationId)), - isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }), - resolveKnowledgeAccessAvailability({ organizationId }), - deployment.hosted && access.isAdmin - ? isOrganizationOnEnterprisePlan(organizationId) - : Promise.resolve(false), - ]) + const [ + config, + [{ memberCount }], + connectedAccountsAvailable, + searchAccess, + hasEnterprisePlan, + governanceActive, + ] = await Promise.all([ + getUserPermissionConfigForOrganization(organizationId), + db + .select({ memberCount: count() }) + .from(member) + .where(eq(member.organizationId, organizationId)), + isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }), + resolveKnowledgeAccessAvailability({ organizationId }), + deployment.hosted && access.isAdmin + ? isOrganizationOnEnterprisePlan(organizationId) + : Promise.resolve(false), + /** + * Access Control stays listed while a payment is failing, because its rules still apply. + * + * Resolved rather than rejected on a read failure: this value only decides whether a nav item + * is drawn, and it is shared by every organization page — letting it throw would take home, + * chat and search down with the billing table. The page and the management API read the same + * regime and still fail closed, so a listed item cannot be used to reach anything. + */ + deployment.hosted && access.isAdmin + ? isOrganizationPermissionRegimeActive(organizationId).catch(() => false) + : Promise.resolve(false), + ]) return { organization: { id: row.id, @@ -112,7 +132,11 @@ async function resolveOrganizationSurfaceContext( }, connectedAccountsAvailable, searchAccess, - settingsFeatures: getOrganizationSettingsFeatures(hasEnterprisePlan, deployment), + settingsFeatures: getOrganizationSettingsFeatures( + hasEnterprisePlan, + deployment, + governanceActive + ), deployment, } } diff --git a/apps/sim/lib/permission-access-requests/README.md b/apps/sim/lib/permission-access-requests/README.md new file mode 100644 index 00000000000..0f18a206667 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/README.md @@ -0,0 +1,32 @@ +# Permission access requests + +Members request access from locked features, the block picker, or **My access requests**. Organization owners and administrators review requests in **Access control → Requests**, **Review access requests** in the workspace menu, or through an authenticated email link. The same queue handles increases to an administrator-set member credit cap. + +## Rollout + +1. Apply migration `0349_permission_access_requests.sql` before deploying the application changes. +2. Enable the global AppConfig `permission-access-requests` flag. Outside AppConfig deployments, set `PERMISSION_ACCESS_REQUESTS_ENABLED=true`. +3. Each organization starts with **Allow users to request permissions** enabled. An explicit organization opt-out disables creation and approval and restores existing feature hiding. History, cancellation, and decline remain available. +4. The existing outbox worker delivers notifications. Email links open authenticated review/history; email never applies a change. + +## Policy and lifecycle + +- Requests refer to canonical public feature identifiers. Private resource names, preview blocks, deployment-disabled integrations, and unknown tenant model names are excluded from discovery. +- Fulfillment updates the current governing group. The preview lists every required change, including parent restrictions, and a conservative upper bound of affected people/workspaces. It does not create individual grants or move members between groups. +- Approval rechecks administrator authority, requester membership identity, workspace ownership, entitlement at admission, organization preference, group resolution, and the preview fingerprint. Membership, policy, or scope changes require a fresh review or request. +- The group/credit-limit update, final request record, and notification enqueue share one database transaction. The final decision stores its original change and impact for history; later policy edits do not rewrite it. +- One pending request per requester/scope/target is enforced by a database index and organization serialization. Member cap requests share an organization-wide key. Submission is bounded to 100 requests per rolling 24 hours and 100 pending requests per requester/organization, in addition to HTTP rate admission. +- A usage request increases the existing member credit cap. It does not change the pooled organization budget, buy credits, or alter temporary request-rate limits. +- Notifications recheck current membership and reviewer authority. Outbox fan-out is bounded and replay-safe; delivery to an email provider remains at-least-once across a crash after send. + +## Validation + +Domain and application tests cover denial/delta parity, deployment ceilings, current authorization, duplicate submissions, stale previews, membership changes, monotonic credit increases, and outbox behavior. DOM tests cover locked pages, request-only block actions, keyboard order, and query reconciliation. + +The optional PostgreSQL migration tests require a disposable local database named `sim_access_requests_test`: + +```sh +ACCESS_REQUESTS_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:5432/sim_access_requests_test bunx vitest run permission-access-requests-migration.postgres.test.ts +``` + +Run that command from `packages/db`. The fixture uses a unique schema and verifies pending uniqueness across independent transactions, lifecycle constraints, default settings, and preservation of decision history. diff --git a/apps/sim/lib/permission-access-requests/application/authorization.test.ts b/apps/sim/lib/permission-access-requests/application/authorization.test.ts new file mode 100644 index 00000000000..679a19206fb --- /dev/null +++ b/apps/sim/lib/permission-access-requests/application/authorization.test.ts @@ -0,0 +1,235 @@ +/** @vitest-environment node */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { member, permissions, user, workspace } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ effectiveRole: vi.fn(), config: vi.fn() })) +vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({ + ...(await importOriginal()), + resolveEffectiveWorkspacePermission: mocks.effectiveRole, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: mocks.config, +})) + +import type { DbOrTx } from '@/lib/db/types' +import { + authorizeAccessRequestScope, + loadAccessRequestMembership, +} from '@/lib/permission-access-requests/application/authorization' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' + +const principal: SessionPrincipal = { kind: 'session', userId: 'person', sessionId: 'session' } +const workspaceScope = { kind: 'workspace' as const, workspaceId: 'workspace' } +const organizationScope = { kind: 'organization' as const, organizationId: 'org' } +const activePerson = { suspendedAt: null, banned: false, banExpires: null } +const canonicalWorkspace = { id: 'workspace', organizationId: 'org', allowPersonalApiKeys: false } + +function queueMembership(orgRole: string | null = 'member', grantId: string | null = 'grant') { + queueTableRows(user, [activePerson]) + queueTableRows(member, orgRole ? [{ id: 'membership', role: orgRole }] : []) + queueTableRows(permissions, grantId ? [{ id: grantId }] : []) +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.effectiveRole.mockResolvedValue('read') + mocks.config.mockRejectedValue(new Error('A capability-exempt session must not load config')) +}) + +describe('access request membership identity', () => { + it('permits an external member only through an explicit effective workspace grant', async () => { + queueMembership(null) + await expect(loadAccessRequestMembership(db, 'person', workspaceScope, 'org')).resolves.toEqual( + { + membershipId: '[null,"grant"]', + role: 'read', + } + ) + expect(mocks.effectiveRole).toHaveBeenCalledWith('person', 'workspace', 'org', db, { + forUpdate: false, + }) + }) + + it('does not substitute organization membership for workspace access', async () => { + queueMembership('member', null) + mocks.effectiveRole.mockResolvedValue(null) + await expect( + loadAccessRequestMembership(db, 'person', workspaceScope, 'org') + ).resolves.toBeNull() + }) + + it('does not substitute workspace administrator access for organization membership', async () => { + queueMembership(null) + mocks.effectiveRole.mockResolvedValue('admin') + await expect( + loadAccessRequestMembership(db, 'person', organizationScope, 'org') + ).resolves.toBeNull() + expect(mocks.effectiveRole).not.toHaveBeenCalled() + }) + + it.each(['owner', 'admin', 'member'])( + 'captures the organization membership incarnation for %s', + async (role) => { + queueMembership(role) + await expect( + loadAccessRequestMembership(db, 'person', organizationScope, 'org') + ).resolves.toEqual({ + membershipId: '["membership",null]', + role: role === 'member' ? 'read' : 'admin', + }) + } + ) + + it('fails closed on an unknown organization role', async () => { + queueMembership('billing-admin') + await expect( + loadAccessRequestMembership(db, 'person', organizationScope, 'org') + ).resolves.toBeNull() + }) + + it.each([ + { ...activePerson, suspendedAt: new Date() }, + { ...activePerson, banned: true }, + { ...activePerson, banned: true, banExpires: new Date('2099-01-01') }, + ])('refuses an account that is currently blocked', async (person) => { + queueTableRows(user, [person]) + await expect( + loadAccessRequestMembership(db, 'person', workspaceScope, 'org', true) + ).resolves.toBeNull() + expect(mocks.effectiveRole).not.toHaveBeenCalled() + expect(dbChainMockFns.from).toHaveBeenCalledExactlyOnceWith(user) + }) + + it('recognizes an expired temporary ban as lifted', async () => { + queueTableRows(user, [{ ...activePerson, banned: true, banExpires: new Date('2020-01-01') }]) + queueTableRows(member, [{ id: 'membership', role: 'member' }]) + await expect( + loadAccessRequestMembership(db, 'person', organizationScope, 'org') + ).resolves.toMatchObject({ role: 'read' }) + }) + + it('keeps reciprocal account reads compatible while exclusively locking membership and grant identities', async () => { + queueMembership() + await loadAccessRequestMembership(db, 'person', workspaceScope, 'org', true) + expect(dbChainMockFns.from.mock.calls.map(([table]) => table)).toEqual([ + user, + member, + permissions, + ]) + expect(dbChainMockFns.for).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.for.mock.calls).toEqual([['share'], ['update'], ['update']]) + expect(mocks.effectiveRole).toHaveBeenCalledWith('person', 'workspace', 'org', db, { + forUpdate: true, + }) + expect(dbChainMockFns.for.mock.invocationCallOrder.at(-1)).toBeLessThan( + mocks.effectiveRole.mock.invocationCallOrder[0] + ) + }) +}) + +describe('access request scope authorization', () => { + it('conceals a workspace tenant change before looking up anyone in the new organization', async () => { + queueTableRows(workspace, [{ ...canonicalWorkspace, organizationId: 'other-org' }]) + await expect( + authorizeAccessRequestScope( + principal, + accessRequestOperations.create, + workspaceScope, + db, + true, + { + organizationId: 'org', + membershipId: '["membership","grant"]', + } + ) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(dbChainMockFns.from).toHaveBeenCalledExactlyOnceWith(workspace) + expect(mocks.effectiveRole).not.toHaveBeenCalled() + }) + + it('refuses a recreated explicit grant even when its current role is sufficient', async () => { + queueTableRows(workspace, [canonicalWorkspace]) + queueMembership('member', 'replacement-grant') + await expect( + authorizeAccessRequestScope( + principal, + accessRequestOperations.create, + workspaceScope, + db, + true, + { + organizationId: 'org', + membershipId: '["membership","grant"]', + } + ) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('refuses a recreated organization membership for an in-flight operation', async () => { + queueMembership('admin') + await expect( + authorizeAccessRequestScope( + principal, + accessRequestOperations.resolve, + organizationScope, + db, + true, + { + organizationId: 'org', + membershipId: '["removed-membership",null]', + } + ) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('rechecks administrator authority through the shared organization funnel in a transaction', async () => { + const executor = { select: db.select } as DbOrTx + queueMembership('member') + queueTableRows(member, [{ role: 'member' }]) + await expect( + authorizeAccessRequestScope( + principal, + accessRequestOperations.resolve, + organizationScope, + executor, + true + ) + ).rejects.toThrow('Organization administrator access is required') + expect(dbChainMockFns.from.mock.calls.filter(([table]) => table === member)).toHaveLength(2) + expect(mocks.config).not.toHaveBeenCalled() + }) + + it('allows organization review independently of the restrictions under review', async () => { + queueMembership('owner') + queueTableRows(member, [{ role: 'owner' }]) + await expect( + authorizeAccessRequestScope( + principal, + accessRequestOperations.resolve, + organizationScope, + db, + true + ) + ).resolves.toMatchObject({ role: 'admin', organizationId: 'org', workspaceId: null }) + expect(mocks.config).not.toHaveBeenCalled() + }) + + it('never authorizes organization review from a workspace-scoped request', async () => { + await expect( + authorizeAccessRequestScope(principal, accessRequestOperations.resolve, workspaceScope) + ).rejects.toThrow('Organization administrator access is required') + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('conceals an archived or removed workspace before membership lookup', async () => { + queueTableRows(workspace, []) + await expect( + authorizeAccessRequestScope(principal, accessRequestOperations.create, workspaceScope) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(dbChainMockFns.from).toHaveBeenCalledExactlyOnceWith(workspace) + }) +}) diff --git a/apps/sim/lib/permission-access-requests/application/authorization.ts b/apps/sim/lib/permission-access-requests/application/authorization.ts new file mode 100644 index 00000000000..0dac5ff2e7c --- /dev/null +++ b/apps/sim/lib/permission-access-requests/application/authorization.ts @@ -0,0 +1,152 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { member, permissions, user, workspace } from '@sim/db/schema' +import { + isOrgAdminRole, + type PermissionType, + resolveEffectiveWorkspacePermission, +} from '@sim/platform-authz/workspace' +import { and, eq, isNull } from 'drizzle-orm' +import { isAccountBlocked } from '@/lib/auth/ban' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import { + authorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal, +} from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import type { AccessRequestOperation } from '@/lib/permission-access-requests/application/operations' +import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' + +export interface AccessRequestContext { + organizationId: string | null + workspaceId: string | null + membershipId: string + role: PermissionType +} + +/** Reinvitation produces a new identity token, even when the same user regains access. */ +export async function loadAccessRequestMembership( + executor: DbOrTx, + userId: string, + scope: AccessRequestScope, + organizationId: string | null, + forUpdate = false +): Promise<{ membershipId: string; role: PermissionType } | null> { + const personQuery = executor + .select({ suspendedAt: user.suspendedAt, banned: user.banned, banExpires: user.banExpires }) + .from(user) + .where(eq(user.id, userId)) + /** Shared account locks block suspension while allowing reciprocal external-member reviews. */ + const [person] = forUpdate ? await personQuery.for('share').limit(1) : await personQuery.limit(1) + if (!person || isAccountBlocked(person)) return null + const orgMember = organizationId + ? await (async () => { + const query = executor + .select({ id: member.id, role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) + const [row] = forUpdate ? await query.for('update').limit(1) : await query.limit(1) + return row + })() + : undefined + if (orgMember && !['member', 'admin', 'owner'].includes(orgMember.role)) return null + if (scope.kind === 'organization') { + if (!orgMember) return null + return { + membershipId: JSON.stringify([orgMember.id, null]), + role: isOrgAdminRole(orgMember.role) ? 'admin' : 'read', + } + } + const grantQuery = executor + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, scope.workspaceId) + ) + ) + const [grant] = forUpdate ? await grantQuery.for('update').limit(1) : await grantQuery.limit(1) + const role = await resolveEffectiveWorkspacePermission( + userId, + scope.workspaceId, + organizationId, + executor, + { forUpdate } + ) + if (!role) return null + return { membershipId: JSON.stringify([orgMember?.id ?? null, grant?.id ?? null]), role } +} + +/** Canonical scope and current role are loaded together on the transaction's connection. */ +export async function authorizeAccessRequestScope( + principal: SessionPrincipal, + operation: AccessRequestOperation, + scope: AccessRequestScope, + executor: DbOrTx = db, + forUpdate = false, + expected?: Pick +): Promise { + requireAllowedWorkspacePrincipal(principal, operation) + if (operation.admin && scope.kind !== 'organization') { + throw new OrchestrationError('forbidden', 'Organization administrator access is required') + } + let canonicalWorkspace: + | Pick + | undefined + let organizationId: string | null + if (scope.kind === 'workspace') { + const query = executor + .select({ + id: workspace.id, + organizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + }) + .from(workspace) + .where(and(eq(workspace.id, scope.workspaceId), isNull(workspace.archivedAt))) + const [row] = forUpdate ? await query.for('update').limit(1) : await query.limit(1) + if (!row || (expected && row.organizationId !== expected.organizationId)) { + throw new OrchestrationError('not_found', 'Workspace not found') + } + canonicalWorkspace = row + organizationId = row.organizationId + } else { + organizationId = scope.organizationId + } + const membership = await loadAccessRequestMembership( + executor, + principal.userId, + scope, + organizationId, + forUpdate + ) + if (!membership || (expected && membership.membershipId !== expected.membershipId)) { + throw new OrchestrationError('not_found', 'Access request scope not found') + } + if (canonicalWorkspace) { + await authorizeWorkspaceOperation( + principal, + operation, + { + workspaceId: canonicalWorkspace.id, + workspaceOrganizationId: canonicalWorkspace.organizationId, + allowPersonalApiKeys: canonicalWorkspace.allowPersonalApiKeys, + }, + { executor, forUpdate } + ) + } else if (scope.kind === 'organization') { + await authorizeOrganizationOperation( + principal, + operation.organizationOperation, + { organizationId: scope.organizationId }, + { executor, forUpdate } + ) + } + return { + organizationId, + workspaceId: scope.kind === 'workspace' ? scope.workspaceId : null, + ...membership, + } +} diff --git a/apps/sim/lib/permission-access-requests/application/authorized-use-case.test.ts b/apps/sim/lib/permission-access-requests/application/authorized-use-case.test.ts new file mode 100644 index 00000000000..607dc7a6963 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/application/authorized-use-case.test.ts @@ -0,0 +1,219 @@ +/** @vitest-environment node */ +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + lock: vi.fn(), + audit: vi.fn(), + outbound: vi.fn(), +})) +vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ + authorizeAccessRequestScope: mocks.authorize, +})) +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationMutationLock: mocks.lock, +})) +vi.mock('@/lib/core/application/authorized-workspace-use-case', () => ({ + recordProjectedUseCaseAuditEntries: mocks.audit, +})) +vi.mock('@/lib/core/network/context.server', () => ({ + runWithOutboundOrganization: mocks.outbound, +})) + +import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { defineAuthorizedAccessRequestUseCase } from '@/lib/permission-access-requests/application/authorized-use-case' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' + +const principal: SessionPrincipal = { kind: 'session', userId: 'requester', sessionId: 'session' } +const scope: AccessRequestScope = { kind: 'workspace', workspaceId: 'workspace' } +const context = { + organizationId: 'org', + workspaceId: 'workspace', + membershipId: '["member","grant"]', + role: 'read', +} +const transaction = { select: vi.fn() } as unknown as DbOrTx +const input = { workspaceId: 'workspace' } + +beforeEach(() => { + vi.clearAllMocks() + mocks.authorize.mockResolvedValue(context) + mocks.lock.mockResolvedValue(undefined) + mocks.outbound.mockImplementation((_organizationId: string, run: () => Promise) => run()) + vi.mocked(db.transaction).mockImplementation(async (run) => run(transaction as never)) +}) + +describe('authorized access request execution', () => { + it.each([ + { kind: 'personal_api_key', userId: 'owner', keyId: 'key' }, + { kind: 'workspace_api_key', workspaceId: 'workspace', keyId: 'key' }, + { + kind: 'oauth_access_token', + userId: 'owner', + tokenId: 'token', + clientId: 'client', + scopes: ['api:write'], + expiresAt: new Date('2099-01-01'), + }, + ])('refuses $kind before scope lookup or preparation', async (caller) => { + const prepare = vi.fn().mockResolvedValue({ ready: true }) + const execute = vi.fn() + const getScope = vi.fn().mockReturnValue(scope) + const useCase = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.create, + scope: getScope, + prepare, + execute, + }) + await expect(useCase.execute({ principal: caller, input })).rejects.toThrow('signed-in user') + expect(getScope).not.toHaveBeenCalled() + expect(mocks.authorize).not.toHaveBeenCalled() + expect(prepare).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + + it('does not prepare or execute when the initial authorization fails', async () => { + mocks.authorize.mockRejectedValue(new OrchestrationError('not_found', 'Workspace not found')) + const prepare = vi.fn() + const execute = vi.fn() + const useCase = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.create, + scope: () => scope, + mutation: true, + prepare, + execute, + }) + await expect(useCase.execute({ principal, input })).rejects.toThrow('Workspace not found') + expect(prepare).not.toHaveBeenCalled() + expect(db.transaction).not.toHaveBeenCalled() + }) + + it('prepares outside locks, then rechecks the original scope and incarnation before mutating', async () => { + const prepared = { catalog: ['governed-item'] } + const prepare = vi.fn().mockResolvedValue(prepared) + const execute = vi.fn().mockResolvedValue({ id: 'request' }) + const useCase = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.create, + scope: () => scope, + mutation: true, + prepare, + execute, + }) + await expect(useCase.execute({ principal, input })).resolves.toEqual({ id: 'request' }) + expect(prepare).toHaveBeenCalledExactlyOnceWith({ principal, input, context }) + expect(prepare.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(db.transaction).mock.invocationCallOrder[0] + ) + expect(mocks.lock).toHaveBeenCalledExactlyOnceWith(transaction, 'org') + expect(mocks.authorize).toHaveBeenNthCalledWith( + 2, + principal, + accessRequestOperations.create, + scope, + transaction, + true, + context + ) + expect(mocks.lock.mock.invocationCallOrder[0]).toBeLessThan( + mocks.authorize.mock.invocationCallOrder[1] + ) + expect(execute).toHaveBeenCalledExactlyOnceWith({ + principal, + input, + context, + executor: transaction, + prepared, + }) + }) + + it('never executes or audits after membership is removed during preparation', async () => { + mocks.authorize + .mockResolvedValueOnce(context) + .mockRejectedValueOnce(new OrchestrationError('not_found', 'Access request scope not found')) + const execute = vi.fn() + const projectAudit = vi.fn().mockReturnValue([]) + const useCase = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.create, + scope: () => scope, + mutation: true, + prepare: async () => true, + execute, + projectAudit, + }) + await expect(useCase.execute({ principal, input })).rejects.toThrow( + 'Access request scope not found' + ) + expect(execute).not.toHaveBeenCalled() + expect(projectAudit).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('does not acquire locks when preparation fails', async () => { + const execute = vi.fn() + const useCase = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.create, + scope: () => scope, + mutation: true, + prepare: async () => { + throw new Error('Catalog unavailable') + }, + execute, + }) + await expect(useCase.execute({ principal, input })).rejects.toThrow('Catalog unavailable') + expect(db.transaction).not.toHaveBeenCalled() + expect(mocks.lock).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + + it('supports authorization-only probes without preparing or running business behavior', async () => { + const prepare = vi.fn() + const execute = vi.fn() + const useCase = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.listMine, + scope: () => scope, + prepare, + execute, + }) + await useCase.authorize?.({ principal, input }) + expect(mocks.authorize).toHaveBeenCalledExactlyOnceWith( + principal, + accessRequestOperations.listMine, + scope + ) + expect(prepare).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + + it('keeps the acting session principal for reads and audits', async () => { + const execute = vi.fn().mockResolvedValue('result') + const projectAudit = vi.fn().mockReturnValue([]) + const useCase = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.listMine, + scope: () => scope, + execute, + projectAudit, + }) + await useCase.execute({ principal, input }) + expect(execute).toHaveBeenCalledExactlyOnceWith({ + principal, + input, + context, + executor: db, + prepared: undefined, + }) + expect(mocks.outbound).toHaveBeenCalledWith('org', expect.any(Function)) + expect(mocks.audit).toHaveBeenCalledWith( + accessRequestOperations.listMine, + 'workspace', + principal, + undefined, + [], + 'org' + ) + expect(db.transaction).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/permission-access-requests/application/authorized-use-case.ts b/apps/sim/lib/permission-access-requests/application/authorized-use-case.ts new file mode 100644 index 00000000000..7eea4a76977 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/application/authorized-use-case.ts @@ -0,0 +1,113 @@ +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { + recordProjectedUseCaseAuditEntries, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application/authorized-workspace-use-case' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { + type AccessRequestContext, + authorizeAccessRequestScope, +} from '@/lib/permission-access-requests/application/authorization' +import type { AccessRequestOperation } from '@/lib/permission-access-requests/application/operations' +import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' + +interface AccessRequestPreparationArgs { + principal: SessionPrincipal + input: I + context: AccessRequestContext +} + +interface AccessRequestUseCaseArgs extends AccessRequestPreparationArgs { + executor: DbOrTx +} + +interface AccessRequestUseCaseDefinition { + operation: AccessRequestOperation + scope(input: I): AccessRequestScope + mutation?: boolean + projectAudit?(args: AccessRequestUseCaseArgs & { result: R }): WorkspaceUseCaseAuditEntry[] +} + +interface PreparedAccessRequestUseCase extends AccessRequestUseCaseDefinition { + prepare(args: AccessRequestPreparationArgs): Promise

+ execute(args: AccessRequestUseCaseArgs & { prepared: P }): Promise +} + +interface UnpreparedAccessRequestUseCase extends AccessRequestUseCaseDefinition { + prepare?: never + execute(args: AccessRequestUseCaseArgs & { prepared: undefined }): Promise +} + +function requireSession(principal: Principal): asserts principal is SessionPrincipal { + if (principal.kind !== 'session') { + throw new OrchestrationError('forbidden', 'A signed-in user is required') + } +} + +export function defineAuthorizedAccessRequestUseCase( + definition: PreparedAccessRequestUseCase +): OperationUseCase +export function defineAuthorizedAccessRequestUseCase( + definition: UnpreparedAccessRequestUseCase +): OperationUseCase +/** Shared session-only funnel; preparation finishes before any transaction acquires locks. */ +export function defineAuthorizedAccessRequestUseCase( + definition: PreparedAccessRequestUseCase | UnpreparedAccessRequestUseCase +): OperationUseCase { + return { + operation: definition.operation, + async authorize({ principal, input }) { + requireSession(principal) + await authorizeAccessRequestScope(principal, definition.operation, definition.scope(input)) + }, + async execute({ principal, input, request }) { + requireSession(principal) + const scope = definition.scope(input) + const initial = await authorizeAccessRequestScope(principal, definition.operation, scope) + return runWithOutboundOrganization(initial.organizationId, async () => { + let execute: (args: AccessRequestUseCaseArgs) => Promise + if (definition.prepare) { + const prepared = await definition.prepare({ principal, input, context: initial }) + const executePrepared = definition.execute + execute = (args) => executePrepared({ ...args, prepared }) + } else { + const executeUnprepared = definition.execute + execute = (args) => executeUnprepared({ ...args, prepared: undefined }) + } + let context = initial + const result = definition.mutation + ? await db.transaction(async (executor) => { + if (initial.organizationId) { + await acquireOrganizationMutationLock(executor, initial.organizationId) + } + context = await authorizeAccessRequestScope( + principal, + definition.operation, + scope, + executor, + true, + initial + ) + return execute({ principal, input, context, executor }) + }) + : await execute({ principal, input, context, executor: db }) + if (definition.projectAudit) { + recordProjectedUseCaseAuditEntries( + definition.operation, + context.workspaceId, + principal, + request, + definition.projectAudit({ principal, input, context, executor: db, result }), + context.organizationId ?? undefined + ) + } + return result + }) + }, + } +} diff --git a/apps/sim/lib/permission-access-requests/application/operations.ts b/apps/sim/lib/permission-access-requests/application/operations.ts new file mode 100644 index 00000000000..fd6e959ffcf --- /dev/null +++ b/apps/sim/lib/permission-access-requests/application/operations.ts @@ -0,0 +1,66 @@ +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' + +function defineAccessRequestOperation(id: string, admin = false) { + const organizationOperation = defineOrganizationOperation({ + id, + minimumRole: admin ? 'admin' : 'member', + principalKinds: ['session'], + /** + * permission-group-exempt: reviewing and requesting withheld access must remain reachable. + */ + capability: 'none', + }) + const workspaceOperation = defineWorkspaceOperation({ + id, + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + /** + * permission-group-exempt: requests never grant a withheld capability without administrator review. + */ + capability: 'none', + }) + return Object.freeze({ ...workspaceOperation, organizationOperation, admin }) +} + +export const accessRequestOperations = { + /** + * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. + */ + discover: defineAccessRequestOperation('access_requests.discover'), + /** + * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. + */ + listMine: defineAccessRequestOperation('access_requests.list_mine'), + /** + * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. + */ + create: defineAccessRequestOperation('access_requests.create'), + /** + * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. + */ + cancel: defineAccessRequestOperation('access_requests.cancel'), + /** + * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. + */ + listOrganization: defineAccessRequestOperation('access_requests.list_organization', true), + /** + * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. + */ + preview: defineAccessRequestOperation('access_requests.preview', true), + /** + * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. + */ + resolve: defineAccessRequestOperation('access_requests.resolve', true), + /** + * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. + */ + getSettings: defineAccessRequestOperation('access_requests.get_settings', true), + /** + * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. + */ + updateSettings: defineAccessRequestOperation('access_requests.update_settings', true), +} as const + +export type AccessRequestOperation = ReturnType diff --git a/apps/sim/lib/permission-access-requests/application/prepare.ts b/apps/sim/lib/permission-access-requests/application/prepare.ts new file mode 100644 index 00000000000..a05269b1f9d --- /dev/null +++ b/apps/sim/lib/permission-access-requests/application/prepare.ts @@ -0,0 +1,37 @@ +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isAccessControlEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { AccessRequestContext } from '@/lib/permission-access-requests/application/authorization' +import { loadAccessRequestCatalog } from '@/lib/permission-access-requests/catalog' +import type { AccessRequestTarget } from '@/lib/permission-groups/access-requests/targets' + +/** Resolve deployment metadata before opening a transaction or taking policy locks. */ +export async function prepareAccessRequestPolicy( + context: Pick, + userId: string, + targetKind?: AccessRequestTarget['kind'] +) { + if (!context.organizationId) + throw new OrchestrationError( + 'forbidden', + 'Access requests require an organization-owned workspace' + ) + const [catalog, entitled, globalEnabled] = await Promise.all([ + loadAccessRequestCatalog( + { + organizationId: context.organizationId, + workspaceId: context.workspaceId, + userId, + }, + targetKind + ), + isHosted + ? isOrganizationOnEnterprisePlan(context.organizationId) + : Promise.resolve(isAccessControlEnabled), + isFeatureEnabled('permission-access-requests'), + ]) + return { catalog, entitled, globalEnabled } +} + +export type PreparedAccessRequestPolicy = Awaited> diff --git a/apps/sim/lib/permission-access-requests/application/requests.test.ts b/apps/sim/lib/permission-access-requests/application/requests.test.ts new file mode 100644 index 00000000000..616c518ca57 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/application/requests.test.ts @@ -0,0 +1,613 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { + organizationMemberUsageLimit, + permissionAccessRequest, + permissionGroup, + workspace, +} from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AccessRequestRecord, AccessRequestTarget } from '@/lib/api/contracts/access-requests' +import type { StoredAccessRequest } from '@/lib/permission-access-requests/repository' +import { createAccessRequestCatalog } from '@/lib/permission-groups/access-requests/targets' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + membership: vi.fn(), + organizationLock: vi.fn(), + groupLock: vi.fn(), + audit: vi.fn(), + outbox: vi.fn(), + enabled: vi.fn(), + featureEnabled: vi.fn(), + enterprise: vi.fn(), + catalog: vi.fn(), + targets: vi.fn(), + deploymentReason: vi.fn(), + group: vi.fn(), + present: vi.fn(), + stored: vi.fn(), + list: vi.fn(), +})) + +vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ + authorizeAccessRequestScope: mocks.authorize, + loadAccessRequestMembership: mocks.membership, +})) +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationMutationLock: mocks.organizationLock, +})) +vi.mock('@/lib/core/application/authorized-workspace-use-case', () => ({ + recordProjectedUseCaseAuditEntries: mocks.audit, +})) +vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mocks.outbox })) +vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.groupLock })) +vi.mock('@/lib/permission-access-requests/settings', () => ({ + isAccessRequestEnabled: mocks.enabled, + readAccessRequestSettings: vi.fn(), +})) +vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mocks.featureEnabled })) +vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true, isAccessControlEnabled: true })) +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mocks.enterprise, +})) +vi.mock('@/lib/permission-access-requests/catalog', () => ({ + loadAccessRequestCatalog: mocks.catalog, + listAccessRequestTargets: mocks.targets, + getAccessRequestDeploymentUnavailableReason: mocks.deploymentReason, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + resolveWorkspaceGroup: mocks.group, + resolveDefaultGroup: mocks.group, +})) +vi.mock('@/lib/permission-access-requests/repository', () => ({ + presentAccessRequest: mocks.present, + loadStoredAccessRequest: mocks.stored, + listAccessRequestRecords: mocks.list, +})) + +import { + cancelAccessRequest, + createAccessRequest, + discoverAccessRequests, + listMyAccessRequests, +} from '@/lib/permission-access-requests/application/requests' +import { + PERMISSION_ACCESS_REQUEST_CREATED_EVENT, + PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, +} from '@/lib/permission-access-requests/notification-events' + +const principal = { kind: 'session', userId: 'requester', sessionId: 'session' } as const +const scope = { kind: 'workspace', workspaceId: 'workspace' } as const +const context = { + organizationId: 'organization', + workspaceId: 'workspace', + membershipId: 'membership', + role: 'write', +} as const +const target = { kind: 'feature', configKey: 'hideTablesTab' } as const +const catalog = createAccessRequestCatalog({ + integrations: [{ id: 'slack_v2', label: 'Slack' }], + providers: [], + models: [], + tools: [], + knowledgeConnectors: [], +}) +const group = { + permissionGroupId: 'group', + groupName: 'Restricted group', + resolution: 'explicit-member', + config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideTablesTab: true, hideFilesTab: true }, +} as const + +function stored(overrides: Partial = {}): StoredAccessRequest { + return { + id: 'request', + organizationId: 'organization', + requesterId: 'requester', + workspaceId: 'workspace', + scopeKey: 'workspace:workspace', + targetKey: 'feature:hideTablesTab', + target, + targetLabel: 'Tables', + membershipId: 'membership', + groupId: 'group', + groupName: 'Restricted group', + reason: 'I need tables', + status: 'pending', + decisionReason: null, + decidedBy: null, + decision: null, + createdAt: new Date('2026-09-01T00:00:00Z'), + updatedAt: new Date('2026-09-01T00:00:00Z'), + decidedAt: null, + ...overrides, + } +} + +function record(row: StoredAccessRequest): AccessRequestRecord { + return { + id: row.id, + organizationId: row.organizationId, + workspaceId: row.workspaceId, + target: row.target as AccessRequestTarget, + targetLabel: row.targetLabel, + reason: row.reason, + status: row.status, + decisionReason: row.decisionReason, + createdAt: row.createdAt.toISOString(), + decidedAt: row.decidedAt?.toISOString() ?? null, + groupName: row.groupName, + requester: { id: row.requesterId, name: 'Requester', email: 'requester@example.com' }, + } +} + +beforeEach(() => { + vi.resetAllMocks() + resetDbChainMock() + mocks.authorize.mockResolvedValue(context) + mocks.membership.mockResolvedValue(null) + mocks.enabled.mockResolvedValue(true) + mocks.featureEnabled.mockResolvedValue(true) + mocks.enterprise.mockResolvedValue(true) + mocks.catalog.mockResolvedValue(catalog) + mocks.deploymentReason.mockReturnValue(null) + mocks.targets.mockReturnValue([target]) + mocks.group.mockResolvedValue(group) + mocks.stored.mockResolvedValue(stored()) + mocks.present.mockImplementation((_executor, row: StoredAccessRequest) => record(row)) + mocks.list.mockResolvedValue({ requests: [record(stored())], total: 1, hasMore: false }) +}) + +describe('create access requests', () => { + it('rejects unknown or deployment-excluded catalog IDs without creating a request', async () => { + for (const id of ['private-custom-block', 'environment-disabled-block']) { + await expect( + createAccessRequest.execute({ + principal, + input: { scope, target: { kind: 'integration', id } }, + }) + ).rejects.toThrow('unavailable') + } + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) + + it('refuses a feature with a hard deployment blocker even when a group also denies it', async () => { + mocks.deploymentReason.mockReturnValue('Disabled by this deployment.') + await expect( + createAccessRequest.execute({ principal, input: { scope, target } }) + ).rejects.toThrow('Disabled by this deployment') + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) + + it('returns an existing pending request without another notification or policy mutation', async () => { + queueTableRows(permissionAccessRequest, [stored()]) + const result = await createAccessRequest.execute({ principal, input: { scope, target } }) + expect(result).toMatchObject({ changed: false, request: { id: 'request', status: 'pending' } }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) + + it('creates the durable request and outbox in the transaction without granting access', async () => { + queueTableRows(permissionAccessRequest, []) + queueTableRows(permissionAccessRequest, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([stored()]) + const result = await createAccessRequest.execute({ + principal, + input: { scope, target, reason: 'I need tables' }, + }) + expect(result.changed).toBe(true) + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + requesterId: principal.userId, + membershipId: 'membership', + groupId: 'group', + target, + }) + ) + expect(mocks.outbox).toHaveBeenCalledWith(db, PERMISSION_ACCESS_REQUEST_CREATED_EVENT, { + requestId: 'request', + }) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(permissionGroup) + expect(mocks.authorize).toHaveBeenCalledTimes(2) + }) + + it('rejects an action unavailable to the baseline workspace role', async () => { + mocks.authorize.mockResolvedValue({ ...context, role: 'read' }) + await expect( + createAccessRequest.execute({ + principal, + input: { scope, target: { kind: 'feature', configKey: 'disableTableCreation' } }, + }) + ).rejects.toThrow('workspace role') + expect(mocks.outbox).not.toHaveBeenCalled() + }) + + it('blocks new requests while the toggle is off', async () => { + mocks.enabled.mockResolvedValue(false) + await expect( + createAccessRequest.execute({ principal, input: { scope, target } }) + ).rejects.toThrow('turned off') + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) + + it('closes an obsolete pending request before recording its replacement', async () => { + queueTableRows(permissionAccessRequest, [stored({ groupId: 'previous-group' })]) + dbChainMockFns.returning + .mockResolvedValueOnce([stored({ status: 'closed' })]) + .mockResolvedValueOnce([stored({ id: 'replacement' })]) + const result = await createAccessRequest.execute({ principal, input: { scope, target } }) + expect(result.request.id).toBe('replacement') + expect(mocks.audit.mock.calls[0]?.[4]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: 'permission_access_request.closed', + resourceId: 'request', + }), + expect.objectContaining({ + action: 'permission_access_request.created', + resourceId: 'replacement', + }), + ]) + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'closed' })) + expect(mocks.outbox.mock.calls.map(([, event, payload]) => [event, payload])).toEqual([ + [PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, { requestId: 'request' }], + [PERMISSION_ACCESS_REQUEST_CREATED_EVENT, { requestId: 'replacement' }], + ]) + }) + + it('closes an existing pending request if access is already available', async () => { + mocks.group.mockResolvedValue({ ...group, config: DEFAULT_PERMISSION_GROUP_CONFIG }) + queueTableRows(permissionAccessRequest, [stored()]) + dbChainMockFns.returning.mockResolvedValueOnce([ + stored({ status: 'closed', decisionReason: 'Access is already available.' }), + ]) + const result = await createAccessRequest.execute({ principal, input: { scope, target } }) + expect(result.request).toMatchObject({ + status: 'closed', + decisionReason: 'Access is already available.', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mocks.outbox).toHaveBeenCalledWith(db, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, { + requestId: 'request', + }) + }) + + it.each([ + { pending: 100, daily: 0, message: '100 pending requests' }, + { pending: 0, daily: 100, message: '100 requests in the last 24 hours' }, + ])('enforces bounded admissions ($message)', async ({ pending, daily, message }) => { + queueTableRows(permissionAccessRequest, []) + queueTableRows(permissionAccessRequest, [{ total: pending }]) + queueTableRows(permissionAccessRequest, [{ total: daily }]) + await expect( + createAccessRequest.execute({ principal, input: { scope, target } }) + ).rejects.toThrow(message) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) + + it.each([25, 99])( + 'allows a new request after %s submissions in the rolling window', + async (daily) => { + queueTableRows(permissionAccessRequest, []) + queueTableRows(permissionAccessRequest, [{ total: 0 }]) + queueTableRows(permissionAccessRequest, [{ total: daily }]) + dbChainMockFns.returning.mockResolvedValueOnce([stored()]) + const result = await createAccessRequest.execute({ principal, input: { scope, target } }) + expect(result.changed).toBe(true) + expect(dbChainMockFns.insert).toHaveBeenCalledWith(permissionAccessRequest) + expect(mocks.audit.mock.calls[0][4]).toEqual([ + expect.objectContaining({ workspaceId: 'workspace', resourceId: 'request' }), + ]) + } + ) + + it('normalizes member cap requests to one organization-wide request across workspaces', async () => { + const cap = stored({ + target: { kind: 'usage_limit', id: 'member' }, + targetKey: 'usage_limit:member', + scopeKey: 'organization:organization:member-limit', + workspaceId: null, + membershipId: 'org-membership', + groupId: null, + groupName: null, + }) + mocks.membership.mockResolvedValue({ membershipId: 'org-membership', role: 'read' }) + queueTableRows(organizationMemberUsageLimit, [{ usageLimit: '10', updatedAt: new Date() }]) + dbChainMockFns.returning.mockResolvedValueOnce([cap]) + const first = await createAccessRequest.execute({ + principal, + input: { scope, target: { kind: 'usage_limit', id: 'member' } }, + }) + expect(first.changed).toBe(true) + expect(mocks.audit.mock.calls[0][4]).toEqual([ + expect.objectContaining({ workspaceId: null, resourceId: cap.id }), + ]) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: null, + membershipId: 'org-membership', + scopeKey: 'organization:organization:member-limit', + }) + ) + mocks.authorize.mockResolvedValue({ + ...context, + workspaceId: 'another-workspace', + membershipId: 'another-grant', + }) + queueTableRows(organizationMemberUsageLimit, [{ usageLimit: '10', updatedAt: new Date() }]) + queueTableRows(permissionAccessRequest, [cap]) + const second = await createAccessRequest.execute({ + principal, + input: { + scope: { kind: 'workspace', workspaceId: 'another-workspace' }, + target: { kind: 'usage_limit', id: 'member' }, + }, + }) + expect(second).toMatchObject({ changed: false, request: { id: cap.id } }) + expect(mocks.outbox).toHaveBeenCalledOnce() + expect(dbChainMockFns.insert).toHaveBeenCalledOnce() + }) + + it('preserves workspace membership provenance for an external member cap request', async () => { + queueTableRows(organizationMemberUsageLimit, [{ usageLimit: '10', updatedAt: new Date() }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + stored({ target: { kind: 'usage_limit', id: 'member' }, groupId: null }), + ]) + await createAccessRequest.execute({ + principal, + input: { scope, target: { kind: 'usage_limit', id: 'member' } }, + }) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace', + membershipId: 'membership', + scopeKey: 'organization:organization:member-limit', + }) + ) + }) + + it.each([true, false])( + 'deduplicates an external member cap only while its original access remains valid (%s)', + async (originValid) => { + const cap = stored({ + target: { kind: 'usage_limit', id: 'member' }, + targetKey: 'usage_limit:member', + scopeKey: 'organization:organization:member-limit', + workspaceId: 'original-workspace', + membershipId: 'original-grant', + groupId: null, + groupName: null, + }) + mocks.membership + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ membershipId: 'original-grant', role: 'read' }) + queueTableRows(organizationMemberUsageLimit, [{ usageLimit: '10', updatedAt: new Date() }]) + queueTableRows(permissionAccessRequest, [cap]) + queueTableRows(workspace, originValid ? [{ id: 'original-workspace' }] : []) + if (!originValid) { + dbChainMockFns.returning.mockResolvedValueOnce([stored({ ...cap, status: 'closed' })]) + dbChainMockFns.returning.mockResolvedValueOnce([stored({ ...cap, id: 'replacement' })]) + } + const result = await createAccessRequest.execute({ + principal, + input: { scope, target: { kind: 'usage_limit', id: 'member' } }, + }) + expect(result.changed).toBe(!originValid) + if (originValid) { + expect(result.request.id).toBe(cap.id) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + } else { + expect(result.request.id).toBe('replacement') + expect(mocks.outbox).toHaveBeenCalledTimes(2) + } + } + ) +}) + +describe('discovery and request history', () => { + it('finds an exact target beyond the first catalog page', async () => { + const integrations = Array.from({ length: 125 }, (_, index) => ({ + id: `integration-${index}`, + label: `Integration ${index}`, + })) + mocks.catalog.mockResolvedValue( + createAccessRequestCatalog({ + integrations, + providers: [], + models: [], + tools: [], + knowledgeConnectors: [], + }) + ) + mocks.targets.mockReturnValue(integrations.map(({ id }) => ({ kind: 'integration', id }))) + mocks.group.mockResolvedValue({ + ...group, + config: { ...group.config, allowedIntegrations: [] }, + }) + const selected = { kind: 'integration', id: 'integration-124' } as const + queueTableRows(permissionAccessRequest, [ + stored({ target: selected, targetKey: 'integration:integration-124' }), + ]) + const result = await discoverAccessRequests.execute({ + principal, + input: { + ...scope, + targetKind: 'integration', + targetKey: 'integration:integration-124', + limit: 1, + offset: 0, + }, + }) + expect(result).toMatchObject({ + total: 1, + hasMore: false, + entries: [{ target: selected, state: 'requestable', pendingRequestId: 'request' }], + }) + }) + + it('does not substitute another target when an exact target is unavailable', async () => { + const result = await discoverAccessRequests.execute({ + principal, + input: { ...scope, targetKey: 'integration:missing', limit: 1, offset: 0 }, + }) + expect(result).toMatchObject({ total: 0, hasMore: false, entries: [] }) + }) + + it('filters requestable state before pagination and reports pending request IDs', async () => { + mocks.targets.mockReturnValue([ + { kind: 'feature', configKey: 'hideKnowledgeBaseTab' }, + target, + { kind: 'feature', configKey: 'hideFilesTab' }, + ]) + queueTableRows(permissionAccessRequest, [ + { + id: 'existing-file-request', + targetKey: 'feature:hideFilesTab', + membershipId: 'membership', + groupId: 'group', + }, + ]) + const result = await discoverAccessRequests.execute({ + principal, + input: { ...scope, state: 'requestable', limit: 1, offset: 1 }, + }) + expect(result).toMatchObject({ + total: 2, + hasMore: false, + entries: [ + { + target: { kind: 'feature', configKey: 'hideFilesTab' }, + pendingRequestId: 'existing-file-request', + }, + ], + }) + expect(mocks.group).toHaveBeenCalledOnce() + }) + + it.each([true, false])( + 'only advertises a pending member cap for its current membership (%s)', + async (valid) => { + mocks.targets.mockReturnValue([{ kind: 'usage_limit', id: 'member' }]) + queueTableRows(organizationMemberUsageLimit, [{ usageLimit: '10', updatedAt: new Date() }]) + queueTableRows(permissionAccessRequest, [ + stored({ + targetKey: 'usage_limit:member', + groupId: null, + membershipId: valid ? context.membershipId : 'old-membership', + }), + ]) + const result = await discoverAccessRequests.execute({ + principal, + input: { ...scope, limit: 50, offset: 0 }, + }) + expect(result.entries[0]?.pendingRequestId).toBe(valid ? 'request' : null) + } + ) + + it('allows a new request when a pending request belongs to a previous governing group', async () => { + queueTableRows(permissionAccessRequest, [stored({ groupId: 'previous-group' })]) + const result = await discoverAccessRequests.execute({ + principal, + input: { ...scope, state: 'requestable', limit: 50, offset: 0 }, + }) + expect(result.entries).toEqual([expect.objectContaining({ target, pendingRequestId: null })]) + }) + + it('hides discovery while disabled but retains requester history', async () => { + mocks.enabled.mockResolvedValue(false) + const discovery = await discoverAccessRequests.execute({ + principal, + input: { ...scope, limit: 50, offset: 0 }, + }) + expect(discovery).toMatchObject({ enabled: false, entries: [] }) + const history = await listMyAccessRequests.execute({ + principal, + input: { scope, limit: 50, offset: 0 }, + }) + expect(history.requests).toHaveLength(1) + expect(mocks.list).toHaveBeenCalledWith(db, expect.anything(), 50, 0) + expect(mocks.list.mock.calls[0]?.[1]).toMatchObject({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + type: 'or', + conditions: expect.arrayContaining([ + expect.objectContaining({ + left: permissionAccessRequest.scopeKey, + right: 'organization:organization:member-limit', + }), + ]), + }), + ]), + }) + }) +}) + +describe('cancel access requests', () => { + it.each(['workspace', null])( + 'keeps cancellation in the stored request scope %s', + async (workspaceId) => { + const row = stored({ + workspaceId, + ...(workspaceId === null + ? { + scopeKey: 'organization:organization:member-limit', + target: { kind: 'usage_limit', id: 'member' }, + } + : {}), + }) + mocks.stored.mockResolvedValue(row) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...row, status: 'cancelled' }]) + await cancelAccessRequest.execute({ principal, input: { scope, requestId: row.id } }) + expect(mocks.audit.mock.calls[0][4]).toEqual([ + expect.objectContaining({ workspaceId, resourceId: row.id }), + ]) + } + ) + + it('allows cancellation while disabled and does not resend a decision for terminal requests', async () => { + mocks.enabled.mockResolvedValue(false) + dbChainMockFns.returning.mockResolvedValueOnce([stored({ status: 'cancelled' })]) + const first = await cancelAccessRequest.execute({ + principal, + input: { scope, requestId: 'request' }, + }) + expect(first.request.status).toBe('cancelled') + expect(mocks.outbox).toHaveBeenCalledWith(db, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, { + requestId: 'request', + }) + mocks.stored.mockResolvedValue(stored({ status: 'cancelled' })) + const second = await cancelAccessRequest.execute({ + principal, + input: { scope, requestId: 'request' }, + }) + expect(second.changed).toBe(false) + expect(mocks.outbox).toHaveBeenCalledOnce() + }) + + it('conceals requests belonging to another user or another workspace', async () => { + for (const row of [ + stored({ requesterId: 'other-user' }), + stored({ scopeKey: 'workspace:other-workspace' }), + ]) { + mocks.stored.mockResolvedValue(row) + await expect( + cancelAccessRequest.execute({ principal, input: { scope, requestId: row.id } }) + ).rejects.toThrow('not found') + } + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/permission-access-requests/application/requests.ts b/apps/sim/lib/permission-access-requests/application/requests.ts new file mode 100644 index 00000000000..e3e023492b3 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/application/requests.ts @@ -0,0 +1,578 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { + organizationAccessRequestSettings, + permissionAccessRequest, + workspace, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, count, eq, gte, isNull, or } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import type { DbOrTx } from '@/lib/db/types' +import type { AccessRequestContext } from '@/lib/permission-access-requests/application/authorization' +import { loadAccessRequestMembership } from '@/lib/permission-access-requests/application/authorization' +import { defineAuthorizedAccessRequestUseCase } from '@/lib/permission-access-requests/application/authorized-use-case' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { prepareAccessRequestPolicy } from '@/lib/permission-access-requests/application/prepare' +import { + listAccessRequestTargets, + loadAccessRequestCatalog, +} from '@/lib/permission-access-requests/catalog' +import { + ACCESS_REQUEST_MAX_DAILY_SUBMISSIONS, + ACCESS_REQUEST_MAX_PENDING, + ACCESS_REQUEST_SUBMISSION_WINDOW_MS, +} from '@/lib/permission-access-requests/constants' +import { + PERMISSION_ACCESS_REQUEST_CREATED_EVENT, + PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, +} from '@/lib/permission-access-requests/notification-events' +import { + evaluateAccessRequestTarget, + loadAccessRequestPolicy, +} from '@/lib/permission-access-requests/policy' +import { + listAccessRequestRecords, + loadStoredAccessRequest, + presentAccessRequest, +} from '@/lib/permission-access-requests/repository' +import { + isAccessRequestEnabled, + readAccessRequestSettings, +} from '@/lib/permission-access-requests/settings' +import type { + AccessRequestDiscovery, + AccessRequestRecord, + AccessRequestSettings, + AccessRequestStatus, + CreateAccessRequestInput, + DiscoverAccessRequestsInput, +} from '@/lib/permission-access-requests/types' +import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' +import { + describeAccessRequestTarget, + getAccessRequestTargetKey, + validateAccessRequestTarget, +} from '@/lib/permission-groups/access-requests/targets' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' + +function requireOrganization(organizationId: string | null): string { + if (!organizationId) + throw new OrchestrationError( + 'forbidden', + 'Access requests require an organization-owned workspace' + ) + return organizationId +} + +export function accessRequestScopeKey(scope: AccessRequestScope): string { + return scope.kind === 'workspace' + ? `workspace:${scope.workspaceId}` + : `organization:${scope.organizationId}` +} + +function memberLimitScopeKey(organizationId: string): string { + return `organization:${organizationId}:member-limit` +} + +async function hasCurrentMemberLimitMembership( + executor: DbOrTx, + context: AccessRequestContext, + userId: string, + pending: { workspaceId: string | null; membershipId: string } +): Promise { + if (!context.organizationId) return false + if (pending.workspaceId && pending.workspaceId === context.workspaceId) + return pending.membershipId === context.membershipId + if (pending.workspaceId) { + const [origin] = await executor + .select({ id: workspace.id }) + .from(workspace) + .where( + and( + eq(workspace.id, pending.workspaceId), + eq(workspace.organizationId, context.organizationId), + isNull(workspace.archivedAt) + ) + ) + .limit(1) + if (!origin) return false + } + const membership = await loadAccessRequestMembership( + executor, + userId, + pending.workspaceId + ? { kind: 'workspace', workspaceId: pending.workspaceId } + : { kind: 'organization', organizationId: context.organizationId }, + context.organizationId + ) + return membership?.membershipId === pending.membershipId +} + +export const discoverAccessRequests = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.discover, + scope: (input: DiscoverAccessRequestsInput) => input, + async execute({ input, principal, context, executor }): Promise { + const organizationId = context.organizationId + if (!organizationId || !(await isAccessRequestEnabled(organizationId, executor))) + return { enabled: false, organizationId, entries: [], total: 0, hasMore: false } + const catalog = await loadAccessRequestCatalog( + { + userId: principal.userId, + organizationId, + workspaceId: context.workspaceId, + }, + input.targetKind + ) + const search = input.search?.toLowerCase() ?? '' + const targets = listAccessRequestTargets(catalog).filter((target) => { + const description = describeAccessRequestTarget(target, catalog) + return ( + description && + (description.scope === 'workspace-or-organization' || description.scope === input.kind) && + (!input.targetKind || input.targetKind === target.kind) && + (!input.targetKey || input.targetKey === getAccessRequestTargetKey(target)) && + (!search || description.label.toLowerCase().includes(search)) + ) + }) + const offset = input.offset ?? 0 + const policy = await loadAccessRequestPolicy(executor, context, principal.userId) + const pending = await executor + .select({ + id: permissionAccessRequest.id, + targetKey: permissionAccessRequest.targetKey, + membershipId: permissionAccessRequest.membershipId, + groupId: permissionAccessRequest.groupId, + workspaceId: permissionAccessRequest.workspaceId, + }) + .from(permissionAccessRequest) + .where( + and( + eq(permissionAccessRequest.organizationId, organizationId), + eq(permissionAccessRequest.requesterId, principal.userId), + or( + eq(permissionAccessRequest.scopeKey, accessRequestScopeKey(input)), + eq(permissionAccessRequest.scopeKey, memberLimitScopeKey(organizationId)) + ), + eq(permissionAccessRequest.status, 'pending'), + input.targetKey ? eq(permissionAccessRequest.targetKey, input.targetKey) : undefined + ) + ) + .limit(ACCESS_REQUEST_MAX_PENDING) + const pendingMemberLimit = pending.find((row) => row.targetKey === 'usage_limit:member') + const memberLimitMembershipMatches = pendingMemberLimit + ? await hasCurrentMemberLimitMembership( + executor, + context, + principal.userId, + pendingMemberLimit + ) + : false + const pendingByTarget = new Map( + pending + .filter((row) => + row.targetKey === 'usage_limit:member' + ? memberLimitMembershipMatches + : row.membershipId === context.membershipId && + row.groupId === (policy.group?.permissionGroupId ?? null) + ) + .map((row) => [row.targetKey, row.id]) + ) + const entries: AccessRequestDiscovery['entries'] = [] + for (const target of targets) { + const result = await evaluateAccessRequestTarget( + executor, + context, + principal.userId, + input, + target, + catalog, + policy, + false + ) + if (input.state && result.state !== input.state) continue + entries.push({ + target, + label: describeAccessRequestTarget(target, catalog)!.label, + state: result.state, + reason: result.reason, + pendingRequestId: pendingByTarget.get(getAccessRequestTargetKey(target)) ?? null, + }) + } + const page = entries.slice(offset, offset + (input.limit ?? 50)) + return { + enabled: true, + organizationId, + entries: page, + total: entries.length, + hasMore: offset + page.length < entries.length, + } + }, +}) + +interface MutationResult { + request: AccessRequestRecord + changed: boolean +} + +export const createAccessRequest = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.create, + scope: (input: CreateAccessRequestInput) => input.scope, + prepare: ({ principal, context, input }) => + prepareAccessRequestPolicy(context, principal.userId, input.target.kind), + mutation: true, + async execute({ + principal, + input, + context, + executor, + prepared, + }): Promise { + const organizationId = requireOrganization(context.organizationId) + if (!(await isAccessRequestEnabled(organizationId, executor, prepared.globalEnabled))) + throw new OrchestrationError( + 'forbidden', + 'Access requests are turned off for this organization' + ) + await acquirePermissionGroupOrgLock(executor, organizationId, { + lockTimeoutAlreadyBounded: true, + }) + const catalog = prepared.catalog + const target = validateAccessRequestTarget(input.target, catalog) + if (!target) + throw new OrchestrationError('validation', 'This item is unavailable for access requests') + const targetKey = getAccessRequestTargetKey(target) + const memberLimitMembership = + target.kind === 'usage_limit' + ? await loadAccessRequestMembership( + executor, + principal.userId, + { kind: 'organization', organizationId }, + organizationId + ) + : null + const requestMembershipId = memberLimitMembership?.membershipId ?? context.membershipId + const requestWorkspaceId = memberLimitMembership ? null : context.workspaceId + const scopeKey = + target.kind === 'usage_limit' + ? memberLimitScopeKey(organizationId) + : accessRequestScopeKey(input.scope) + const currentPolicy = await loadAccessRequestPolicy( + executor, + context, + principal.userId, + prepared.entitled + ) + const policy = await evaluateAccessRequestTarget( + executor, + context, + principal.userId, + input.scope, + target, + catalog, + currentPolicy + ) + const [pending] = await executor + .select() + .from(permissionAccessRequest) + .where( + and( + eq(permissionAccessRequest.organizationId, organizationId), + eq(permissionAccessRequest.requesterId, principal.userId), + eq(permissionAccessRequest.scopeKey, scopeKey), + eq(permissionAccessRequest.targetKey, targetKey), + eq(permissionAccessRequest.status, 'pending') + ) + ) + .limit(1) + let pendingMembershipMatches = pending?.membershipId === requestMembershipId + if ( + pending && + target.kind === 'usage_limit' && + !memberLimitMembership && + pending.workspaceId && + pending.workspaceId !== requestWorkspaceId + ) { + const [origin] = await executor + .select({ id: workspace.id }) + .from(workspace) + .where( + and( + eq(workspace.id, pending.workspaceId), + eq(workspace.organizationId, organizationId), + isNull(workspace.archivedAt) + ) + ) + .limit(1) + const membership = origin + ? await loadAccessRequestMembership( + executor, + principal.userId, + { kind: 'workspace', workspaceId: pending.workspaceId }, + organizationId + ) + : null + pendingMembershipMatches = membership?.membershipId === pending.membershipId + } + if ( + pending && + pendingMembershipMatches && + pending.groupId === (policy.group?.permissionGroupId ?? null) && + policy.state === 'requestable' + ) + return { request: await presentAccessRequest(executor, pending), changed: false } + let closedRequest: AccessRequestRecord | undefined + if (pending) { + const [closed] = await executor + .update(permissionAccessRequest) + .set({ + status: 'closed', + decisionReason: + policy.state === 'allowed' + ? 'Access is already available.' + : 'Membership or the governing policy changed after this request was created.', + decidedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(permissionAccessRequest.id, pending.id)) + .returning() + await enqueueOutboxEvent(executor, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, { + requestId: pending.id, + }) + if (policy.state === 'allowed') + return { request: await presentAccessRequest(executor, closed), changed: true } + closedRequest = await presentAccessRequest(executor, closed) + } + if (policy.state !== 'requestable') + throw new OrchestrationError( + 'conflict', + policy.reason ?? 'You already have access to this item' + ) + const [outstanding] = await executor + .select({ total: count() }) + .from(permissionAccessRequest) + .where( + and( + eq(permissionAccessRequest.organizationId, organizationId), + eq(permissionAccessRequest.requesterId, principal.userId), + eq(permissionAccessRequest.status, 'pending') + ) + ) + if ((outstanding?.total ?? 0) >= ACCESS_REQUEST_MAX_PENDING) + throw new OrchestrationError( + 'conflict', + `You have ${ACCESS_REQUEST_MAX_PENDING} pending requests. Cancel an existing request before sending another.` + ) + const [daily] = await executor + .select({ total: count() }) + .from(permissionAccessRequest) + .where( + and( + eq(permissionAccessRequest.organizationId, organizationId), + eq(permissionAccessRequest.requesterId, principal.userId), + gte( + permissionAccessRequest.createdAt, + new Date(Date.now() - ACCESS_REQUEST_SUBMISSION_WINDOW_MS) + ) + ) + ) + if ((daily?.total ?? 0) >= ACCESS_REQUEST_MAX_DAILY_SUBMISSIONS) + throw new OrchestrationError( + 'conflict', + `You have sent ${ACCESS_REQUEST_MAX_DAILY_SUBMISSIONS} requests in the last 24 hours. Try again later.` + ) + const [row] = await executor + .insert(permissionAccessRequest) + .values({ + id: generateId(), + organizationId, + requesterId: principal.userId, + workspaceId: requestWorkspaceId, + scopeKey, + targetKey, + target, + targetLabel: describeAccessRequestTarget(target, catalog)!.label, + membershipId: requestMembershipId, + groupId: policy.group?.permissionGroupId ?? null, + groupName: policy.group?.groupName ?? null, + reason: input.reason ?? '', + }) + .returning() + await enqueueOutboxEvent(executor, PERMISSION_ACCESS_REQUEST_CREATED_EVENT, { + requestId: row.id, + }) + return { request: await presentAccessRequest(executor, row), changed: true, closedRequest } + }, + projectAudit: ({ result }) => + result.changed + ? [ + ...(result.closedRequest + ? [ + { + action: AuditAction.PERMISSION_ACCESS_REQUEST_CLOSED, + resourceType: AuditResourceType.PERMISSION_ACCESS_REQUEST, + resourceId: result.closedRequest.id, + workspaceId: result.closedRequest.workspaceId, + metadata: { target: result.closedRequest.target }, + }, + ] + : []), + { + action: + result.request.status === 'closed' + ? AuditAction.PERMISSION_ACCESS_REQUEST_CLOSED + : AuditAction.PERMISSION_ACCESS_REQUEST_CREATED, + resourceType: AuditResourceType.PERMISSION_ACCESS_REQUEST, + resourceId: result.request.id, + workspaceId: result.request.workspaceId, + metadata: { target: result.request.target }, + }, + ] + : [], +}) + +interface ListMineInput { + requestId?: string + scope: AccessRequestScope + limit: number + offset: number +} +export const listMyAccessRequests = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.listMine, + scope: (input: ListMineInput) => input.scope, + async execute({ principal, input, context, executor }) { + if (!context.organizationId) return { requests: [], total: 0, hasMore: false } + return listAccessRequestRecords( + executor, + and( + eq(permissionAccessRequest.organizationId, context.organizationId), + eq(permissionAccessRequest.requesterId, principal.userId), + input.requestId ? eq(permissionAccessRequest.id, input.requestId) : undefined, + or( + eq(permissionAccessRequest.scopeKey, accessRequestScopeKey(input.scope)), + eq(permissionAccessRequest.scopeKey, memberLimitScopeKey(context.organizationId)) + ) + )!, + input.limit, + input.offset + ) + }, +}) + +interface CancelInput { + scope: AccessRequestScope + requestId: string +} +export const cancelAccessRequest = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.cancel, + scope: (input: CancelInput) => input.scope, + mutation: true, + async execute({ principal, input, context, executor }): Promise { + const row = await loadStoredAccessRequest( + executor, + requireOrganization(context.organizationId), + input.requestId, + true + ) + if ( + row.requesterId !== principal.userId || + (row.scopeKey !== accessRequestScopeKey(input.scope) && + row.scopeKey !== memberLimitScopeKey(row.organizationId)) + ) + throw new OrchestrationError('not_found', 'Access request not found') + if (row.status !== 'pending') + return { request: await presentAccessRequest(executor, row), changed: false } + const [updated] = await executor + .update(permissionAccessRequest) + .set({ + status: 'cancelled', + decidedBy: principal.userId, + decidedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(permissionAccessRequest.id, row.id)) + .returning() + await enqueueOutboxEvent(executor, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, { + requestId: row.id, + }) + return { request: await presentAccessRequest(executor, updated), changed: true } + }, + projectAudit: ({ result }) => + result.changed + ? [ + { + action: AuditAction.PERMISSION_ACCESS_REQUEST_CANCELLED, + resourceType: AuditResourceType.PERMISSION_ACCESS_REQUEST, + resourceId: result.request.id, + workspaceId: result.request.workspaceId, + }, + ] + : [], +}) + +interface OrganizationInput { + organizationId: string +} +interface OrganizationListInput extends OrganizationInput { + limit: number + offset: number + status?: AccessRequestStatus +} +const organizationScope = (input: OrganizationInput): AccessRequestScope => ({ + kind: 'organization', + organizationId: input.organizationId, +}) + +export const listOrganizationAccessRequests = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.listOrganization, + scope: (input: OrganizationListInput) => organizationScope(input), + execute: ({ input, executor }) => + listAccessRequestRecords( + executor, + and( + eq(permissionAccessRequest.organizationId, input.organizationId), + input.status ? eq(permissionAccessRequest.status, input.status) : undefined + )!, + input.limit, + input.offset + ), +}) + +export const getAccessRequestSettings = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.getSettings, + scope: organizationScope, + execute: ({ input, executor }) => readAccessRequestSettings(input.organizationId, executor), +}) + +interface UpdateSettingsInput extends OrganizationInput, AccessRequestSettings {} +export const updateAccessRequestSettings = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.updateSettings, + scope: (input: UpdateSettingsInput) => organizationScope(input), + mutation: true, + async execute({ principal, input, executor }): Promise { + await executor + .insert(organizationAccessRequestSettings) + .values({ + organizationId: input.organizationId, + allowRequests: input.allowRequests, + updatedBy: principal.userId, + }) + .onConflictDoUpdate({ + target: organizationAccessRequestSettings.organizationId, + set: { + allowRequests: input.allowRequests, + updatedBy: principal.userId, + updatedAt: new Date(), + }, + }) + return { allowRequests: input.allowRequests } + }, + projectAudit: ({ input }) => [ + { + action: AuditAction.PERMISSION_ACCESS_REQUEST_SETTINGS_CHANGED, + resourceType: AuditResourceType.PERMISSION_ACCESS_REQUEST, + resourceId: input.organizationId, + metadata: { allowRequests: input.allowRequests }, + }, + ], +}) diff --git a/apps/sim/lib/permission-access-requests/application/review.test.ts b/apps/sim/lib/permission-access-requests/application/review.test.ts new file mode 100644 index 00000000000..cc111932b7f --- /dev/null +++ b/apps/sim/lib/permission-access-requests/application/review.test.ts @@ -0,0 +1,580 @@ +/** + * @vitest-environment node + */ +import { AuditAction } from '@sim/audit' +import { db } from '@sim/db' +import { + organizationMemberUsageLimit, + permissionAccessRequest, + permissionGroup, + workspace, +} from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AccessRequestRecord, AccessRequestTarget } from '@/lib/api/contracts/access-requests' +import type { StoredAccessRequest } from '@/lib/permission-access-requests/repository' +import { createAccessRequestCatalog } from '@/lib/permission-groups/access-requests/targets' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + membership: vi.fn(), + organizationLock: vi.fn(), + groupLock: vi.fn(), + audit: vi.fn(), + outbox: vi.fn(), + enabled: vi.fn(), + featureEnabled: vi.fn(), + enterprise: vi.fn(), + catalog: vi.fn(), + deploymentReason: vi.fn(), + group: vi.fn(), + impact: vi.fn(), + present: vi.fn(), + stored: vi.fn(), + setLimit: vi.fn(), +})) + +vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ + authorizeAccessRequestScope: mocks.authorize, + loadAccessRequestMembership: mocks.membership, +})) +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationMutationLock: mocks.organizationLock, +})) +vi.mock('@/lib/core/application/authorized-workspace-use-case', () => ({ + recordProjectedUseCaseAuditEntries: mocks.audit, +})) +vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mocks.outbox })) +vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.groupLock })) +vi.mock('@/lib/permission-access-requests/settings', () => ({ + isAccessRequestEnabled: mocks.enabled, +})) +vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mocks.featureEnabled })) +vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true, isAccessControlEnabled: true })) +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mocks.enterprise, +})) +vi.mock('@/lib/permission-access-requests/catalog', () => ({ + loadAccessRequestCatalog: mocks.catalog, + getAccessRequestDeploymentUnavailableReason: mocks.deploymentReason, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + resolveWorkspaceGroup: mocks.group, + resolveDefaultGroup: mocks.group, +})) +vi.mock('@/lib/permission-access-requests/impact', () => ({ + loadAccessRequestGroupImpact: mocks.impact, +})) +vi.mock('@/lib/permission-access-requests/repository', () => ({ + presentAccessRequest: mocks.present, + loadStoredAccessRequest: mocks.stored, +})) +vi.mock('@/lib/billing/organizations/member-limits', () => ({ + setOrgMemberUsageLimit: mocks.setLimit, +})) + +import { + previewAccessRequest, + resolveAccessRequest, +} from '@/lib/permission-access-requests/application/review' +import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/lib/permission-access-requests/notification-events' + +const principal = { kind: 'session', userId: 'admin', sessionId: 'session' } as const +const input = { organizationId: 'organization', requestId: 'request' } +const target = { kind: 'model', id: 'gpt-example' } as const +const catalog = createAccessRequestCatalog({ + integrations: [], + providers: [{ id: 'openai', label: 'OpenAI' }], + models: [{ id: 'gpt-example', label: 'Example model', providerId: 'openai' }], + tools: [], + knowledgeConnectors: [], +}) +const group = { + permissionGroupId: 'group', + groupName: 'Restricted group', + resolution: 'explicit-member', + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedModelProviders: [], + deniedModels: ['gpt-example', 'keep-denied'], + hideTablesTab: true, + }, +} +const impact = { + memberCount: 12, + workspaceCount: 2, + workspaceNames: ['One', 'Two'], + truncated: false, +} + +function stored(overrides: Partial = {}): StoredAccessRequest { + return { + id: 'request', + organizationId: 'organization', + requesterId: 'requester', + workspaceId: 'workspace', + scopeKey: 'workspace:workspace', + targetKey: 'model:gpt-example', + target, + targetLabel: 'Example model', + membershipId: 'membership', + groupId: 'group', + groupName: 'Restricted group', + reason: '', + status: 'pending', + decisionReason: null, + decidedBy: null, + decision: null, + createdAt: new Date('2026-09-01T00:00:00Z'), + updatedAt: new Date('2026-09-01T00:00:00Z'), + decidedAt: null, + ...overrides, + } +} + +function record(row: StoredAccessRequest): AccessRequestRecord { + return { + id: row.id, + organizationId: row.organizationId, + workspaceId: row.workspaceId, + target: row.target as AccessRequestTarget, + targetLabel: row.targetLabel, + reason: row.reason, + status: row.status, + decisionReason: row.decisionReason, + createdAt: row.createdAt.toISOString(), + decidedAt: row.decidedAt?.toISOString() ?? null, + groupName: row.groupName, + requester: { id: row.requesterId, name: 'Requester', email: 'requester@example.com' }, + } +} + +function queueWorkspace() { + queueTableRows(workspace, [{ organizationId: 'organization', allowPersonalApiKeys: true }]) +} + +function queueLimit(credits: number) { + const row = { usageLimit: String(credits / 200), updatedAt: new Date('2026-09-01T00:00:00Z') } + queueTableRows(organizationMemberUsageLimit, [row]) + queueTableRows(organizationMemberUsageLimit, [row]) +} + +async function preview() { + queueWorkspace() + return previewAccessRequest.execute({ principal, input }) +} + +beforeEach(() => { + vi.resetAllMocks() + resetDbChainMock() + mocks.authorize.mockResolvedValue({ + organizationId: 'organization', + workspaceId: null, + membershipId: 'admin-membership', + role: 'admin', + }) + mocks.membership.mockResolvedValue({ membershipId: 'membership', role: 'read' }) + mocks.enabled.mockResolvedValue(true) + mocks.featureEnabled.mockResolvedValue(true) + mocks.enterprise.mockResolvedValue(true) + mocks.catalog.mockResolvedValue(catalog) + mocks.deploymentReason.mockReturnValue(null) + mocks.group.mockResolvedValue(group) + mocks.impact.mockResolvedValue({ impact, revision: 'cohort-v1' }) + mocks.stored.mockResolvedValue(stored()) + mocks.present.mockImplementation((_executor, row: StoredAccessRequest) => record(row)) +}) + +describe('permission request review', () => { + it('shows the provider expansion, exact model removal, and affected scope together', async () => { + const result = await preview() + expect(result).toMatchObject({ + canApply: true, + resolutionKind: 'permission', + group: { id: 'group' }, + impact, + }) + expect(result.changes).toEqual([ + expect.objectContaining({ + configKey: 'allowedModelProviders', + before: [], + after: ['openai'], + }), + expect.objectContaining({ + configKey: 'deniedModels', + before: ['gpt-example', 'keep-denied'], + after: ['keep-denied'], + }), + ]) + }) + + it('rechecks enterprise entitlement after admission on the transaction executor', async () => { + const before = await preview() + mocks.enterprise.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + queueWorkspace() + await expect( + resolveAccessRequest.execute({ + principal, + input: { + ...input, + decision: { action: 'apply', expectedFingerprint: before.fingerprint }, + }, + }) + ).rejects.toThrow('Permission groups are unavailable') + expect(mocks.enterprise).toHaveBeenLastCalledWith('organization', 'return-false', db) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) + + it('preserves valid legacy policy values and long names in fulfilled snapshots', async () => { + const longName = 'x'.repeat(600) + mocks.group.mockResolvedValue({ + ...group, + groupName: longName, + config: { + ...group.config, + deniedModels: [ + target.id, + ...Array.from({ length: 10_001 }, (_, index) => `${longName}${index}`), + ], + }, + }) + mocks.impact.mockResolvedValue({ + impact: { ...impact, workspaceNames: [longName] }, + revision: 'large-policy', + }) + const before = await preview() + queueWorkspace() + dbChainMockFns.returning.mockResolvedValueOnce([stored({ status: 'fulfilled' })]) + const result = await resolveAccessRequest.execute({ + principal, + input: { + ...input, + decision: { action: 'apply', expectedFingerprint: before.fingerprint }, + }, + }) + const decision = 'decision' in result ? result.decision : undefined + expect( + decision?.changes.find((change) => change.configKey === 'deniedModels')?.after + ).toHaveLength(10_001) + mocks.stored.mockResolvedValue(stored({ status: 'fulfilled', decision })) + const history = await previewAccessRequest.execute({ principal, input }) + expect(history.impact.workspaceNames).toEqual([longName]) + expect(history.group?.name).toBe(longName) + }) + + it('rejects a stale preview when either policy or audience changes', async () => { + const before = await preview() + mocks.impact.mockResolvedValue({ impact, revision: 'cohort-v2' }) + queueWorkspace() + await expect( + resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'apply', expectedFingerprint: before.fingerprint } }, + }) + ).rejects.toThrow('Review the updated preview') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) + + it('refuses an approval after the requester moves into another governing group', async () => { + const before = await preview() + mocks.group.mockResolvedValue({ ...group, permissionGroupId: 'replacement-group' }) + queueWorkspace() + await expect( + resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'apply', expectedFingerprint: before.fingerprint } }, + }) + ).rejects.toThrow('governing permission group changed') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('requires a fresh preview when catalog parent rules change the proposed patch', async () => { + const before = await preview() + mocks.catalog.mockResolvedValue( + createAccessRequestCatalog({ + integrations: [], + providers: [{ id: 'replacement-provider', label: 'Replacement provider' }], + models: [{ id: 'gpt-example', label: 'Example model', providerId: 'replacement-provider' }], + tools: [], + knowledgeConnectors: [], + }) + ) + queueWorkspace() + await expect( + resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'apply', expectedFingerprint: before.fingerprint } }, + }) + ).rejects.toThrow('Review the updated preview') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) + + it('refuses an approval after the requester leaves and rejoins', async () => { + const before = await preview() + mocks.membership.mockResolvedValue({ membershipId: 'new-membership', role: 'read' }) + queueWorkspace() + await expect( + resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'apply', expectedFingerprint: before.fingerprint } }, + }) + ).rejects.toThrow('membership changed') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) + + it('rejects a newly imposed deployment ceiling before writing a group policy', async () => { + const before = await preview() + mocks.deploymentReason.mockReturnValue('Disabled by this deployment.') + queueWorkspace() + await expect( + resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'apply', expectedFingerprint: before.fingerprint } }, + }) + ).rejects.toThrow('Disabled by this deployment') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('applies the complete group patch, durable decision, and outbox with one executor', async () => { + const before = await preview() + queueWorkspace() + dbChainMockFns.returning.mockResolvedValueOnce([stored({ status: 'fulfilled' })]) + const result = await resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'apply', expectedFingerprint: before.fingerprint } }, + }) + expect(result.request.status).toBe('fulfilled') + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([ + permissionGroup, + permissionAccessRequest, + ]) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + config: { + ...group.config, + allowedModelProviders: ['openai'], + deniedModels: ['keep-denied'], + }, + }) + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'fulfilled', + decision: expect.objectContaining({ + changes: before.changes, + impact, + group: { id: 'group', name: 'Restricted group' }, + }), + }) + ) + expect(mocks.membership).toHaveBeenLastCalledWith( + db, + 'requester', + { kind: 'workspace', workspaceId: 'workspace' }, + 'organization', + true + ) + expect(mocks.outbox).toHaveBeenCalledWith(db, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, { + requestId: 'request', + }) + expect(mocks.setLimit).not.toHaveBeenCalled() + }) + + it('blocks approval while requests are disabled but still permits an explicit decline', async () => { + const before = await preview() + mocks.enabled.mockResolvedValue(false) + queueWorkspace() + await expect( + resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'apply', expectedFingerprint: before.fingerprint } }, + }) + ).rejects.toThrow('turned off') + dbChainMockFns.returning.mockResolvedValueOnce([ + stored({ status: 'declined', decisionReason: 'Use the existing provider.' }), + ]) + const result = await resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'decline', reason: 'Use the existing provider.' } }, + }) + expect(result.request.status).toBe('declined') + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(permissionGroup) + expect(mocks.outbox).toHaveBeenCalledOnce() + }) + + it.each(['workspace', null])( + 'attributes a declined request to its stored scope %s', + async (workspaceId) => { + mocks.stored.mockResolvedValue(stored({ workspaceId })) + dbChainMockFns.returning.mockResolvedValueOnce([stored({ workspaceId, status: 'declined' })]) + await resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'decline', reason: 'Use the existing provider.' } }, + }) + expect(mocks.audit.mock.calls[0][4]).toEqual([ + expect.objectContaining({ + action: AuditAction.PERMISSION_ACCESS_REQUEST_DECLINED, + workspaceId, + }), + ]) + } + ) + + it('keeps request fulfillment workspace-scoped and the group change organization-scoped', async () => { + const before = await preview() + queueWorkspace() + dbChainMockFns.returning.mockResolvedValueOnce([stored({ status: 'fulfilled' })]) + await resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'apply', expectedFingerprint: before.fingerprint } }, + }) + const [, defaultWorkspaceId, , , entries] = mocks.audit.mock.calls[0] + expect(defaultWorkspaceId).toBeNull() + expect(entries).toEqual([ + expect.objectContaining({ + action: AuditAction.PERMISSION_ACCESS_REQUEST_FULFILLED, + workspaceId: 'workspace', + }), + expect.objectContaining({ action: AuditAction.PERMISSION_GROUP_UPDATED }), + ]) + expect(entries[1]).not.toHaveProperty('workspaceId') + }) + + it('does not apply or notify a second time after resolution', async () => { + mocks.stored.mockResolvedValue(stored({ status: 'fulfilled' })) + const result = await resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'apply', expectedFingerprint: 'obsolete' } }, + }) + expect(result.changed).toBe(false) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + expect(mocks.catalog).not.toHaveBeenCalled() + expect(mocks.audit.mock.calls[0][4]).toEqual([]) + }) + + it('reads fulfilled history from its stored decision without rebuilding the catalog', async () => { + const decision = { + resolutionKind: 'permission', + changes: [{ configKey: 'hideTablesTab', label: 'Tables', before: true, after: false }], + impact, + group: { id: 'original-group', name: 'Original group' }, + currentLimitCredits: null, + newLimitCredits: null, + fingerprint: 'original-preview', + } + mocks.stored.mockResolvedValue(stored({ status: 'fulfilled', decision })) + const result = await previewAccessRequest.execute({ principal, input }) + expect(result).toMatchObject({ ...decision, canApply: false }) + expect(mocks.catalog).not.toHaveBeenCalled() + expect(mocks.membership).not.toHaveBeenCalled() + expect(mocks.group).not.toHaveBeenCalled() + }) +}) + +describe('member limit review', () => { + beforeEach(() => { + mocks.stored.mockResolvedValue( + stored({ + workspaceId: null, + target: { kind: 'usage_limit', id: 'member' }, + targetKey: 'usage_limit:member', + scopeKey: 'organization:organization:member-limit', + groupId: null, + groupName: null, + }) + ) + }) + + it.each([undefined, 2000, 1999, 2000.5])( + 'requires a whole-number limit greater than the current value (%s)', + async (newLimitCredits) => { + queueLimit(2000) + const before = await previewAccessRequest.execute({ principal, input }) + queueLimit(2000) + await expect( + resolveAccessRequest.execute({ + principal, + input: { + ...input, + decision: { action: 'apply', expectedFingerprint: before.fingerprint, newLimitCredits }, + }, + }) + ).rejects.toThrow('greater than the current limit') + expect(mocks.setLimit).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + } + ) + + it('changes the requester cap in stored dollars without changing group policy', async () => { + queueLimit(2000) + const before = await previewAccessRequest.execute({ principal, input }) + expect(before).toMatchObject({ + resolutionKind: 'usage_limit', + group: null, + currentLimitCredits: 2000, + canApply: true, + }) + queueLimit(2000) + dbChainMockFns.returning.mockResolvedValueOnce([ + stored({ + workspaceId: null, + status: 'fulfilled', + target: { kind: 'usage_limit', id: 'member' }, + }), + ]) + await resolveAccessRequest.execute({ + principal, + input: { + ...input, + decision: { + action: 'apply', + expectedFingerprint: before.fingerprint, + newLimitCredits: 3000, + }, + }, + }) + expect(mocks.setLimit).toHaveBeenCalledWith('organization', 'requester', 15, 'admin', db) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(permissionGroup) + const [, defaultWorkspaceId, , , entries] = mocks.audit.mock.calls[0] + expect(defaultWorkspaceId).toBeNull() + expect(entries).toEqual([ + expect.objectContaining({ + action: AuditAction.PERMISSION_ACCESS_REQUEST_FULFILLED, + workspaceId: null, + }), + expect.objectContaining({ action: AuditAction.ORG_MEMBER_USAGE_LIMIT_CHANGED }), + ]) + expect(entries[1]).not.toHaveProperty('workspaceId') + expect(mocks.outbox).toHaveBeenCalledWith(db, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, { + requestId: 'request', + }) + }) + + it('requires another review if the current cap changes after preview', async () => { + queueLimit(2000) + const before = await previewAccessRequest.execute({ principal, input }) + queueLimit(2500) + await expect( + resolveAccessRequest.execute({ + principal, + input: { + ...input, + decision: { + action: 'apply', + expectedFingerprint: before.fingerprint, + newLimitCredits: 3000, + }, + }, + }) + ).rejects.toThrow('Review the updated preview') + expect(mocks.setLimit).not.toHaveBeenCalled() + expect(mocks.outbox).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/permission-access-requests/application/review.ts b/apps/sim/lib/permission-access-requests/application/review.ts new file mode 100644 index 00000000000..630cd7d4fca --- /dev/null +++ b/apps/sim/lib/permission-access-requests/application/review.ts @@ -0,0 +1,398 @@ +import { createHash } from 'node:crypto' +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { permissionAccessRequest, permissionGroup, workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { creditsToDollars } from '@/lib/billing/credits/conversion' +import { setOrgMemberUsageLimit } from '@/lib/billing/organizations/member-limits' +import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application/authorized-workspace-use-case' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import type { DbOrTx } from '@/lib/db/types' +import { loadAccessRequestMembership } from '@/lib/permission-access-requests/application/authorization' +import { defineAuthorizedAccessRequestUseCase } from '@/lib/permission-access-requests/application/authorized-use-case' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { + type PreparedAccessRequestPolicy, + prepareAccessRequestPolicy, +} from '@/lib/permission-access-requests/application/prepare' +import { loadAccessRequestGroupImpact } from '@/lib/permission-access-requests/impact' +import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/lib/permission-access-requests/notification-events' +import { + evaluateAccessRequestTarget, + loadAccessRequestPolicy, + loadMemberLimit, +} from '@/lib/permission-access-requests/policy' +import { + loadStoredAccessRequest, + presentAccessRequest, + type StoredAccessRequest, +} from '@/lib/permission-access-requests/repository' +import { + storedAccessRequestDecisionSchema, + storedAccessRequestTargetSchema, +} from '@/lib/permission-access-requests/schemas' +import { isAccessRequestEnabled } from '@/lib/permission-access-requests/settings' +import type { + AccessRequestPreview, + ResolveAccessRequestDecision, +} from '@/lib/permission-access-requests/types' +import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' + +interface ReviewInput { + organizationId: string + requestId: string +} +interface ResolveInput extends ReviewInput { + decision: ResolveAccessRequestDecision +} +const organizationScope = (input: ReviewInput): AccessRequestScope => ({ + kind: 'organization', + organizationId: input.organizationId, +}) + +/** Checks the requester's present scope before inspecting or modifying any governing policy. */ +async function loadReviewPreview( + executor: DbOrTx, + row: StoredAccessRequest, + prepared: PreparedAccessRequestPolicy | null, + forUpdate = false +) { + const request = await presentAccessRequest(executor, row) + if (row.status === 'fulfilled' && row.decision) { + const snapshot = storedAccessRequestDecisionSchema.parse(row.decision) + const common = { + request, + changes: snapshot.changes, + impact: snapshot.impact, + fingerprint: snapshot.fingerprint, + canApply: false, + unavailableReason: 'This request has already been fulfilled.', + newLimitCredits: snapshot.newLimitCredits, + } + const preview: AccessRequestPreview = + snapshot.resolutionKind === 'usage_limit' + ? { + ...common, + resolutionKind: 'usage_limit', + group: null, + currentLimitCredits: snapshot.currentLimitCredits, + } + : { + ...common, + resolutionKind: 'permission', + group: snapshot.group, + currentLimitCredits: null, + } + return { preview, policy: null } + } + if (!prepared) throw new OrchestrationError('internal', 'Request preview preparation is missing') + const scope: AccessRequestScope = row.workspaceId + ? { kind: 'workspace', workspaceId: row.workspaceId } + : { kind: 'organization', organizationId: row.organizationId } + let canonicalWorkspaceValid = true + if (row.workspaceId) { + const query = executor + .select({ + organizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + }) + .from(workspace) + .where(and(eq(workspace.id, row.workspaceId), isNull(workspace.archivedAt))) + const [canonical] = forUpdate ? await query.for('update').limit(1) : await query.limit(1) + canonicalWorkspaceValid = Boolean(canonical && canonical.organizationId === row.organizationId) + } + const membership = canonicalWorkspaceValid + ? await loadAccessRequestMembership( + executor, + row.requesterId, + scope, + row.organizationId, + forUpdate + ) + : null + const context = { + organizationId: row.organizationId, + workspaceId: row.workspaceId, + membershipId: membership?.membershipId ?? '', + role: membership?.role ?? ('read' as const), + } + if (forUpdate) + await acquirePermissionGroupOrgLock(executor, row.organizationId, { + lockTimeoutAlreadyBounded: true, + }) + const catalog = prepared.catalog + const currentPolicy = await loadAccessRequestPolicy( + executor, + context, + row.requesterId, + prepared.entitled + ) + const target = storedAccessRequestTargetSchema.parse(row.target) + const policy = await evaluateAccessRequestTarget( + executor, + context, + row.requesterId, + scope, + target, + catalog, + currentPolicy + ) + const enabled = await isAccessRequestEnabled(row.organizationId, executor, prepared.globalEnabled) + const audience = policy.group + ? await loadAccessRequestGroupImpact( + executor, + row.organizationId, + policy.group.permissionGroupId + ) + : { + impact: { + memberCount: 1, + workspaceCount: row.workspaceId ? 1 : 0, + workspaceNames: [], + truncated: false, + }, + revision: '', + } + const limit = + target.kind === 'usage_limit' + ? await loadMemberLimit(executor, row.organizationId, row.requesterId, forUpdate) + : null + const unavailableReason = + row.status !== 'pending' + ? 'This request has already been resolved.' + : !enabled + ? 'Access requests are turned off for this organization.' + : !membership || membership.membershipId !== row.membershipId + ? 'The requester’s membership changed. Ask them to send a new request.' + : policy.state === 'unavailable' + ? policy.reason + : target.kind !== 'usage_limit' && policy.group?.permissionGroupId !== row.groupId + ? 'The governing permission group changed. Ask the requester to send a new request.' + : target.kind === 'usage_limit' && !limit + ? 'The requester no longer has a member credit cap.' + : null + const fingerprint = createHash('sha256') + .update( + JSON.stringify({ + requestId: row.id, + status: row.status, + target, + membership: membership?.membershipId, + role: membership?.role, + groupId: policy.group?.permissionGroupId, + config: policy.group?.config, + changes: policy.delta?.changes, + audience: audience.revision, + limit, + enabled, + unavailableReason, + }) + ) + .digest('hex') + const common = { + newLimitCredits: null, + request, + changes: policy.delta?.changes ?? [], + impact: audience.impact, + fingerprint, + canApply: !unavailableReason, + unavailableReason, + } + const preview: AccessRequestPreview = + target.kind === 'usage_limit' + ? { + ...common, + resolutionKind: 'usage_limit', + group: null, + currentLimitCredits: limit?.credits ?? null, + } + : { + ...common, + resolutionKind: 'permission', + group: policy.group + ? { id: policy.group.permissionGroupId, name: policy.group.groupName } + : null, + currentLimitCredits: null, + } + return { preview, policy } +} + +export const previewAccessRequest = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.preview, + scope: organizationScope, + prepare: async ({ input }) => { + const row = await loadStoredAccessRequest(db, input.organizationId, input.requestId) + if (row.status === 'fulfilled' && row.decision) return null + return prepareAccessRequestPolicy( + row, + row.requesterId, + storedAccessRequestTargetSchema.parse(row.target).kind + ) + }, + async execute({ input, executor, prepared }) { + const row = await loadStoredAccessRequest(executor, input.organizationId, input.requestId) + return (await loadReviewPreview(executor, row, prepared)).preview + }, +}) + +export const resolveAccessRequest = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.resolve, + scope: (input: ResolveInput) => organizationScope(input), + mutation: true, + prepare: async ({ input }) => { + if (input.decision.action === 'decline') return null + const row = await loadStoredAccessRequest(db, input.organizationId, input.requestId) + if (row.status !== 'pending') return null + return prepareAccessRequestPolicy( + row, + row.requesterId, + storedAccessRequestTargetSchema.parse(row.target).kind + ) + }, + async execute({ principal, input, executor, prepared }) { + const row = await loadStoredAccessRequest(executor, input.organizationId, input.requestId, true) + if (row.status !== 'pending') + return { request: await presentAccessRequest(executor, row), changed: false } + const now = new Date() + if (input.decision.action === 'decline') { + const [updated] = await executor + .update(permissionAccessRequest) + .set({ + status: 'declined', + decisionReason: input.decision.reason, + decidedBy: principal.userId, + decidedAt: now, + updatedAt: now, + }) + .where(eq(permissionAccessRequest.id, row.id)) + .returning() + await enqueueOutboxEvent(executor, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, { + requestId: row.id, + }) + return { request: await presentAccessRequest(executor, updated), changed: true } + } + if (!prepared) + throw new OrchestrationError('internal', 'Request preview preparation is missing') + const { preview, policy } = await loadReviewPreview(executor, row, prepared, true) + if (!preview.canApply) + throw new OrchestrationError( + 'conflict', + preview.unavailableReason ?? 'This request can no longer be fulfilled' + ) + if (preview.fingerprint !== input.decision.expectedFingerprint) + throw new OrchestrationError( + 'conflict', + 'The policy or affected scope changed. Review the updated preview before applying.' + ) + if (!policy) throw new OrchestrationError('conflict', 'This request has already been fulfilled') + const newLimitCredits = input.decision.newLimitCredits + if (preview.resolutionKind === 'usage_limit') { + if ( + newLimitCredits === undefined || + !Number.isSafeInteger(newLimitCredits) || + newLimitCredits <= (preview.currentLimitCredits ?? 0) + ) + throw new OrchestrationError( + 'validation', + 'Enter a whole-number credit limit greater than the current limit' + ) + await setOrgMemberUsageLimit( + row.organizationId, + row.requesterId, + creditsToDollars(newLimitCredits), + principal.userId, + executor + ) + } else { + if (newLimitCredits !== undefined) + throw new OrchestrationError( + 'validation', + 'A credit limit cannot be applied to a permission request' + ) + if (!policy.group || !policy.delta) + throw new OrchestrationError( + 'conflict', + 'The governing permission group is no longer available' + ) + if (policy.delta.changes.length) + await executor + .update(permissionGroup) + .set({ config: policy.delta.config, updatedAt: now }) + .where( + and( + eq(permissionGroup.id, policy.group.permissionGroupId), + eq(permissionGroup.organizationId, row.organizationId) + ) + ) + } + const decision = storedAccessRequestDecisionSchema.parse({ + resolutionKind: preview.resolutionKind, + changes: preview.changes, + impact: preview.impact, + group: preview.group, + currentLimitCredits: preview.currentLimitCredits, + newLimitCredits: newLimitCredits ?? null, + fingerprint: preview.fingerprint, + }) + const [updated] = await executor + .update(permissionAccessRequest) + .set({ + status: 'fulfilled', + decidedBy: principal.userId, + decidedAt: now, + updatedAt: now, + decision, + }) + .where(eq(permissionAccessRequest.id, row.id)) + .returning() + await enqueueOutboxEvent(executor, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, { + requestId: row.id, + }) + return { request: await presentAccessRequest(executor, updated), changed: true, decision } + }, + projectAudit: ({ result }) => { + if (!result.changed) return [] + const decision = 'decision' in result ? result.decision : undefined + const entries: WorkspaceUseCaseAuditEntry[] = [ + { + action: + result.request.status === 'fulfilled' + ? AuditAction.PERMISSION_ACCESS_REQUEST_FULFILLED + : AuditAction.PERMISSION_ACCESS_REQUEST_DECLINED, + resourceType: AuditResourceType.PERMISSION_ACCESS_REQUEST, + resourceId: result.request.id, + workspaceId: result.request.workspaceId, + metadata: { + target: result.request.target, + requesterId: result.request.requester.id, + ...(decision ? { decision } : {}), + }, + }, + ] + if (decision?.resolutionKind === 'permission' && decision.group && decision.changes.length) { + entries.push({ + action: AuditAction.PERMISSION_GROUP_UPDATED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: decision.group.id, + resourceName: decision.group.name, + metadata: { requestId: result.request.id, changes: decision.changes }, + }) + } + if (decision?.resolutionKind === 'usage_limit') { + entries.push({ + action: AuditAction.ORG_MEMBER_USAGE_LIMIT_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: result.request.organizationId, + metadata: { + requestId: result.request.id, + userId: result.request.requester.id, + creditLimit: decision.newLimitCredits, + previousCreditLimit: decision.currentLimitCredits, + }, + }) + } + return entries + }, +}) diff --git a/apps/sim/lib/permission-access-requests/catalog-registry.ts b/apps/sim/lib/permission-access-requests/catalog-registry.ts new file mode 100644 index 00000000000..c3678f3ae4a --- /dev/null +++ b/apps/sim/lib/permission-access-requests/catalog-registry.ts @@ -0,0 +1,158 @@ +import { getBlockVisibility } from '@/lib/core/config/block-visibility' +import { env } from '@/lib/core/config/env' +import { + getAllowedIntegrationsFromEnv, + getBlacklistedProvidersFromEnv, + isHosted, +} from '@/lib/core/config/env-flags' +import { isOllamaUrlConfigured } from '@/lib/core/utils/urls' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' +import { + isIntegrationDeploymentAvailableForVisibility, + isOAuthServiceDeploymentAvailable, +} from '@/lib/integrations/availability.server' +import { + type AccessRequestCatalog, + type AccessRequestCatalogItem, + type AccessRequestModelItem, + type AccessRequestTarget, + type AccessRequestToolItem, + createAccessRequestCatalog, +} from '@/lib/permission-groups/access-requests/targets' +import { + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/integration-allowlist' +import { getBlockRegistry } from '@/blocks/registry' +import { isHiddenUnder } from '@/blocks/visibility/context' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { getStaticProviderModels, PROVIDER_DEFINITIONS } from '@/providers/models' +import { filterBlacklistedModels } from '@/providers/utils' +import { getToolMetadata } from '@/tools/metadata' + +export interface AccessRequestCatalogContext { + userId: string + organizationId: string + workspaceId: string | null +} + +function isProviderDeploymentAvailable(providerId: string): boolean { + if (providerId === 'ollama') return !isHosted || isOllamaUrlConfigured() + if (providerId === 'vllm') return Boolean(env.VLLM_BASE_URL?.trim()) + if (providerId === 'litellm') return Boolean(env.LITELLM_BASE_URL?.trim()) + return true +} + +/** + * Public, built-in choices available to an already-authorized viewer. No custom block, credential, + * sandbox, or tenant model names are read. The permission group is intentionally not applied here: + * callers compare it with this deployment ceiling to distinguish requestable restrictions. + */ +export async function loadAccessRequestRegistryCatalog( + context: AccessRequestCatalogContext, + targetKind?: AccessRequestTarget['kind'] +): Promise { + const needsBlocks = !targetKind || targetKind === 'integration' || targetKind === 'tool' + const needsTools = !targetKind || targetKind === 'tool' + const [visibility, credentialGroupsAvailable] = needsBlocks + ? await Promise.all([ + getBlockVisibility({ + userId: context.userId, + orgId: context.organizationId, + workspaceId: context.workspaceId, + }), + isScopedCredentialGroupsAvailable({ + kind: 'organization', + organizationId: context.organizationId, + }), + ]) + : [null, false] + const allowedIntegrations = toAccessControlAllowlist(getAllowedIntegrationsFromEnv()) + const integrations: AccessRequestCatalogItem[] = [] + const tools = new Map() + const ambiguousTools = new Set() + + if (needsBlocks) { + for (const item of [ + { id: 'loop', label: 'Loop' }, + { id: 'parallel', label: 'Parallel' }, + ]) { + if (allowedIntegrations === null || allowedIntegrations.has(item.id)) integrations.push(item) + } + } + + for (const block of needsBlocks ? Object.values(getBlockRegistry()) : []) { + if ( + block.type.startsWith('custom_block_') || + block.type === 'start_trigger' || + block.hideFromToolbar || + (visibility && isHiddenUnder(visibility, block)) || + resolveAccessControlBlockType(block.type) !== block.type || + (block.type === 'credential_group' && !credentialGroupsAvailable) || + (allowedIntegrations !== null && !allowedIntegrations.has(block.type)) || + (visibility && !isIntegrationDeploymentAvailableForVisibility(block.type, visibility)) + ) { + continue + } + integrations.push({ id: block.type, label: block.name }) + for (const toolId of needsTools ? (block.tools?.access ?? []) : []) { + if (ambiguousTools.has(toolId)) continue + const existing = tools.get(toolId) + if (existing && existing.integrationId !== block.type) { + tools.delete(toolId) + ambiguousTools.add(toolId) + continue + } + const metadata = getToolMetadata(toolId) + if (!metadata) continue + tools.set(toolId, { + id: toolId, + label: `${block.name}: ${metadata.name || toolId}`, + integrationId: block.type, + }) + } + } + + const blacklistedProviders = new Set(getBlacklistedProvidersFromEnv()) + const providers: AccessRequestCatalogItem[] = [] + const models: AccessRequestModelItem[] = [] + for (const provider of !targetKind || targetKind === 'provider' || targetKind === 'model' + ? Object.values(PROVIDER_DEFINITIONS) + : []) { + if ( + blacklistedProviders.has(provider.id.toLowerCase()) || + !isProviderDeploymentAvailable(provider.id) + ) { + continue + } + providers.push({ id: provider.id, label: provider.name }) + if (targetKind === 'provider') continue + const availableModelIds = filterBlacklistedModels( + getStaticProviderModels(provider.id) + .filter((model) => model.sunset?.status !== 'deprecated') + .map((model) => model.id) + ) + for (const id of availableModelIds) models.push({ id, label: id, providerId: provider.id }) + } + + const knowledgeConnectors = ( + !targetKind || targetKind === 'knowledge_connector' + ? Object.values(CONNECTOR_META_REGISTRY) + : [] + ) + .filter( + (connector) => + connector.auth.mode !== 'oauth' || + Boolean(connector.auth.apiKey) || + isOAuthServiceDeploymentAvailable(connector.auth.provider) + ) + .map((connector) => ({ id: connector.id, label: connector.name })) + + return createAccessRequestCatalog({ + integrations, + providers, + models, + tools: [...tools.values()], + knowledgeConnectors, + }) +} diff --git a/apps/sim/lib/permission-access-requests/catalog.test.ts b/apps/sim/lib/permission-access-requests/catalog.test.ts new file mode 100644 index 00000000000..ad85beb35d3 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/catalog.test.ts @@ -0,0 +1,280 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + blocks: vi.fn(), + visibility: vi.fn(), + credentialGroups: vi.fn(), + allowedIntegrations: vi.fn(), + blacklistedProviders: vi.fn(), + integrationAvailable: vi.fn(), + oauthAvailable: vi.fn(), + filterModels: vi.fn(), + toolMetadata: vi.fn(), +})) + +vi.mock('@/blocks/registry', () => ({ + getBlockRegistry: mocks.blocks, + getBlock: (id: string) => mocks.blocks()[id], +})) +vi.mock('@/lib/core/config/block-visibility', () => ({ getBlockVisibility: mocks.visibility })) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.credentialGroups, +})) +vi.mock('@/lib/core/config/env', () => ({ env: { VLLM_BASE_URL: '', LITELLM_BASE_URL: '' } })) +vi.mock('@/lib/core/config/env-flags', () => ({ + getAllowedIntegrationsFromEnv: mocks.allowedIntegrations, + getBlacklistedProvidersFromEnv: mocks.blacklistedProviders, + isHosted: true, + isChatEnabled: false, + isInboxEnabled: true, + isInvitationsDisabled: true, + isPublicApiDisabled: true, + isSandboxesEnabled: false, + isSsoEnabled: false, +})) +vi.mock('@/lib/core/utils/urls', () => ({ isOllamaUrlConfigured: () => false })) +vi.mock('@/lib/integrations/availability.server', () => ({ + isIntegrationDeploymentAvailableForVisibility: mocks.integrationAvailable, + isOAuthServiceDeploymentAvailable: mocks.oauthAvailable, +})) +vi.mock('@/providers/utils', () => ({ filterBlacklistedModels: mocks.filterModels })) +vi.mock('@/tools/metadata', () => ({ getToolMetadata: mocks.toolMetadata })) +vi.mock('@/providers/models', () => { + const publicModels = { + openai: [ + { id: 'public-model' }, + { id: 'blocked-model' }, + { id: 'retired-model', sunset: { status: 'deprecated' } }, + ], + fireworks: [{ id: 'fireworks/public-model' }], + } + return { + getStaticProviderModels: (providerId: string) => + publicModels[providerId as keyof typeof publicModels] ?? [], + PROVIDER_DEFINITIONS: { + openai: { id: 'openai', name: 'OpenAI', models: publicModels.openai }, + anthropic: { id: 'anthropic', name: 'Anthropic', models: [{ id: 'anthropic-model' }] }, + ollama: { id: 'ollama', name: 'Ollama', models: [{ id: 'private-local' }] }, + vllm: { id: 'vllm', name: 'vLLM', models: [] }, + litellm: { id: 'litellm', name: 'LiteLLM', models: [] }, + openrouter: { + id: 'openrouter', + name: 'OpenRouter', + models: [{ id: 'private-tenant-model' }], + }, + fireworks: { + id: 'fireworks', + name: 'Fireworks', + models: [...publicModels.fireworks, { id: 'fireworks/private-model' }], + }, + }, + } +}) +vi.mock('@/connectors/registry', () => ({ + CONNECTOR_META_REGISTRY: { + available: { + id: 'available', + name: 'Available connector', + auth: { mode: 'oauth', provider: 'available' }, + }, + unavailable: { + id: 'unavailable', + name: 'Unavailable connector', + auth: { mode: 'oauth', provider: 'unavailable' }, + }, + token: { id: 'token', name: 'Token connector', auth: { mode: 'apiKey' } }, + fallback: { + id: 'fallback', + name: 'Token fallback', + auth: { mode: 'oauth', provider: 'unavailable', apiKey: { label: 'Token' } }, + }, + }, +})) + +import { + getAccessRequestDeploymentUnavailableReason, + listAccessRequestTargets, + loadAccessRequestCatalog, +} from '@/lib/permission-access-requests/catalog' +import { + buildAccessRequestPolicyDelta, + validateAccessRequestTarget, +} from '@/lib/permission-groups/access-requests/targets' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const context = { userId: 'viewer', organizationId: 'org', workspaceId: 'ws' } + +describe('access request catalog deployment ceilings', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.visibility.mockResolvedValue({ + revealed: new Set(['revealed']), + disabled: new Set(['killed']), + previewTagged: new Set(), + }) + mocks.credentialGroups.mockResolvedValue(false) + mocks.allowedIntegrations.mockReturnValue(null) + mocks.blacklistedProviders.mockReturnValue(['anthropic']) + mocks.integrationAvailable.mockImplementation((id: string) => id !== 'misconfigured') + mocks.oauthAvailable.mockImplementation((id: string) => id === 'available') + mocks.filterModels.mockImplementation((models: string[]) => + models.filter((id) => id !== 'blocked-model') + ) + mocks.toolMetadata.mockImplementation((id: string) => + id === 'missing' ? undefined : { id, name: id } + ) + mocks.blocks.mockReturnValue({ + slack_v2: { + type: 'slack_v2', + name: 'Slack', + tools: { access: ['slack_send_message_v2', 'missing'] }, + }, + github_v2: { type: 'github_v2', name: 'GitHub', tools: { access: ['github_create_issue'] } }, + github: { + type: 'github', + name: 'Retired GitHub', + tools: { access: ['retired_github_tool'] }, + }, + hidden: { type: 'hidden', name: 'Hidden', hideFromToolbar: true }, + unrevealed: { type: 'unrevealed', name: 'Unrevealed', preview: true }, + revealed: { type: 'revealed', name: 'Revealed', preview: true }, + killed: { type: 'killed', name: 'Killed' }, + misconfigured: { type: 'misconfigured', name: 'Misconfigured' }, + credential_group: { type: 'credential_group', name: 'Credential Group' }, + custom_block_private: { type: 'custom_block_private', name: 'Secret Customer Name' }, + start_trigger: { type: 'start_trigger', name: 'Start' }, + }) + }) + + it('exposes only public visible and deployment-available block/tool metadata', async () => { + const catalog = await loadAccessRequestCatalog(context) + expect([...catalog.integrations.keys()]).toEqual([ + 'loop', + 'parallel', + 'slack_v2', + 'github_v2', + 'revealed', + ]) + expect([...catalog.tools.keys()]).toEqual(['slack_send_message_v2', 'github_create_issue']) + expect(mocks.visibility).toHaveBeenCalledWith({ + userId: 'viewer', + orgId: 'org', + workspaceId: 'ws', + }) + expect(JSON.stringify(listAccessRequestTargets(catalog))).not.toContain('private') + }) + + it.each(['feature', 'usage_limit', 'file_share_auth', 'chat_deploy_auth'] as const)( + 'keeps %s discovery independent of registry and visibility work', + async (kind) => { + const catalog = await loadAccessRequestCatalog(context, kind) + expect(catalog.integrations.size).toBe(0) + expect(mocks.visibility).not.toHaveBeenCalled() + expect(mocks.credentialGroups).not.toHaveBeenCalled() + expect(mocks.blocks).not.toHaveBeenCalled() + expect(mocks.toolMetadata).not.toHaveBeenCalled() + } + ) + + it('loads only the relevant catalog family', async () => { + const integrations = await loadAccessRequestCatalog(context, 'integration') + expect(integrations.integrations.size).toBeGreaterThan(0) + expect(integrations.providers.size).toBe(0) + expect(integrations.tools.size).toBe(0) + expect(mocks.toolMetadata).not.toHaveBeenCalled() + mocks.visibility.mockClear() + const models = await loadAccessRequestCatalog(context, 'model') + expect(models.models.size).toBeGreaterThan(0) + expect(models.integrations.size).toBe(0) + expect(mocks.visibility).not.toHaveBeenCalled() + }) + + it('canonicalizes the deployment integration allowlist before matching', async () => { + mocks.allowedIntegrations.mockReturnValue(['SLACK']) + const catalog = await loadAccessRequestCatalog(context) + expect([...catalog.integrations.keys()]).toEqual(['slack_v2']) + expect([...catalog.tools.keys()]).toEqual(['slack_send_message_v2']) + mocks.allowedIntegrations.mockReturnValue([]) + expect((await loadAccessRequestCatalog(context)).integrations.size).toBe(0) + }) + + it('enforces deployment ceilings without removing unrelated stored grants from an approval', async () => { + mocks.allowedIntegrations.mockReturnValue(['slack']) + const catalog = await loadAccessRequestCatalog(context, 'integration') + expect( + validateAccessRequestTarget({ kind: 'integration', id: 'github_v2' }, catalog) + ).toBeNull() + const config = { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: ['github_v2'] } + const delta = buildAccessRequestPolicyDelta( + { kind: 'integration', id: 'slack_v2' }, + config, + catalog + ) + expect(delta.config.allowedIntegrations).toEqual(['github_v2', 'slack_v2']) + expect(config.allowedIntegrations).toEqual(['github_v2']) + }) + + it('omits blacklisted/retired models, unconfigured endpoints, and private dynamic names', async () => { + const catalog = await loadAccessRequestCatalog(context) + expect([...catalog.providers.keys()]).toEqual(['openai', 'openrouter', 'fireworks']) + expect([...catalog.models.keys()]).toEqual(['public-model', 'fireworks/public-model']) + expect([...catalog.knowledgeConnectors.keys()]).toEqual(['available', 'token', 'fallback']) + }) + + it('keeps public models of dynamic providers requestable without exposing private names', async () => { + const catalog = await loadAccessRequestCatalog(context, 'model') + + expect(catalog.models.get('fireworks/public-model')).toEqual({ + id: 'fireworks/public-model', + label: 'fireworks/public-model', + providerId: 'fireworks', + }) + expect(catalog.models.has('fireworks/private-model')).toBe(false) + expect(catalog.models.has('private-tenant-model')).toBe(false) + }) + + it('refuses ambiguous tool parent policies instead of choosing one silently', async () => { + mocks.blocks.mockReturnValue({ + first: { type: 'first', name: 'First', tools: { access: ['shared_tool'] } }, + second: { type: 'second', name: 'Second', tools: { access: ['shared_tool'] } }, + }) + expect((await loadAccessRequestCatalog(context)).tools.has('shared_tool')).toBe(false) + }) + + it('reports deployment flags as unavailable, independently of group config', () => { + expect( + getAccessRequestDeploymentUnavailableReason({ kind: 'feature', configKey: 'hideCopilot' }) + ).not.toBeNull() + expect( + getAccessRequestDeploymentUnavailableReason({ + kind: 'feature', + configKey: 'disableInvitations', + }) + ).not.toBeNull() + expect( + getAccessRequestDeploymentUnavailableReason({ kind: 'file_share_auth', id: 'sso' }) + ).not.toBeNull() + expect( + getAccessRequestDeploymentUnavailableReason({ kind: 'feature', configKey: 'hideTablesTab' }) + ).toBeNull() + }) + + it('retains genuinely governed core blocks while omitting canonical exemptions', async () => { + mocks.blocks.mockReturnValue({ + agent: { type: 'agent', name: 'Agent' }, + condition: { type: 'condition', name: 'Condition' }, + thinking: { type: 'thinking', name: 'Retired thinking', hideFromToolbar: true }, + start_trigger: { type: 'start_trigger', name: 'Start' }, + }) + const catalog = await loadAccessRequestCatalog(context) + expect(isBlockTypeAccessControlExempt('agent')).toBe(false) + expect(isBlockTypeAccessControlExempt('condition')).toBe(false) + expect(isBlockTypeAccessControlExempt('thinking')).toBe(true) + expect(isBlockTypeAccessControlExempt('start_trigger')).toBe(true) + expect([...catalog.integrations.keys()]).toEqual(['loop', 'parallel', 'agent', 'condition']) + }) +}) diff --git a/apps/sim/lib/permission-access-requests/catalog.ts b/apps/sim/lib/permission-access-requests/catalog.ts new file mode 100644 index 00000000000..983be1eda05 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/catalog.ts @@ -0,0 +1,82 @@ +import { + isChatEnabled, + isInboxEnabled, + isInvitationsDisabled, + isPublicApiDisabled, + isSandboxesEnabled, + isSsoEnabled, +} from '@/lib/core/config/env-flags' +import type { AccessRequestCatalogContext } from '@/lib/permission-access-requests/catalog-registry' +import { + type AccessRequestCatalog, + type AccessRequestTarget, + createAccessRequestCatalog, +} from '@/lib/permission-groups/access-requests/targets' +import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' +import { FILE_SHARE_AUTH_TYPES } from '@/lib/permission-groups/fields' + +/** Small navigation and credit-limit checks never load or enumerate the integration registries. */ +export async function loadAccessRequestCatalog( + context: AccessRequestCatalogContext, + targetKind?: AccessRequestTarget['kind'] +): Promise { + if ( + targetKind && + ['feature', 'usage_limit', 'file_share_auth', 'chat_deploy_auth'].includes(targetKind) + ) + return createAccessRequestCatalog({ + integrations: [], + providers: [], + models: [], + tools: [], + knowledgeConnectors: [], + }) + const { loadAccessRequestRegistryCatalog } = await import( + '@/lib/permission-access-requests/catalog-registry' + ) + return loadAccessRequestRegistryCatalog(context, targetKind) +} + +/** Enumerates public targets; scope, role, current policy, usage, and pending state remain caller-owned. */ +export function listAccessRequestTargets(catalog: AccessRequestCatalog): AccessRequestTarget[] { + return [ + ...PLATFORM_FEATURES.map( + (feature): AccessRequestTarget => ({ kind: 'feature', configKey: feature.configKey }) + ), + ...[...catalog.integrations.keys()].map( + (id): AccessRequestTarget => ({ kind: 'integration', id }) + ), + ...[...catalog.providers.keys()].map((id): AccessRequestTarget => ({ kind: 'provider', id })), + ...[...catalog.models.values()].map(({ id }): AccessRequestTarget => ({ kind: 'model', id })), + ...[...catalog.tools.keys()].map((id): AccessRequestTarget => ({ kind: 'tool', id })), + ...[...catalog.knowledgeConnectors.keys()].map( + (id): AccessRequestTarget => ({ kind: 'knowledge_connector', id }) + ), + ...FILE_SHARE_AUTH_TYPES.map((id): AccessRequestTarget => ({ kind: 'file_share_auth', id })), + ...FILE_SHARE_AUTH_TYPES.map((id): AccessRequestTarget => ({ kind: 'chat_deploy_auth', id })), + { kind: 'usage_limit', id: 'member' }, + ] +} + +/** Group edits cannot enable a deployment-disabled feature or sharing authentication mode. */ +export function getAccessRequestDeploymentUnavailableReason( + target: AccessRequestTarget +): string | null { + if ( + (target.kind === 'file_share_auth' || target.kind === 'chat_deploy_auth') && + target.id === 'sso' && + !isSsoEnabled + ) { + return 'SSO is not available on this deployment.' + } + if (target.kind !== 'feature') return null + const disabled = + (target.configKey === 'hideCopilot' && !isChatEnabled) || + (target.configKey === 'hideInboxTab' && !isInboxEnabled) || + (target.configKey === 'hideSandboxesTab' && !isSandboxesEnabled) || + (target.configKey === 'disableInvitations' && isInvitationsDisabled) || + (target.configKey === 'disablePublicApi' && isPublicApiDisabled) + return disabled + ? 'This feature is disabled for this deployment. An organization permission change cannot enable it.' + : null +} diff --git a/apps/sim/lib/permission-access-requests/constants.ts b/apps/sim/lib/permission-access-requests/constants.ts new file mode 100644 index 00000000000..7bdb702c2f9 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/constants.ts @@ -0,0 +1,7 @@ +export const ACCESS_REQUEST_LIST_PAGE_SIZE = 25 +export const ACCESS_REQUEST_MAX_OFFSET = 1_000_000 +export const ACCESS_REQUEST_MAX_SEARCH_LENGTH = 200 +export const ACCESS_REQUEST_MAX_ID_LENGTH = 128 +export const ACCESS_REQUEST_MAX_DAILY_SUBMISSIONS = 100 +export const ACCESS_REQUEST_MAX_PENDING = 100 +export const ACCESS_REQUEST_SUBMISSION_WINDOW_MS = 24 * 60 * 60 * 1000 diff --git a/apps/sim/lib/permission-access-requests/impact.postgres.test.ts b/apps/sim/lib/permission-access-requests/impact.postgres.test.ts new file mode 100644 index 00000000000..3f9eb90359a --- /dev/null +++ b/apps/sim/lib/permission-access-requests/impact.postgres.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { generateId } from '@sim/utils/id' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { describe, expect, it, vi } from 'vitest' +import { loadAccessRequestGroupImpact } from '@/lib/permission-access-requests/impact' + +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') + +const databaseUrl = process.env.ACCESS_REQUESTS_TEST_DATABASE_URL + +async function createFixture() { + const url = new URL(databaseUrl ?? '') + if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + url.pathname !== '/sim_access_requests_test' + ) + throw new Error('Use a disposable local sim_access_requests_test database') + const schema = `access_impact_${generateId().replaceAll('-', '')}` + const client = postgres(url.toString(), { max: 1, onnotice: () => undefined }) + await client.unsafe(`CREATE SCHEMA "${schema}"`) + await client.unsafe(`SET search_path TO "${schema}"`) + await client.unsafe(` + CREATE TABLE workspace (id text PRIMARY KEY, name text, organization_id text, archived_at timestamp); + CREATE TABLE permission_group (id text PRIMARY KEY, organization_id text, is_default boolean, updated_at timestamp, membership_mode text, created_at timestamp DEFAULT now()); + CREATE TABLE permissions (id text PRIMARY KEY, user_id text, entity_id text, entity_type text, permission_type text, updated_at timestamp); + CREATE TABLE member (id text PRIMARY KEY, user_id text, organization_id text, role text); + CREATE TABLE permission_group_member (id text PRIMARY KEY, organization_id text, permission_group_id text, user_id text); + CREATE TABLE permission_group_workspace (id text PRIMARY KEY, organization_id text, permission_group_id text, workspace_id text); + INSERT INTO permission_group VALUES ('group', 'org', false, now(), 'explicit'); + INSERT INTO workspace VALUES ('one', 'One', 'org', null), ('two', 'Two', 'org', null), ('foreign', 'Foreign', 'another-org', null); + INSERT INTO permission_group_workspace VALUES ('scope', 'org', 'group', 'one'); + INSERT INTO member VALUES ('m1', 'admin', 'org', 'admin'), ('m2', 'member', 'org', 'member'), ('m3', 'outside', 'another-org', 'admin'); + INSERT INTO permissions VALUES ('p1', 'admin', 'one', 'workspace', 'read', now()), ('p2', 'guest', 'one', 'workspace', 'read', now()), ('p3', 'guest', 'two', 'workspace', 'read', now()), ('p4', 'outside', 'foreign', 'workspace', 'read', now()); + INSERT INTO permission_group_member VALUES ('assignment', 'org', 'group', 'member'); + `) + return { + client, + executor: drizzle(client), + async cleanup() { + try { + await client.unsafe(`DROP SCHEMA "${schema}" CASCADE`) + } finally { + await client.end() + } + }, + } +} + +describe.skipIf(!databaseUrl)('access request impact on PostgreSQL', () => { + it('counts scoped people once without requiring or scanning the global user table', async () => { + const fixture = await createFixture() + try { + const scoped = await loadAccessRequestGroupImpact(fixture.executor, 'org', 'group') + expect(scoped.impact).toEqual({ + memberCount: 2, + workspaceCount: 1, + workspaceNames: ['One'], + truncated: false, + }) + await fixture.client`UPDATE permission_group SET is_default = true WHERE id = 'group'` + const defaultGroup = await loadAccessRequestGroupImpact(fixture.executor, 'org', 'group') + expect(defaultGroup.impact).toEqual({ + memberCount: 3, + workspaceCount: 2, + workspaceNames: ['One', 'Two'], + truncated: false, + }) + } finally { + await fixture.cleanup() + } + }) + + it('ignores disjoint group, assignment, scope, and ordinary member changes', async () => { + const fixture = await createFixture() + try { + const load = () => loadAccessRequestGroupImpact(fixture.executor, 'org', 'group') + const before = await load() + await fixture.client`INSERT INTO permission_group VALUES ('unrelated', 'org', false, now(), 'explicit')` + await fixture.client`INSERT INTO permission_group_workspace VALUES ('unrelated-scope', 'org', 'unrelated', 'two')` + await fixture.client`INSERT INTO permission_group_member VALUES ('unrelated-assignment', 'org', 'unrelated', 'unrelated-user')` + expect(await load()).toEqual(before) + await fixture.client`UPDATE permission_group SET updated_at = updated_at + interval '1 second' WHERE id = 'unrelated'` + expect(await load()).toEqual(before) + await fixture.client`UPDATE permission_group_member SET user_id = 'different-user' WHERE id = 'unrelated-assignment'` + expect(await load()).toEqual(before) + await fixture.client`DELETE FROM permission_group_workspace WHERE id = 'unrelated-scope'` + expect(await load()).toEqual(before) + await fixture.client`INSERT INTO member VALUES ('unrelated-membership', 'unrelated-member', 'org', 'member')` + expect(await load()).toEqual(before) + } finally { + await fixture.cleanup() + } + }) + + it('tracks competing group precedence but ignores their unrelated policy updates and scopes', async () => { + const fixture = await createFixture() + try { + const load = () => loadAccessRequestGroupImpact(fixture.executor, 'org', 'group') + const before = await load() + await fixture.client`INSERT INTO permission_group VALUES ('competitor', 'org', false, now(), 'inherit')` + await fixture.client`INSERT INTO permission_group_workspace VALUES ('competing-scope', 'org', 'competitor', 'one')` + const competing = await load() + expect(competing.revision).not.toBe(before.revision) + await fixture.client`UPDATE permission_group SET updated_at = updated_at + interval '1 second' WHERE id = 'competitor'` + await fixture.client`INSERT INTO permission_group_workspace VALUES ('other-scope', 'org', 'competitor', 'two')` + expect(await load()).toEqual(competing) + await fixture.client`INSERT INTO permission_group_member VALUES ('competing-member', 'org', 'competitor', 'unrelated-user')` + const assigned = await load() + expect(assigned.revision).not.toBe(competing.revision) + await fixture.client`UPDATE permission_group SET membership_mode = 'explicit' WHERE id = 'competitor'` + const explicit = await load() + expect(explicit.revision).not.toBe(assigned.revision) + await fixture.client`UPDATE permission_group SET created_at = created_at - interval '1 day' WHERE id = 'competitor'` + const reordered = await load() + expect(reordered.revision).not.toBe(explicit.revision) + await fixture.client`DELETE FROM permission_group_workspace WHERE id = 'competing-scope'` + expect((await load()).revision).not.toBe(reordered.revision) + } finally { + await fixture.cleanup() + } + }) + + it('tracks relevant membership and administrator roles, and keeps default-group scope broad', async () => { + const fixture = await createFixture() + try { + const load = () => loadAccessRequestGroupImpact(fixture.executor, 'org', 'group') + const before = await load() + await fixture.client`INSERT INTO member VALUES ('guest-member', 'guest', 'org', 'member')` + const joined = await load() + expect(joined.impact).toEqual(before.impact) + expect(joined.revision).not.toBe(before.revision) + await fixture.client`UPDATE member SET role = 'owner' WHERE id = 'm2'` + const promoted = await load() + expect(promoted.impact.memberCount).toBe(3) + expect(promoted.revision).not.toBe(joined.revision) + await fixture.client`UPDATE permission_group SET is_default = true WHERE id = 'group'` + const defaultGroup = await load() + await fixture.client`INSERT INTO member VALUES ('new-member', 'new-person', 'org', 'member')` + const added = await load() + expect(added.impact.memberCount).toBe(defaultGroup.impact.memberCount + 1) + expect(added.revision).not.toBe(defaultGroup.revision) + await fixture.client`UPDATE workspace SET archived_at = now() WHERE id = 'two'` + const archived = await load() + expect(archived.impact.workspaceCount).toBe(1) + expect(archived.revision).not.toBe(added.revision) + } finally { + await fixture.cleanup() + } + }) + + it('detects in-place assignment, grant, and workspace changes with a bounded revision', async () => { + const fixture = await createFixture() + try { + const load = () => loadAccessRequestGroupImpact(fixture.executor, 'org', 'group') + const initial = await load() + expect((await load()).revision).toBe(initial.revision) + await fixture.client`UPDATE permission_group_member SET user_id = 'guest' WHERE id = 'assignment'` + const reassigned = await load() + expect(reassigned.revision).not.toBe(initial.revision) + await fixture.client`UPDATE permissions SET permission_type = 'write' WHERE id = 'p2'` + const promoted = await load() + expect(promoted.revision).not.toBe(reassigned.revision) + await fixture.client`UPDATE workspace SET name = 'Renamed' WHERE id = 'one'` + const renamed = await load() + expect(renamed.revision).not.toBe(promoted.revision) + await fixture.client`INSERT INTO workspace SELECT 'ws-' || n, 'Workspace ' || n, 'org', null FROM generate_series(1, 150) n` + await fixture.client`UPDATE permission_group SET is_default = true WHERE id = 'group'` + const large = await load() + expect(large.impact.workspaceCount).toBe(152) + expect(large.impact.workspaceNames).toHaveLength(100) + expect(large.impact.truncated).toBe(true) + expect(large.revision.length).toBeLessThan(1000) + } finally { + await fixture.cleanup() + } + }) +}) diff --git a/apps/sim/lib/permission-access-requests/impact.ts b/apps/sim/lib/permission-access-requests/impact.ts new file mode 100644 index 00000000000..2a0ae391b48 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/impact.ts @@ -0,0 +1,178 @@ +import { + member, + permissionGroup, + permissionGroupMember, + permissionGroupWorkspace, + permissions, + workspace, +} from '@sim/db/schema' +import { and, count, eq, inArray, isNull, or, type SQL, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import type { AccessRequestImpact } from '@/lib/permission-access-requests/types' + +/** Order-independent change detector with fixed-size aggregate state instead of sorted row strings. */ +function membershipRevision(value: SQL): SQL { + return sql`count(*)::text || ':' || + coalesce(sum(('x' || substr(md5(${value}), 1, 16))::bit(64)::bigint::numeric), 0)::text || ':' || + coalesce(sum(('x' || substr(md5(${value}), 17, 16))::bit(64)::bigint::numeric), 0)::text` +} + +/** Counts a conservative audience on the database and returns at most 100 workspace names. */ +export async function loadAccessRequestGroupImpact( + executor: DbOrTx, + organizationId: string, + groupId: string +): Promise<{ impact: AccessRequestImpact; revision: string }> { + const [group] = await executor + .select({ isDefault: permissionGroup.isDefault, updatedAt: permissionGroup.updatedAt }) + .from(permissionGroup) + .where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId))) + .limit(1) + const scope = and( + eq(workspace.organizationId, organizationId), + isNull(workspace.archivedAt), + group?.isDefault + ? undefined + : sql`exists (select 1 from ${permissionGroupWorkspace} where ${permissionGroupWorkspace.permissionGroupId} = ${groupId} and ${permissionGroupWorkspace.workspaceId} = ${workspace.id})` + ) + const scopedWorkspaces = executor.select({ id: workspace.id }).from(workspace).where(scope) + const scopedGrantees = executor + .select({ userId: permissions.userId }) + .from(permissions) + .where( + and(eq(permissions.entityType, 'workspace'), inArray(permissions.entityId, scopedWorkspaces)) + ) + /** Competing groups in the same workspaces can change explicit/inherited group precedence. */ + const relevantGroups = executor + .select({ id: permissionGroup.id }) + .from(permissionGroup) + .where( + and( + eq(permissionGroup.organizationId, organizationId), + or( + eq(permissionGroup.id, groupId), + inArray( + permissionGroup.id, + executor + .select({ groupId: permissionGroupWorkspace.permissionGroupId }) + .from(permissionGroupWorkspace) + .where(inArray(permissionGroupWorkspace.workspaceId, scopedWorkspaces)) + ) + ) + ) + ) + const names = await executor + .select({ name: workspace.name }) + .from(workspace) + .where(scope) + .orderBy(workspace.name, workspace.id) + .limit(100) + const [workspaces] = await executor + .select({ + total: count(), + revision: membershipRevision( + sql`jsonb_build_array(${workspace.id}, ${workspace.name})::text` + ), + }) + .from(workspace) + .where(scope) + const [grants] = await executor + .select({ + revision: membershipRevision( + sql`jsonb_build_array(${permissions.id}, ${permissions.userId}, ${permissions.entityId}, ${permissions.permissionType}, ${permissions.updatedAt})::text` + ), + }) + .from(permissions) + .innerJoin( + workspace, + and(eq(workspace.id, permissions.entityId), eq(permissions.entityType, 'workspace')) + ) + .where(scope) + const [orgMembers] = await executor + .select({ + revision: membershipRevision( + sql`jsonb_build_array(${member.id}, ${member.userId}, ${member.role})::text` + ), + }) + .from(member) + .where( + and( + eq(member.organizationId, organizationId), + group?.isDefault + ? undefined + : or(inArray(member.role, ['admin', 'owner']), inArray(member.userId, scopedGrantees)) + ) + ) + const [assignments] = await executor + .select({ + revision: membershipRevision( + sql`jsonb_build_array(${permissionGroupMember.id}, ${permissionGroupMember.userId}, ${permissionGroupMember.permissionGroupId})::text` + ), + }) + .from(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.organizationId, organizationId), + inArray(permissionGroupMember.permissionGroupId, relevantGroups) + ) + ) + const [scopes] = await executor + .select({ + revision: membershipRevision( + sql`jsonb_build_array(${permissionGroupWorkspace.id}, ${permissionGroupWorkspace.workspaceId}, ${permissionGroupWorkspace.permissionGroupId})::text` + ), + }) + .from(permissionGroupWorkspace) + .where( + and( + eq(permissionGroupWorkspace.organizationId, organizationId), + inArray(permissionGroupWorkspace.workspaceId, scopedWorkspaces) + ) + ) + const candidates = executor + .select({ userId: permissions.userId }) + .from(permissions) + .innerJoin( + workspace, + and(eq(workspace.id, permissions.entityId), eq(permissions.entityType, 'workspace')) + ) + .where(scope) + .union( + executor + .select({ userId: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, organizationId), + group?.isDefault ? undefined : inArray(member.role, ['admin', 'owner']) + ) + ) + ) + .as('affected_people') + const [people] = await executor.select({ total: count() }).from(candidates) + const [groupVersions] = await executor + .select({ + revision: membershipRevision( + sql`jsonb_build_array(${permissionGroup.id}, ${permissionGroup.createdAt}, ${permissionGroup.membershipMode}, ${permissionGroup.isDefault})::text` + ), + }) + .from(permissionGroup) + .where(inArray(permissionGroup.id, relevantGroups)) + return { + impact: { + memberCount: Number(people?.total ?? 0), + workspaceCount: workspaces?.total ?? 0, + workspaceNames: names.map((row) => row.name), + truncated: (workspaces?.total ?? 0) > names.length, + }, + revision: JSON.stringify([ + group?.updatedAt, + workspaces?.revision, + grants?.revision, + orgMembers?.revision, + groupVersions?.revision, + assignments?.revision, + scopes?.revision, + ]), + } +} diff --git a/apps/sim/lib/permission-access-requests/notification-events.ts b/apps/sim/lib/permission-access-requests/notification-events.ts new file mode 100644 index 00000000000..3adf1aa1897 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/notification-events.ts @@ -0,0 +1,2 @@ +export const PERMISSION_ACCESS_REQUEST_CREATED_EVENT = 'permission-access-request.created' +export const PERMISSION_ACCESS_REQUEST_DECIDED_EVENT = 'permission-access-request.decided' diff --git a/apps/sim/lib/permission-access-requests/notifications.test.ts b/apps/sim/lib/permission-access-requests/notifications.test.ts new file mode 100644 index 00000000000..9d11dda7fac --- /dev/null +++ b/apps/sim/lib/permission-access-requests/notifications.test.ts @@ -0,0 +1,388 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { member, outboxEvent, permissionAccessRequest, user, workspace } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OutboxEventContext } from '@/lib/core/outbox/service' + +const { mockRender, mockSend, mockHasEmailService, mockMembership, mockEnabled } = vi.hoisted( + () => ({ + mockRender: vi.fn(), + mockSend: vi.fn(), + mockHasEmailService: vi.fn(), + mockMembership: vi.fn(), + mockEnabled: vi.fn(), + }) +) + +vi.mock('@/components/emails/render', () => ({ + renderPermissionAccessRequestEmail: mockRender, +})) +vi.mock('@/components/emails/subjects', () => ({ getEmailSubject: (kind: string) => kind })) +vi.mock('@/lib/messaging/email/mailer', () => ({ + sendEmail: mockSend, + hasEmailService: mockHasEmailService, +})) +vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ + loadAccessRequestMembership: mockMembership, +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.example' })) +vi.mock('@/lib/permission-access-requests/settings', () => ({ + isAccessRequestEnabled: mockEnabled, +})) + +import { + PERMISSION_ACCESS_REQUEST_CREATED_EVENT, + PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, +} from '@/lib/permission-access-requests/notification-events' +import { permissionAccessRequestOutboxHandlers } from '@/lib/permission-access-requests/notifications' + +const request = { + id: 'request-one', + requesterId: 'requester-one', + organizationId: 'organization-one', + workspaceId: 'workspace-one', + status: 'pending', + membershipId: '[null,"grant-one"]', +} +const adminEvent = 'permission-access-request.notify-admin' +const createdHandler = + permissionAccessRequestOutboxHandlers[PERMISSION_ACCESS_REQUEST_CREATED_EVENT] +const decidedHandler = + permissionAccessRequestOutboxHandlers[PERMISSION_ACCESS_REQUEST_DECIDED_EVENT] +const adminHandler = permissionAccessRequestOutboxHandlers[adminEvent] + +function context(): OutboxEventContext { + return { + eventId: 'event-one', + eventType: PERMISSION_ACCESS_REQUEST_CREATED_EVENT, + attempts: 0, + maxAttempts: 10, + signal: new AbortController().signal, + checkpointPayload: vi.fn().mockResolvedValue(undefined), + } +} + +function queueWorkspaceRequest(overrides: Partial = {}) { + queueTableRows(permissionAccessRequest, [{ ...request, ...overrides }]) + queueTableRows(workspace, [{ organizationId: request.organizationId }]) +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockRender.mockResolvedValue('Authenticated request link') + mockSend.mockResolvedValue({ success: true }) + mockHasEmailService.mockReturnValue(true) + mockMembership.mockResolvedValue({ role: 'read', membershipId: request.membershipId }) + mockEnabled.mockResolvedValue(true) +}) + +describe('access request administrator notifications', () => { + it('bounds fan-out to one page and checkpoints durable progress', async () => { + queueWorkspaceRequest() + const recipients = Array.from({ length: 50 }, (_, index) => ({ + id: `member-${index}`, + userId: `admin-${index}`, + })) + queueTableRows(member, recipients) + const eventContext = context() + + const result = await createdHandler({ requestId: request.id }, eventContext) + + expect(dbChainMockFns.limit).toHaveBeenCalledWith(50) + expect(dbChainMockFns.insert).toHaveBeenCalledExactlyOnceWith(outboxEvent) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + recipients.map((recipient) => ({ + id: `${adminEvent}:${request.id}:${recipient.userId}`, + eventType: adminEvent, + payload: { requestId: request.id, recipientUserId: recipient.userId }, + })) + ) + expect(dbChainMockFns.onConflictDoNothing).toHaveBeenCalledWith({ target: outboxEvent.id }) + expect(eventContext.checkpointPayload).toHaveBeenCalledWith({ afterMemberId: 'member-49' }) + expect(result).toMatchObject({ outcome: 'deferred', consumeAttempt: false }) + expect(mockSend).not.toHaveBeenCalled() + }) + + it('resumes after the saved member cursor and terminates after a partial page', async () => { + queueWorkspaceRequest() + queueTableRows(member, [{ id: 'member-60', userId: 'admin-60' }]) + + await expect( + createdHandler({ requestId: request.id, afterMemberId: 'member-59' }, context()) + ).resolves.toBeUndefined() + + expect(dbChainMockFns.where).toHaveBeenCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([{ type: 'gt', left: member.id, right: 'member-59' }]), + }) + ) + }) + + it('reuses child event IDs when a crash interrupts checkpointing', async () => { + const failedContext = context() + vi.mocked(failedContext.checkpointPayload).mockRejectedValueOnce(new Error('lease lost')) + queueWorkspaceRequest() + queueTableRows(member, [{ id: 'member-one', userId: 'admin-one' }]) + + await expect(createdHandler({ requestId: request.id }, failedContext)).rejects.toThrow( + 'lease lost' + ) + + queueWorkspaceRequest() + queueTableRows(member, [{ id: 'member-one', userId: 'admin-one' }]) + await createdHandler({ requestId: request.id }, context()) + + expect(dbChainMockFns.values.mock.calls[0][0]).toEqual(dbChainMockFns.values.mock.calls[1][0]) + expect(dbChainMockFns.onConflictDoNothing).toHaveBeenCalledTimes(2) + }) + + it('skips fan-out when email is not configured', async () => { + queueWorkspaceRequest() + mockHasEmailService.mockReturnValue(false) + + await createdHandler({ requestId: request.id }, context()) + + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mockSend).not.toHaveBeenCalled() + }) + + it('does not email a former administrator', async () => { + queueWorkspaceRequest() + queueTableRows(user, [{ email: 'former-admin@example.com' }]) + queueTableRows(member, []) + + await adminHandler({ requestId: request.id, recipientUserId: 'former-admin' }, context()) + + expect(mockSend).not.toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([ + { type: 'eq', left: member.userId, right: 'former-admin' }, + { type: 'eq', left: member.organizationId, right: request.organizationId }, + { type: 'inArray', column: member.role, values: ['admin', 'owner'] }, + ]), + }) + ) + }) + + it('emails an eligible administrator after a temporary ban has expired', async () => { + queueWorkspaceRequest() + queueTableRows(user, [ + { + email: 'admin@example.com', + banned: true, + banExpires: new Date('2020-01-01'), + suspendedAt: null, + }, + ]) + queueTableRows(member, [{ id: 'admin-member' }]) + + await adminHandler({ requestId: request.id, recipientUserId: 'admin-one' }, context()) + + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ to: 'admin@example.com' })) + }) + + it.each([ + { banned: true, banExpires: null, suspendedAt: null }, + { banned: true, banExpires: new Date('2099-01-01'), suspendedAt: null }, + { banned: false, banExpires: null, suspendedAt: new Date() }, + ])('does not email a recipient whose account is currently blocked', async (account) => { + queueWorkspaceRequest() + queueTableRows(user, [{ email: 'admin@example.com', ...account }]) + + await adminHandler({ requestId: request.id, recipientUserId: 'admin-one' }, context()) + + expect(mockSend).not.toHaveBeenCalled() + }) + + it('skips pending-request emails after the organization disables requests', async () => { + queueWorkspaceRequest() + mockEnabled.mockResolvedValue(false) + + await adminHandler({ requestId: request.id, recipientUserId: 'admin-one' }, context()) + + expect(mockSend).not.toHaveBeenCalled() + }) + + it('builds an authenticated review link and loads the current administrator email', async () => { + queueWorkspaceRequest() + queueTableRows(user, [{ email: 'current-admin@example.com' }]) + queueTableRows(member, [{ id: 'member-one' }]) + + await adminHandler({ requestId: request.id, recipientUserId: 'admin-one' }, context()) + + expect(mockRender).toHaveBeenCalledExactlyOnceWith({ + kind: 'created', + requestLink: + 'https://sim.example/access-requests?organizationId=organization-one&view=admin&requestId=request-one', + }) + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ to: 'current-admin@example.com', emailType: 'transactional' }) + ) + }) + + it('discards stale creation notifications after a request is resolved', async () => { + queueTableRows(permissionAccessRequest, [{ ...request, status: 'fulfilled' }]) + + await adminHandler({ requestId: request.id, recipientUserId: 'admin-one' }, context()) + + expect(mockSend).not.toHaveBeenCalled() + }) + + it('fails delivery for retry without mutating request state', async () => { + queueWorkspaceRequest() + queueTableRows(user, [{ email: 'admin@example.com' }]) + queueTableRows(member, [{ id: 'member-one' }]) + mockSend.mockResolvedValueOnce({ success: false }) + + await expect( + adminHandler({ requestId: request.id, recipientUserId: 'admin-one' }, context()) + ).rejects.toThrow('Failed to send access request notification') + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('refuses a caller-provided destination or URL in the durable payload', async () => { + await expect( + adminHandler( + { requestId: request.id, recipientUserId: 'admin-one', email: 'attacker@example.com' }, + context() + ) + ).rejects.toThrow() + expect(mockSend).not.toHaveBeenCalled() + }) +}) + +describe('access request requester notifications', () => { + it.each(['fulfilled', 'declined', 'cancelled', 'closed'])( + 'notifies the requester for %s without putting private request details in email', + async (status) => { + queueWorkspaceRequest({ status }) + queueTableRows(user, [{ email: 'requester@example.com' }]) + + await decidedHandler({ requestId: request.id }, context()) + + expect(mockRender).toHaveBeenCalledExactlyOnceWith({ + kind: 'decided', + requestLink: + 'https://sim.example/workspace/workspace-one/access-requests?requestId=request-one', + }) + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ to: 'requester@example.com', emailType: 'transactional' }) + ) + } + ) + + it('supports an external workspace member without organization membership', async () => { + queueWorkspaceRequest({ status: 'fulfilled' }) + queueTableRows(user, [{ email: 'external@example.com' }]) + + await decidedHandler({ requestId: request.id }, context()) + + expect(mockMembership).toHaveBeenCalledWith( + db, + request.requesterId, + { kind: 'workspace', workspaceId: request.workspaceId }, + request.organizationId + ) + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ to: 'external@example.com' })) + expect(mockRender).toHaveBeenCalledExactlyOnceWith({ + kind: 'decided', + requestLink: + 'https://sim.example/workspace/workspace-one/access-requests?requestId=request-one', + }) + }) + + it('encodes a canonical workspace identifier as one path segment', async () => { + queueWorkspaceRequest({ status: 'fulfilled', workspaceId: 'workspace/with?characters' }) + queueTableRows(user, [{ email: 'requester@example.com' }]) + + await decidedHandler({ requestId: request.id }, context()) + + expect(mockRender).toHaveBeenCalledExactlyOnceWith({ + kind: 'decided', + requestLink: + 'https://sim.example/workspace/workspace%2Fwith%3Fcharacters/access-requests?requestId=request-one', + }) + }) + + it('skips delivery after the requester loses workspace access', async () => { + queueWorkspaceRequest({ status: 'fulfilled' }) + mockMembership.mockResolvedValueOnce(null) + + await decidedHandler({ requestId: request.id }, context()) + + expect(mockSend).not.toHaveBeenCalled() + }) + + it('skips delivery after the workspace moves to another organization', async () => { + queueTableRows(permissionAccessRequest, [{ ...request, status: 'closed' }]) + queueTableRows(workspace, [{ organizationId: 'other-organization' }]) + + await decidedHandler({ requestId: request.id }, context()) + + expect(mockMembership).not.toHaveBeenCalled() + expect(mockSend).not.toHaveBeenCalled() + }) + + it('requires current organization membership for an organization-scoped request', async () => { + queueTableRows(permissionAccessRequest, [{ ...request, workspaceId: null, status: 'declined' }]) + mockMembership.mockResolvedValueOnce(null) + + await decidedHandler({ requestId: request.id }, context()) + + expect(mockSend).not.toHaveBeenCalled() + }) + + it('links organization-scoped decisions to the requester history', async () => { + queueTableRows(permissionAccessRequest, [ + { ...request, workspaceId: null, status: 'fulfilled' }, + ]) + queueTableRows(user, [{ email: 'requester@example.com' }]) + + await decidedHandler({ requestId: request.id }, context()) + + expect(mockRender).toHaveBeenCalledWith({ + kind: 'decided', + requestLink: + 'https://sim.example/access-requests?organizationId=organization-one&requestId=request-one', + }) + }) + + it('stops before email delivery when its outbox lease has expired', async () => { + queueWorkspaceRequest({ status: 'fulfilled' }) + queueTableRows(user, [{ email: 'requester@example.com' }]) + const eventContext = context() + eventContext.signal = AbortSignal.abort(new Error('lease expired')) + + await expect(decidedHandler({ requestId: request.id }, eventContext)).rejects.toThrow( + 'lease expired' + ) + expect(mockSend).not.toHaveBeenCalled() + }) + + it('does not revive an old notification after the requester is removed and reinvited', async () => { + queueWorkspaceRequest({ status: 'fulfilled' }) + mockMembership.mockResolvedValueOnce({ role: 'read', membershipId: '[null,"new-grant"]' }) + + await decidedHandler({ requestId: request.id }, context()) + + expect(mockSend).not.toHaveBeenCalled() + }) + + it('delivers existing decisions after the organization disables new requests', async () => { + queueWorkspaceRequest({ status: 'declined' }) + queueTableRows(user, [{ email: 'requester@example.com' }]) + mockEnabled.mockResolvedValue(false) + + await decidedHandler({ requestId: request.id }, context()) + + expect(mockSend).toHaveBeenCalledOnce() + expect(mockEnabled).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/permission-access-requests/notifications.ts b/apps/sim/lib/permission-access-requests/notifications.ts new file mode 100644 index 00000000000..5516dde5dd7 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/notifications.ts @@ -0,0 +1,210 @@ +import { db } from '@sim/db' +import { member, outboxEvent, permissionAccessRequest, user, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, asc, eq, gt, inArray, isNull } from 'drizzle-orm' +import { z } from 'zod' +import { renderPermissionAccessRequestEmail } from '@/components/emails/render' +import { getEmailSubject } from '@/components/emails/subjects' +import { isAccountBlocked } from '@/lib/auth/ban' +import { + continueOutboxHandler, + type OutboxEventContext, + type OutboxHandlerRegistry, +} from '@/lib/core/outbox/service' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { hasEmailService, sendEmail } from '@/lib/messaging/email/mailer' +import { loadAccessRequestMembership } from '@/lib/permission-access-requests/application/authorization' +import { + PERMISSION_ACCESS_REQUEST_CREATED_EVENT, + PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, +} from '@/lib/permission-access-requests/notification-events' +import { isAccessRequestEnabled } from '@/lib/permission-access-requests/settings' + +const logger = createLogger('PermissionAccessRequestNotifications') +const ADMIN_RECIPIENT_PAGE_SIZE = 50 +const ADMIN_NOTIFICATION_EVENT = 'permission-access-request.notify-admin' +const notificationPayloadSchema = z.object({ requestId: z.string().min(1).max(256) }).strict() +const createdPayloadSchema = notificationPayloadSchema.extend({ + afterMemberId: z.string().min(1).max(256).optional(), +}) +const adminPayloadSchema = notificationPayloadSchema.extend({ + recipientUserId: z.string().min(1).max(256), +}) + +async function loadRequest(requestId: string) { + const [request] = await db + .select({ + id: permissionAccessRequest.id, + organizationId: permissionAccessRequest.organizationId, + requesterId: permissionAccessRequest.requesterId, + workspaceId: permissionAccessRequest.workspaceId, + status: permissionAccessRequest.status, + membershipId: permissionAccessRequest.membershipId, + }) + .from(permissionAccessRequest) + .where(eq(permissionAccessRequest.id, requestId)) + .limit(1) + return request ?? null +} + +type NotificationRequest = NonNullable>> + +async function requesterHasCurrentAccess(request: NotificationRequest): Promise { + if (request.workspaceId) { + const [scope] = await db + .select({ organizationId: workspace.organizationId }) + .from(workspace) + .where(and(eq(workspace.id, request.workspaceId), isNull(workspace.archivedAt))) + .limit(1) + if (!scope || scope.organizationId !== request.organizationId) return false + } + const membership = await loadAccessRequestMembership( + db, + request.requesterId, + request.workspaceId + ? { kind: 'workspace', workspaceId: request.workspaceId } + : { kind: 'organization', organizationId: request.organizationId }, + request.organizationId + ) + return membership?.membershipId === request.membershipId +} + +function requestLink(request: NotificationRequest, kind: 'created' | 'decided'): string { + const requesterWorkspaceId = kind === 'decided' ? request.workspaceId : null + const url = new URL( + requesterWorkspaceId + ? `/workspace/${encodeURIComponent(requesterWorkspaceId)}/access-requests` + : '/access-requests', + getBaseUrl() + ) + if (!requesterWorkspaceId) url.searchParams.set('organizationId', request.organizationId) + if (kind === 'created') url.searchParams.set('view', 'admin') + url.searchParams.set('requestId', request.id) + return url.toString() +} + +async function deliverNotification( + request: NotificationRequest, + kind: 'created' | 'decided', + recipientUserId: string, + context: OutboxEventContext +): Promise { + const html = await renderPermissionAccessRequestEmail({ + kind, + requestLink: requestLink(request, kind), + }) + const recipientQuery = db + .select({ + email: user.email, + suspendedAt: user.suspendedAt, + banned: user.banned, + banExpires: user.banExpires, + }) + .from(user) + .where(eq(user.id, recipientUserId)) + const [recipient] = await recipientQuery.limit(1) + if (!recipient || isAccountBlocked(recipient)) return + if (kind === 'created') { + const [membership] = await db + .select({ id: member.id }) + .from(member) + .where( + and( + eq(member.userId, recipientUserId), + eq(member.organizationId, request.organizationId), + inArray(member.role, ['admin', 'owner']) + ) + ) + .limit(1) + if (!membership) return + } + context.signal.throwIfAborted() + const result = await sendEmail({ + to: recipient.email, + subject: getEmailSubject( + kind === 'created' ? 'permission-access-request-created' : 'permission-access-request-decided' + ), + html, + emailType: 'transactional', + }) + if (!result.success) throw new Error('Failed to send access request notification') +} + +/** + * Fan-out replays insert the same child IDs; one failed recipient cannot prevent + * other administrators receiving their messages. Provider sends remain at-least-once + * across a crash between delivery and outbox completion. + */ +export const permissionAccessRequestOutboxHandlers = { + [PERMISSION_ACCESS_REQUEST_CREATED_EVENT]: async (rawPayload, context) => { + const { requestId, afterMemberId } = createdPayloadSchema.parse(rawPayload) + const request = await loadRequest(requestId) + if ( + !request || + request.status !== 'pending' || + !(await isAccessRequestEnabled(request.organizationId)) || + !(await requesterHasCurrentAccess(request)) + ) { + return + } + if (!hasEmailService()) { + logger.info('Access request email skipped because email is not configured', { requestId }) + return + } + const recipients = await db + .select({ id: member.id, userId: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, request.organizationId), + inArray(member.role, ['admin', 'owner']), + afterMemberId ? gt(member.id, afterMemberId) : undefined + ) + ) + .orderBy(asc(member.id)) + .limit(ADMIN_RECIPIENT_PAGE_SIZE) + if (recipients.length === 0) return + context.signal.throwIfAborted() + await db + .insert(outboxEvent) + .values( + recipients.map((recipient) => ({ + id: `${ADMIN_NOTIFICATION_EVENT}:${requestId}:${recipient.userId}`, + eventType: ADMIN_NOTIFICATION_EVENT, + payload: { requestId, recipientUserId: recipient.userId }, + })) + ) + .onConflictDoNothing({ target: outboxEvent.id }) + await context.checkpointPayload({ afterMemberId: recipients[recipients.length - 1].id }) + if (recipients.length === ADMIN_RECIPIENT_PAGE_SIZE) { + return continueOutboxHandler('Continue access request administrator notifications') + } + }, + [ADMIN_NOTIFICATION_EVENT]: async (rawPayload, context) => { + const { requestId, recipientUserId } = adminPayloadSchema.parse(rawPayload) + const request = await loadRequest(requestId) + if ( + !request || + request.status !== 'pending' || + !hasEmailService() || + !(await isAccessRequestEnabled(request.organizationId)) || + !(await requesterHasCurrentAccess(request)) + ) { + return + } + await deliverNotification(request, 'created', recipientUserId, context) + }, + [PERMISSION_ACCESS_REQUEST_DECIDED_EVENT]: async (rawPayload, context) => { + const { requestId } = notificationPayloadSchema.parse(rawPayload) + const request = await loadRequest(requestId) + if ( + !request || + request.status === 'pending' || + !hasEmailService() || + !(await requesterHasCurrentAccess(request)) + ) { + return + } + await deliverNotification(request, 'decided', request.requesterId, context) + }, +} satisfies OutboxHandlerRegistry diff --git a/apps/sim/lib/permission-access-requests/policy.ts b/apps/sim/lib/permission-access-requests/policy.ts new file mode 100644 index 00000000000..29c4459f2c3 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/policy.ts @@ -0,0 +1,122 @@ +import { organizationMemberUsageLimit } from '@sim/db/schema' +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { and, eq } from 'drizzle-orm' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { isAccessControlEnabled, isHosted } from '@/lib/core/config/env-flags' +import type { DbOrTx } from '@/lib/db/types' +import type { AccessRequestContext } from '@/lib/permission-access-requests/application/authorization' +import { getAccessRequestDeploymentUnavailableReason } from '@/lib/permission-access-requests/catalog' +import type { + AccessRequestScope, + AccessRequestTarget, +} from '@/lib/permission-groups/access-requests/targets' +import { + type AccessRequestCatalog, + buildAccessRequestPolicyDelta, + describeAccessRequestTarget, + isAccessRequestTargetDenied, + isAccessRequestTargetInScope, + validateAccessRequestTarget, +} from '@/lib/permission-groups/access-requests/targets' +import { resolveDefaultGroup, resolveWorkspaceGroup } from '@/lib/permission-groups/resolve.server' + +export async function loadMemberLimit( + executor: DbOrTx, + organizationId: string, + userId: string, + forUpdate = false +) { + const query = executor + .select({ + usageLimit: organizationMemberUsageLimit.usageLimit, + updatedAt: organizationMemberUsageLimit.updatedAt, + }) + .from(organizationMemberUsageLimit) + .where( + and( + eq(organizationMemberUsageLimit.organizationId, organizationId), + eq(organizationMemberUsageLimit.userId, userId) + ) + ) + const [row] = forUpdate ? await query.for('update').limit(1) : await query.limit(1) + return row ? { ...row, credits: dollarsToCredits(Number(row.usageLimit)) } : null +} + +export async function loadAccessRequestPolicy( + executor: DbOrTx, + context: AccessRequestContext, + userId: string, + entitledAtAdmission?: boolean +) { + if (!context.organizationId) return { entitled: false, group: null, limit: null } + const entitled = + entitledAtAdmission !== false && + (isHosted + ? await isOrganizationOnEnterprisePlan(context.organizationId, 'return-false', executor) + : isAccessControlEnabled) + const group = !entitled + ? null + : context.workspaceId + ? await resolveWorkspaceGroup(userId, context.organizationId, context.workspaceId, executor) + : await resolveDefaultGroup(context.organizationId, executor) + const limit = isHosted ? await loadMemberLimit(executor, context.organizationId, userId) : null + return { entitled, group, limit } +} + +/** Hard deployment ceilings remain unavailable regardless of the permission group. */ +export async function evaluateAccessRequestTarget( + executor: DbOrTx, + context: AccessRequestContext, + userId: string, + scope: AccessRequestScope, + target: AccessRequestTarget, + catalog: AccessRequestCatalog, + preloaded?: Awaited>, + includeDelta = true +) { + const canonical = validateAccessRequestTarget(target, catalog) + const description = canonical && describeAccessRequestTarget(canonical, catalog) + const unavailable = (reason: string) => ({ + state: 'unavailable' as const, + reason, + group: null, + delta: null, + limit: null, + }) + if (!canonical || !description || !isAccessRequestTargetInScope(canonical, scope, catalog)) + return unavailable('This item is unavailable in this context.') + if (!context.organizationId) return unavailable('An organization administrator is required.') + const deploymentReason = getAccessRequestDeploymentUnavailableReason(canonical) + if (deploymentReason) return unavailable(deploymentReason) + if (!permissionSatisfies(context.role, description.minimumRole)) + return unavailable('Your workspace role does not permit this action.') + const policy = preloaded ?? (await loadAccessRequestPolicy(executor, context, userId)) + if (canonical.kind === 'usage_limit') { + if (!isHosted) return unavailable('Member credit limits are unavailable for this deployment.') + const limit = policy.limit + return { + state: limit ? ('requestable' as const) : ('allowed' as const), + reason: limit ? 'An organization administrator set your credit limit.' : null, + group: null, + delta: null, + limit, + } + } + if (!policy.entitled) + return unavailable('Permission groups are unavailable for this organization.') + const group = policy.group + if (!group) + return { state: 'allowed' as const, reason: null, group: null, delta: null, limit: null } + const denied = isAccessRequestTargetDenied(canonical, group.config, catalog) + const delta = includeDelta + ? buildAccessRequestPolicyDelta(canonical, group.config, catalog) + : null + return { + state: denied ? ('requestable' as const) : ('allowed' as const), + reason: denied ? 'Restricted by your permission group.' : null, + group, + delta, + limit: null, + } +} diff --git a/apps/sim/lib/permission-access-requests/repository.ts b/apps/sim/lib/permission-access-requests/repository.ts new file mode 100644 index 00000000000..c43e8986bf5 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/repository.ts @@ -0,0 +1,117 @@ +import { permissionAccessRequest, user } from '@sim/db/schema' +import { and, count, desc, eq, type SQL } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { storedAccessRequestTargetSchema } from '@/lib/permission-access-requests/schemas' +import type { AccessRequestList, AccessRequestRecord } from '@/lib/permission-access-requests/types' + +export type StoredAccessRequest = typeof permissionAccessRequest.$inferSelect + +export async function loadStoredAccessRequest( + executor: DbOrTx, + organizationId: string, + requestId: string, + forUpdate = false +): Promise { + const query = executor + .select() + .from(permissionAccessRequest) + .where( + and( + eq(permissionAccessRequest.id, requestId), + eq(permissionAccessRequest.organizationId, organizationId) + ) + ) + const [row] = forUpdate ? await query.for('update').limit(1) : await query.limit(1) + if (!row) throw new OrchestrationError('not_found', 'Access request not found') + return row +} + +export async function presentAccessRequest( + executor: DbOrTx, + row: StoredAccessRequest +): Promise { + const [requester] = await executor + .select({ id: user.id, name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, row.requesterId)) + .limit(1) + if (!requester) throw new OrchestrationError('not_found', 'Access request not found') + return projectAccessRequest(row, requester) +} + +type AccessRequestPresentation = Pick< + StoredAccessRequest, + | 'id' + | 'organizationId' + | 'workspaceId' + | 'target' + | 'targetLabel' + | 'reason' + | 'status' + | 'decisionReason' + | 'createdAt' + | 'decidedAt' + | 'groupName' +> + +function projectAccessRequest( + row: AccessRequestPresentation, + requester: AccessRequestRecord['requester'] +): AccessRequestRecord { + return { + id: row.id, + organizationId: row.organizationId, + workspaceId: row.workspaceId, + target: storedAccessRequestTargetSchema.parse(row.target), + targetLabel: row.targetLabel, + reason: row.reason, + status: row.status, + decisionReason: row.decisionReason, + createdAt: row.createdAt.toISOString(), + decidedAt: row.decidedAt?.toISOString() ?? null, + groupName: row.groupName, + requester, + } +} + +export async function listAccessRequestRecords( + executor: DbOrTx, + where: SQL, + limit: number, + offset: number +): Promise { + const rows = await executor + .select({ + row: { + id: permissionAccessRequest.id, + organizationId: permissionAccessRequest.organizationId, + workspaceId: permissionAccessRequest.workspaceId, + target: permissionAccessRequest.target, + targetLabel: permissionAccessRequest.targetLabel, + reason: permissionAccessRequest.reason, + status: permissionAccessRequest.status, + decisionReason: permissionAccessRequest.decisionReason, + createdAt: permissionAccessRequest.createdAt, + decidedAt: permissionAccessRequest.decidedAt, + groupName: permissionAccessRequest.groupName, + }, + requester: { id: user.id, name: user.name, email: user.email }, + }) + .from(permissionAccessRequest) + .innerJoin(user, eq(user.id, permissionAccessRequest.requesterId)) + .where(where) + .orderBy(desc(permissionAccessRequest.createdAt), desc(permissionAccessRequest.id)) + .limit(limit) + .offset(offset) + const [aggregate] = await executor + .select({ total: count() }) + .from(permissionAccessRequest) + .where(where) + const total = aggregate?.total ?? 0 + return { + requests: rows.map(({ row, requester }) => projectAccessRequest(row, requester)), + total, + hasMore: offset + rows.length < total, + } +} diff --git a/apps/sim/lib/permission-access-requests/schemas.test.ts b/apps/sim/lib/permission-access-requests/schemas.test.ts new file mode 100644 index 00000000000..d98cf87162a --- /dev/null +++ b/apps/sim/lib/permission-access-requests/schemas.test.ts @@ -0,0 +1,66 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { storedAccessRequestPolicyChangeSchema } from '@/lib/permission-access-requests/schemas' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + PERMISSION_GROUP_FIELDS, +} from '@/lib/permission-groups/fields' + +describe('stored access request policy changes', () => { + it('accepts unchanged canonical values for every field', () => { + for (const configKey of Object.keys( + PERMISSION_GROUP_FIELDS + ) as (keyof typeof PERMISSION_GROUP_FIELDS)[]) { + const value = DEFAULT_PERMISSION_GROUP_CONFIG[configKey] + expect( + storedAccessRequestPolicyChangeSchema.parse({ + configKey, + label: configKey, + before: value, + after: value, + }) + ).toEqual({ configKey, label: configKey, before: value, after: value }) + } + }) + + it.each([ + { configKey: 'hideCopilot', before: true, after: false }, + { configKey: 'allowedIntegrations', before: ['github_v2'], after: ['github_v2', 'slack_v2'] }, + { + configKey: 'allowedFileShareAuthTypes', + before: null, + after: ['public', 'password', 'email', 'sso'], + }, + { configKey: 'allowedChatDeployAuthTypes', before: [], after: null }, + { configKey: 'deniedTools', before: ['tool'], after: [] }, + ])('preserves valid snapshots for $configKey', (change) => { + expect(storedAccessRequestPolicyChangeSchema.parse({ ...change, label: 'Access' })).toEqual({ + ...change, + label: 'Access', + }) + }) + + it.each([ + { configKey: 'hideCopilot', valid: false, invalid: ['public'] }, + { configKey: 'allowedIntegrations', valid: null, invalid: false }, + { configKey: 'allowedFileShareAuthTypes', valid: ['sso'], invalid: ['invented'] }, + { configKey: 'allowedChatDeployAuthTypes', valid: null, invalid: ['invented'] }, + { configKey: 'deniedTools', valid: [], invalid: null }, + { configKey: 'deniedModels', valid: [], invalid: true }, + ])('rejects invalid before and after values for $configKey', ({ configKey, valid, invalid }) => { + for (const side of ['before', 'after'] as const) { + const result = storedAccessRequestPolicyChangeSchema.safeParse({ + configKey, + label: 'Access', + before: valid, + after: valid, + [side]: invalid, + }) + expect(result.success).toBe(false) + if (!result.success) + expect(result.error.issues).toEqual([expect.objectContaining({ path: [side] })]) + } + }) +}) diff --git a/apps/sim/lib/permission-access-requests/schemas.ts b/apps/sim/lib/permission-access-requests/schemas.ts new file mode 100644 index 00000000000..5e17319cd38 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/schemas.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import type { AccessRequestTarget as DomainAccessRequestTarget } from '@/lib/permission-groups/access-requests/targets' +import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' +import { FILE_SHARE_AUTH_TYPES, PERMISSION_GROUP_FIELDS } from '@/lib/permission-groups/fields' + +const targetIdSchema = z.string().min(1, 'Target ID cannot be empty').max(512) +const fingerprintSchema = z.string().min(1, 'A current preview is required').max(128) + +/** Canonical validators for the target and decision JSON persisted with a request. */ +export const storedAccessRequestTargetSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('feature'), + configKey: z.enum(PLATFORM_FEATURES.map((feature) => feature.configKey)), + }) + .strict(), + z.object({ kind: z.literal('integration'), id: targetIdSchema }).strict(), + z.object({ kind: z.literal('provider'), id: targetIdSchema }).strict(), + z.object({ kind: z.literal('model'), id: targetIdSchema }).strict(), + z.object({ kind: z.literal('tool'), id: targetIdSchema }).strict(), + z.object({ kind: z.literal('knowledge_connector'), id: targetIdSchema }).strict(), + z.object({ kind: z.literal('file_share_auth'), id: z.enum(FILE_SHARE_AUTH_TYPES) }).strict(), + z.object({ kind: z.literal('chat_deploy_auth'), id: z.enum(FILE_SHARE_AUTH_TYPES) }).strict(), + z.object({ kind: z.literal('usage_limit'), id: z.literal('member') }).strict(), +]) satisfies z.ZodType +export const storedAccessRequestPolicyValueSchema = z.union([ + z.boolean(), + PERMISSION_GROUP_FIELDS.allowedIntegrations.readSchema, +]) + +export const storedAccessRequestPolicyChangeSchema = z + .object({ + configKey: z.enum( + Object.keys(PERMISSION_GROUP_FIELDS) as (keyof typeof PERMISSION_GROUP_FIELDS)[] + ), + label: z.string().min(1).max(512), + before: storedAccessRequestPolicyValueSchema, + after: storedAccessRequestPolicyValueSchema, + }) + .superRefine((change, context) => { + const schema = PERMISSION_GROUP_FIELDS[change.configKey].readSchema + for (const side of ['before', 'after'] as const) { + if (!schema.safeParse(change[side]).success) { + context.addIssue({ + code: 'custom', + path: [side], + message: `Invalid ${side} value for ${change.configKey}`, + }) + } + } + }) + +export const storedAccessRequestDecisionSchema = z.object({ + resolutionKind: z.enum(['permission', 'usage_limit']), + changes: z + .array(storedAccessRequestPolicyChangeSchema) + .max(Object.keys(PERMISSION_GROUP_FIELDS).length), + impact: z.object({ + memberCount: z.number().int().nonnegative(), + workspaceCount: z.number().int().nonnegative(), + workspaceNames: z.array(z.string()).max(100), + truncated: z.boolean(), + }), + group: z.object({ id: z.string().min(1).max(128), name: z.string() }).nullable(), + currentLimitCredits: z.number().finite().nonnegative().nullable(), + newLimitCredits: z.number().finite().nonnegative().nullable(), + fingerprint: fingerprintSchema, +}) +export type AccessRequestDecision = z.output diff --git a/apps/sim/lib/permission-access-requests/settings.test.ts b/apps/sim/lib/permission-access-requests/settings.test.ts new file mode 100644 index 00000000000..69643b15c04 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/settings.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { organizationAccessRequestSettings } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsFeatureEnabled } = vi.hoisted(() => ({ mockIsFeatureEnabled: vi.fn() })) + +vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) + +import { + isAccessRequestEnabled, + readAccessRequestSettings, +} from '@/lib/permission-access-requests/settings' + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsFeatureEnabled.mockResolvedValue(true) +}) + +describe('permission access request settings', () => { + it('defaults the organization preference on when no settings row exists', async () => { + queueTableRows(organizationAccessRequestSettings, []) + + await expect(readAccessRequestSettings('organization-one')).resolves.toEqual({ + allowRequests: true, + }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'eq', + left: organizationAccessRequestSettings.organizationId, + right: 'organization-one', + }) + }) + + it('does not let the default-on preference bypass the global rollout flag', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + + await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(false) + + expect(mockIsFeatureEnabled).toHaveBeenCalledExactlyOnceWith('permission-access-requests') + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('enables requests when rollout is active and the organization has not opted out', async () => { + queueTableRows(organizationAccessRequestSettings, []) + + await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(true) + }) + + it('rechecks rollout after an enabled admission snapshot', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + + await expect(isAccessRequestEnabled('organization-one', undefined, true)).resolves.toBe(false) + + expect(mockIsFeatureEnabled).toHaveBeenCalledExactlyOnceWith('permission-access-requests') + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('keeps a disabled admission snapshot denied even if rollout is now enabled', async () => { + await expect(isAccessRequestEnabled('organization-one', undefined, false)).resolves.toBe(false) + + expect(mockIsFeatureEnabled).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('requires the current organization preference after an enabled admission snapshot', async () => { + queueTableRows(organizationAccessRequestSettings, [{ allowRequests: false }]) + + await expect(isAccessRequestEnabled('organization-one', undefined, true)).resolves.toBe(false) + + expect(mockIsFeatureEnabled).toHaveBeenCalledExactlyOnceWith('permission-access-requests') + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + + it('honors an organization opt-out while global rollout is active', async () => { + queueTableRows(organizationAccessRequestSettings, [{ allowRequests: false }]) + + await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(false) + }) + + it('preserves an explicit enabled preference', async () => { + queueTableRows(organizationAccessRequestSettings, [{ allowRequests: true }]) + + await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(true) + }) + + it('does not reinterpret a failed settings lookup as permission to submit', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) + + await expect(isAccessRequestEnabled('organization-one')).rejects.toThrow('database unavailable') + }) +}) diff --git a/apps/sim/lib/permission-access-requests/settings.ts b/apps/sim/lib/permission-access-requests/settings.ts new file mode 100644 index 00000000000..f2b35394116 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/settings.ts @@ -0,0 +1,26 @@ +import { db } from '@sim/db' +import { organizationAccessRequestSettings } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import type { DbOrTx } from '@/lib/db/types' + +/** Missing settings preserve the default-on organization preference. */ +export async function readAccessRequestSettings(organizationId: string, executor: DbOrTx = db) { + const [row] = await executor + .select({ allowRequests: organizationAccessRequestSettings.allowRequests }) + .from(organizationAccessRequestSettings) + .where(eq(organizationAccessRequestSettings.organizationId, organizationId)) + .limit(1) + return { allowRequests: row?.allowRequests ?? true } +} + +/** The rollout flag is evaluated globally; organization preferences can only narrow it. */ +export async function isAccessRequestEnabled( + organizationId: string, + executor: DbOrTx = db, + enabledAtAdmission?: boolean +): Promise { + if (enabledAtAdmission === false || !(await isFeatureEnabled('permission-access-requests'))) + return false + return (await readAccessRequestSettings(organizationId, executor)).allowRequests +} diff --git a/apps/sim/lib/permission-access-requests/types.ts b/apps/sim/lib/permission-access-requests/types.ts new file mode 100644 index 00000000000..9acb355d778 --- /dev/null +++ b/apps/sim/lib/permission-access-requests/types.ts @@ -0,0 +1,83 @@ +import type { AccessRequestDecision } from '@/lib/permission-access-requests/schemas' +import type { + AccessRequestScope, + AccessRequestTarget, +} from '@/lib/permission-groups/access-requests/targets' + +export type AccessRequestStatus = 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + +export interface AccessRequestSettings { + allowRequests: boolean +} + +export interface AccessRequestRecord { + id: string + organizationId: string + workspaceId: string | null + target: AccessRequestTarget + targetLabel: string + reason: string + status: AccessRequestStatus + decisionReason: string | null + createdAt: string + decidedAt: string | null + groupName: string | null + requester: { id: string; name: string | null; email: string } +} + +export interface AccessRequestList { + requests: AccessRequestRecord[] + total: number + hasMore: boolean +} + +export interface CreateAccessRequestInput { + scope: AccessRequestScope + target: AccessRequestTarget + reason?: string +} + +export type DiscoverAccessRequestsInput = AccessRequestScope & { + limit: number + offset: number + search?: string + targetKind?: AccessRequestTarget['kind'] + targetKey?: string + state?: 'allowed' | 'requestable' | 'unavailable' +} + +export interface AccessRequestDiscovery { + enabled: boolean + organizationId: string | null + entries: { + target: AccessRequestTarget + label: string + state: 'allowed' | 'requestable' | 'unavailable' + reason: string | null + pendingRequestId: string | null + }[] + total: number + hasMore: boolean +} + +export type ResolveAccessRequestDecision = + | { action: 'apply'; expectedFingerprint: string; newLimitCredits?: number } + | { action: 'decline'; reason: string } + +export type AccessRequestImpact = AccessRequestDecision['impact'] + +export type AccessRequestPreview = Omit< + AccessRequestDecision, + 'resolutionKind' | 'group' | 'currentLimitCredits' +> & { + request: AccessRequestRecord + canApply: boolean + unavailableReason: string | null +} & ( + | { + resolutionKind: 'permission' + group: AccessRequestDecision['group'] + currentLimitCredits: null + } + | { resolutionKind: 'usage_limit'; group: null; currentLimitCredits: number | null } + ) diff --git a/apps/sim/lib/permission-groups/access-requests/targets.test.ts b/apps/sim/lib/permission-groups/access-requests/targets.test.ts new file mode 100644 index 00000000000..d5ac0b2db25 --- /dev/null +++ b/apps/sim/lib/permission-groups/access-requests/targets.test.ts @@ -0,0 +1,333 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type AccessRequestTarget, + buildAccessRequestPolicyDelta, + createAccessRequestCatalog, + describeAccessRequestTarget, + getAccessRequestTargetKey, + isAccessRequestTargetDenied, + isAccessRequestTargetInScope, + validateAccessRequestTarget, +} from '@/lib/permission-groups/access-requests/targets' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const catalog = createAccessRequestCatalog({ + integrations: [ + { id: 'slack_v2', label: 'Slack' }, + { id: 'github', label: 'GitHub' }, + ], + providers: [ + { id: 'openai', label: 'OpenAI' }, + { id: 'anthropic', label: 'Anthropic' }, + ], + models: [ + { id: 'gpt-example', label: 'Example GPT', providerId: 'openai' }, + { id: 'other-gpt', label: 'Other GPT', providerId: 'openai' }, + ], + tools: [{ id: 'slack_send_message_v2', label: 'Send message', integrationId: 'slack_v2' }], + knowledgeConnectors: [{ id: 'google_drive', label: 'Google Drive' }], +}) + +describe('access request targets', () => { + it('rejects unknown and prototype-named catalog IDs', () => { + for (const id of ['__proto__', 'constructor', 'missing']) { + expect(validateAccessRequestTarget({ kind: 'integration', id }, catalog)).toBeNull() + expect(validateAccessRequestTarget({ kind: 'provider', id }, catalog)).toBeNull() + expect(validateAccessRequestTarget({ kind: 'model', id }, catalog)).toBeNull() + expect(validateAccessRequestTarget({ kind: 'tool', id }, catalog)).toBeNull() + } + }) + + it('canonicalizes integration successors before testing or keying a request', () => { + const target = validateAccessRequestTarget({ kind: 'integration', id: 'Slack' }, catalog) + expect(target).toEqual({ kind: 'integration', id: 'slack_v2' }) + expect(target && getAccessRequestTargetKey(target)).toBe('integration:slack_v2') + const delta = buildAccessRequestPolicyDelta( + { kind: 'integration', id: 'slack_v2' }, + { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: ['SLACK'] }, + catalog + ) + expect(delta.changes).toEqual([]) + }) + + it('allows a single member of an empty allowlist without lifting the whole allowlist', () => { + const delta = buildAccessRequestPolicyDelta( + { kind: 'integration', id: 'slack_v2' }, + { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: [] }, + catalog + ) + expect(delta.config.allowedIntegrations).toEqual(['slack_v2']) + expect(delta.changes).toEqual([ + { + configKey: 'allowedIntegrations', + label: 'Allowed integrations and blocks', + before: [], + after: ['slack_v2'], + }, + ]) + expect( + buildAccessRequestPolicyDelta( + { kind: 'integration', id: 'slack_v2' }, + DEFAULT_PERMISSION_GROUP_CONFIG, + catalog + ).changes + ).toEqual([]) + }) + + it('shows both provider and model changes while preserving other explicit denials', () => { + const original = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedModelProviders: ['anthropic'], + deniedModels: ['GPT-EXAMPLE', 'other-gpt'], + } + const delta = buildAccessRequestPolicyDelta( + { kind: 'model', id: 'GpT-ExAmPlE' }, + original, + catalog + ) + expect(delta.config.allowedModelProviders).toEqual(['anthropic', 'openai']) + expect(delta.config.deniedModels).toEqual(['other-gpt']) + expect(delta.changes.map((change) => change.configKey)).toEqual([ + 'allowedModelProviders', + 'deniedModels', + ]) + expect(original.allowedModelProviders).toEqual(['anthropic']) + expect(original.deniedModels).toEqual(['GPT-EXAMPLE', 'other-gpt']) + }) + + it('keeps model denials when requesting a provider', () => { + const delta = buildAccessRequestPolicyDelta( + { kind: 'provider', id: 'openai' }, + { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedModelProviders: [], + deniedModels: ['gpt-example'], + }, + catalog + ) + expect(delta.config.deniedModels).toEqual(['gpt-example']) + expect(delta.changes.map((change) => change.configKey)).toEqual(['allowedModelProviders']) + }) + + it('shows the tool and parent integration changes using exact tool IDs', () => { + const config = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['github'], + deniedTools: ['slack_send_message_v2', 'other_tool'], + } + const delta = buildAccessRequestPolicyDelta( + { kind: 'tool', id: 'slack_send_message_v2' }, + config, + catalog + ) + expect(delta.config.allowedIntegrations).toEqual(['github_v2', 'slack_v2']) + expect(delta.config.deniedTools).toEqual(['other_tool']) + expect(delta.changes.map((change) => change.configKey)).toEqual([ + 'allowedIntegrations', + 'deniedTools', + ]) + expect( + validateAccessRequestTarget({ kind: 'tool', id: 'SLACK_SEND_MESSAGE_V2' }, catalog) + ).toBeNull() + }) + + it('opens the parent module for a child action and preserves other child restrictions', () => { + const config = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + disableKnowledgeBaseCreation: true, + disableKnowledgeBaseExport: true, + } + const delta = buildAccessRequestPolicyDelta( + { kind: 'feature', configKey: 'disableKnowledgeBaseCreation' }, + config, + catalog + ) + expect(CAPABILITY_RULES['knowledge.create'].deniedBy(delta.config)).toBe(false) + expect(CAPABILITY_RULES['knowledge.export'].deniedBy(delta.config)).toBe(true) + expect(delta.changes.map((change) => change.configKey)).toEqual([ + 'hideKnowledgeBaseTab', + 'disableKnowledgeBaseCreation', + ]) + }) + + it('lifts both the connector allowlist and the module restriction', () => { + const delta = buildAccessRequestPolicyDelta( + { kind: 'knowledge_connector', id: 'google_drive' }, + { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + allowedKnowledgeConnectors: [], + }, + catalog + ) + expect(CAPABILITY_RULES['knowledge.use'].deniedBy(delta.config)).toBe(false) + expect(CAPABILITY_RULES['knowledge.connectors'].deniedBy(delta.config, 'google_drive')).toBe( + false + ) + expect(delta.config.allowedKnowledgeConnectors).toEqual(['google_drive']) + }) + + it('includes parent sharing/module restrictions when allowing one authentication mode', () => { + const delta = buildAccessRequestPolicyDelta( + { kind: 'file_share_auth', id: 'sso' }, + { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideFilesTab: true, + disablePublicFileSharing: true, + allowedFileShareAuthTypes: ['password'], + }, + catalog + ) + expect(delta.config.allowedFileShareAuthTypes).toEqual(['password', 'sso']) + expect(delta.changes.map((change) => change.configKey)).toEqual([ + 'hideFilesTab', + 'disablePublicFileSharing', + 'allowedFileShareAuthTypes', + ]) + }) + + it('identifies organization-only targets and keeps billing out of policy mutation', () => { + expect( + isAccessRequestTargetInScope( + { kind: 'feature', configKey: 'disableWorkspaceCreation' }, + { kind: 'workspace', workspaceId: 'ws' }, + catalog + ) + ).toBe(false) + expect( + isAccessRequestTargetInScope( + { kind: 'feature', configKey: 'disableCliAccess' }, + { kind: 'organization', organizationId: 'org' }, + catalog + ) + ).toBe(true) + expect(describeAccessRequestTarget({ kind: 'usage_limit', id: 'member' }, catalog)?.scope).toBe( + 'workspace-or-organization' + ) + for (const scope of [ + { kind: 'workspace', workspaceId: 'ws' }, + { kind: 'organization', organizationId: 'org' }, + ] as const) { + expect( + isAccessRequestTargetInScope({ kind: 'usage_limit', id: 'member' }, scope, catalog) + ).toBe(true) + } + expect(() => + buildAccessRequestPolicyDelta( + { kind: 'usage_limit', id: 'member' }, + DEFAULT_PERMISSION_GROUP_CONFIG, + catalog + ) + ).toThrow('billing limit change') + }) + + it('includes module and upload blockers for built-in tool requests', () => { + const moduleCatalog = createAccessRequestCatalog({ + integrations: [ + { id: 'mcp', label: 'MCP' }, + { id: 'knowledge', label: 'Knowledge' }, + { id: 'table_v2', label: 'Tables' }, + ], + providers: [], + models: [], + knowledgeConnectors: [], + tools: [ + { id: 'mcp_run_operation', label: 'Run MCP operation', integrationId: 'mcp' }, + { id: 'knowledge_create_document', label: 'Create document', integrationId: 'knowledge' }, + ], + }) + const config = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableMcpTools: true, + hideKnowledgeBaseTab: true, + disableKnowledgeBaseFileUpload: true, + hideTablesTab: true, + } + const mcp = buildAccessRequestPolicyDelta( + { kind: 'tool', id: 'mcp_run_operation' }, + config, + moduleCatalog + ) + expect(mcp.changes.map((change) => change.configKey)).toEqual(['disableMcpTools']) + const upload = buildAccessRequestPolicyDelta( + { kind: 'tool', id: 'knowledge_create_document' }, + config, + moduleCatalog + ) + expect(upload.changes.map((change) => change.configKey)).toEqual([ + 'hideKnowledgeBaseTab', + 'disableKnowledgeBaseFileUpload', + ]) + const table = buildAccessRequestPolicyDelta( + { kind: 'integration', id: 'table_v2' }, + config, + moduleCatalog + ) + expect(table.changes.map((change) => change.configKey)).toEqual(['hideTablesTab']) + for (const delta of [mcp, upload, table]) { + expect(isAccessRequestTargetDenied(delta.target, config, moduleCatalog)).toBe(true) + expect(isAccessRequestTargetDenied(delta.target, delta.config, moduleCatalog)).toBe(false) + } + }) + + it('keeps cheap discovery denial in parity with full policy deltas', () => { + const targets: AccessRequestTarget[] = [ + { kind: 'feature', configKey: 'disableKnowledgeBaseCreation' }, + { kind: 'integration', id: 'slack' }, + { kind: 'provider', id: 'openai' }, + { kind: 'model', id: 'GPT-EXAMPLE' }, + { kind: 'tool', id: 'slack_send_message_v2' }, + { kind: 'knowledge_connector', id: 'google_drive' }, + { kind: 'file_share_auth', id: 'public' }, + { kind: 'chat_deploy_auth', id: 'sso' }, + ] + const configs = [ + DEFAULT_PERMISSION_GROUP_CONFIG, + { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['SLACK'], + allowedModelProviders: ['openai'], + deniedModels: ['OTHER-GPT'], + }, + { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + hideFilesTab: true, + hideDeployChatbot: true, + }, + { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: [], + allowedModelProviders: [], + deniedModels: ['GPT-EXAMPLE'], + deniedTools: ['slack_send_message_v2'], + allowedKnowledgeConnectors: [], + allowedFileShareAuthTypes: [], + allowedChatDeployAuthTypes: [], + }, + ] + for (const config of configs) { + for (const target of targets) { + expect(isAccessRequestTargetDenied(target, config, catalog)).toBe( + buildAccessRequestPolicyDelta(target, config, catalog).changes.length > 0 + ) + } + } + }) + + it('permits read-level module requests for secrets and API-key visibility', () => { + expect( + describeAccessRequestTarget({ kind: 'feature', configKey: 'hideSecretsTab' }, catalog) + ?.minimumRole + ).toBe('read') + expect( + describeAccessRequestTarget({ kind: 'feature', configKey: 'hideApiKeysTab' }, catalog) + ?.minimumRole + ).toBe('read') + }) +}) diff --git a/apps/sim/lib/permission-groups/access-requests/targets.ts b/apps/sim/lib/permission-groups/access-requests/targets.ts new file mode 100644 index 00000000000..80ad601af85 --- /dev/null +++ b/apps/sim/lib/permission-groups/access-requests/targets.ts @@ -0,0 +1,446 @@ +import { + type BooleanPermissionGroupConfigKey, + PLATFORM_FEATURES, +} from '@/lib/permission-groups/features' +import { + FILE_SHARE_AUTH_TYPES, + PERMISSION_GROUP_FIELDS, + type PermissionGroupCapabilityScope, + type PermissionGroupConfig, + type PermissionGroupConfigKey, +} from '@/lib/permission-groups/fields' +import { + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/integration-allowlist' + +export type AccessRequestScope = + | { kind: 'workspace'; workspaceId: string } + | { kind: 'organization'; organizationId: string } + +export type AccessRequestTarget = + | { kind: 'feature'; configKey: BooleanPermissionGroupConfigKey } + | { kind: 'integration' | 'provider' | 'model' | 'tool' | 'knowledge_connector'; id: string } + | { kind: 'file_share_auth' | 'chat_deploy_auth'; id: (typeof FILE_SHARE_AUTH_TYPES)[number] } + | { kind: 'usage_limit'; id: 'member' } + +export const ACCESS_REQUEST_TARGET_KINDS = [ + 'feature', + 'integration', + 'provider', + 'model', + 'tool', + 'knowledge_connector', + 'file_share_auth', + 'chat_deploy_auth', + 'usage_limit', +] as const satisfies readonly AccessRequestTarget['kind'][] + +export interface AccessRequestCatalogItem { + id: string + label: string +} + +export interface AccessRequestModelItem extends AccessRequestCatalogItem { + providerId: string | null +} + +export interface AccessRequestToolItem extends AccessRequestCatalogItem { + integrationId: string | null +} + +export interface AccessRequestCatalogInput { + integrations: readonly AccessRequestCatalogItem[] + providers: readonly AccessRequestCatalogItem[] + models: readonly AccessRequestModelItem[] + tools: readonly AccessRequestToolItem[] + knowledgeConnectors: readonly AccessRequestCatalogItem[] +} + +/** Catalogs come from authorized discovery; executable registries stay out of this module. */ +export interface AccessRequestCatalog { + integrations: ReadonlyMap + providers: ReadonlyMap + models: ReadonlyMap + tools: ReadonlyMap + knowledgeConnectors: ReadonlyMap +} + +export interface AccessRequestTargetDescription { + label: string + scope: PermissionGroupCapabilityScope + minimumRole: 'read' | 'write' +} + +export type AccessRequestPolicyValue = boolean | string[] | null + +export interface AccessRequestPolicyChange { + configKey: PermissionGroupConfigKey + label: string + before: AccessRequestPolicyValue + after: AccessRequestPolicyValue +} + +export interface AccessRequestPolicyDelta { + target: AccessRequestTarget + targetLabel: string + config: PermissionGroupConfig + changes: AccessRequestPolicyChange[] +} + +const FEATURES_BY_KEY = new Map(PLATFORM_FEATURES.map((feature) => [feature.configKey, feature])) + +/** The parent module must also be available for these actions to become usable. */ +const FEATURE_PARENTS: Partial< + Record +> = { + disableKnowledgeBaseCreation: ['hideKnowledgeBaseTab'], + disableKnowledgeBaseFileUpload: ['hideKnowledgeBaseTab'], + disableKnowledgeBaseExport: ['hideKnowledgeBaseTab'], + disableTableCreation: ['hideTablesTab'], + disableTableExport: ['hideTablesTab'], + disableBulkFileDownload: ['hideFilesTab'], + disablePublicFileSharing: ['hideFilesTab'], + disablePersonalCredentials: ['hideIntegrationsTab'], +} + +const WRITE_FEATURES = new Set([ + 'hideDeployApi', + 'hideDeployMcp', + 'hideDeployChatbot', + 'disableKnowledgeBaseCreation', + 'disableKnowledgeBaseFileUpload', + 'disableTableCreation', + 'disableInvitations', + 'disablePublicFileSharing', + 'disableWebhookTriggers', +]) + +/** Built-in blocks whose operations also enter a governed platform module. */ +const INTEGRATION_FEATURES = new Map([ + ['mcp', 'disableMcpTools'], + ['knowledge', 'hideKnowledgeBaseTab'], + ['table_v2', 'hideTablesTab'], + ['file_v5', 'hideFilesTab'], +]) + +const TOOL_FEATURES = new Map([ + ['knowledge_create_document', 'disableKnowledgeBaseFileUpload'], + ['knowledge_upsert_document', 'disableKnowledgeBaseFileUpload'], +]) + +const LIST_FIELD_LABELS = { + allowedIntegrations: 'Allowed integrations and blocks', + allowedModelProviders: 'Allowed model providers', + deniedModels: 'Blocked models', + deniedTools: 'Blocked tools', + allowedKnowledgeConnectors: 'Allowed knowledge base connectors', + allowedFileShareAuthTypes: 'Allowed file sharing authentication', + allowedChatDeployAuthTypes: 'Allowed chat authentication', +} as const + +const AUTH_LABELS = { + public: 'Public', + password: 'Password', + email: 'Email', + sso: 'SSO', +} as const + +function indexItems( + items: readonly T[], + normalize: (id: string) => string = (id) => id +): ReadonlyMap { + return new Map(items.map((item) => [normalize(item.id), item])) +} + +function normalizeIntegration(id: string): string { + return resolveAccessControlBlockType(id.toLowerCase()).toLowerCase() +} + +/** Index once per discovery, so evaluating every target does not scan every catalog repeatedly. */ +export function createAccessRequestCatalog(input: AccessRequestCatalogInput): AccessRequestCatalog { + return { + integrations: indexItems(input.integrations, normalizeIntegration), + providers: indexItems(input.providers), + models: indexItems(input.models, (id) => id.toLowerCase()), + tools: indexItems(input.tools), + knowledgeConnectors: indexItems(input.knowledgeConnectors), + } +} + +/** Rejects unknown IDs and returns the vocabulary the existing permission gates compare. */ +export function validateAccessRequestTarget( + target: AccessRequestTarget, + catalog: AccessRequestCatalog +): AccessRequestTarget | null { + switch (target.kind) { + case 'feature': + return FEATURES_BY_KEY.has(target.configKey) ? { ...target } : null + case 'integration': { + const id = normalizeIntegration(target.id) + return catalog.integrations.has(id) ? { kind: target.kind, id } : null + } + case 'provider': + return catalog.providers.has(target.id) ? { ...target } : null + case 'model': { + const model = catalog.models.get(target.id.toLowerCase()) + if (!model || (model.providerId !== null && !catalog.providers.has(model.providerId))) { + return null + } + return { kind: target.kind, id: model.id } + } + case 'tool': { + const tool = catalog.tools.get(target.id) + if ( + !tool || + (tool.integrationId !== null && + !catalog.integrations.has(normalizeIntegration(tool.integrationId))) + ) { + return null + } + return { kind: target.kind, id: tool.id } + } + case 'knowledge_connector': + return catalog.knowledgeConnectors.has(target.id) ? { ...target } : null + case 'file_share_auth': + case 'chat_deploy_auth': + return FILE_SHARE_AUTH_TYPES.some((authType) => authType === target.id) ? { ...target } : null + case 'usage_limit': + return target.id === 'member' ? { ...target } : null + } +} + +/** Stable key for a target already canonicalized by validateAccessRequestTarget. */ +export function getAccessRequestTargetKey(target: AccessRequestTarget): string { + return `${target.kind}:${encodeURIComponent(target.kind === 'feature' ? target.configKey : target.id)}` +} + +export function describeAccessRequestTarget( + target: AccessRequestTarget, + catalog: AccessRequestCatalog +): AccessRequestTargetDescription | null { + const canonical = validateAccessRequestTarget(target, catalog) + if (!canonical) return null + if (canonical.kind === 'feature') { + const feature = FEATURES_BY_KEY.get(canonical.configKey) + if (!feature) return null + return { + label: feature.label, + scope: feature.scope, + minimumRole: WRITE_FEATURES.has(canonical.configKey) ? 'write' : 'read', + } + } + if (canonical.kind === 'usage_limit') { + return { label: 'Member usage limit', scope: 'workspace-or-organization', minimumRole: 'read' } + } + if (canonical.kind === 'file_share_auth' || canonical.kind === 'chat_deploy_auth') { + const subject = canonical.kind === 'file_share_auth' ? 'file sharing' : 'chat deployment' + return { + label: `${AUTH_LABELS[canonical.id]} ${subject}`, + scope: 'workspace', + minimumRole: 'write', + } + } + const items = { + integration: catalog.integrations, + provider: catalog.providers, + model: catalog.models, + tool: catalog.tools, + knowledge_connector: catalog.knowledgeConnectors, + } + const item = items[canonical.kind].get( + canonical.kind === 'model' ? canonical.id.toLowerCase() : canonical.id + ) + if (!item) return null + return { + label: item.label, + scope: 'workspace', + minimumRole: canonical.kind === 'knowledge_connector' ? 'write' : 'read', + } +} + +export function isAccessRequestTargetInScope( + target: AccessRequestTarget, + scope: AccessRequestScope, + catalog: AccessRequestCatalog +): boolean { + const description = describeAccessRequestTarget(target, catalog) + return Boolean( + description && + (description.scope === 'workspace-or-organization' || description.scope === scope.kind) + ) +} + +function allowMember(allowed: T[] | null, member: T): T[] | null { + return allowed === null || allowed.includes(member) ? allowed : [...allowed, member] +} + +function requiredFeatures( + target: AccessRequestTarget, + catalog: AccessRequestCatalog +): BooleanPermissionGroupConfigKey[] { + const keys: BooleanPermissionGroupConfigKey[] = [] + if (target.kind === 'feature') keys.push(target.configKey) + if (target.kind === 'integration') { + const feature = INTEGRATION_FEATURES.get(target.id) + if (feature) keys.push(feature) + } + if (target.kind === 'tool') { + const integrationId = catalog.tools.get(target.id)?.integrationId + const integrationFeature = integrationId && INTEGRATION_FEATURES.get(integrationId) + if (integrationFeature) keys.push(integrationFeature) + const feature = TOOL_FEATURES.get(target.id) + if (feature) keys.push(feature) + } + if (target.kind === 'knowledge_connector') keys.push('hideKnowledgeBaseTab') + if (target.kind === 'file_share_auth') keys.push('disablePublicFileSharing') + if (target.kind === 'chat_deploy_auth') keys.push('hideDeployChatbot') + return [...new Set(keys.flatMap((key) => [key, ...(FEATURE_PARENTS[key] ?? [])]))] +} + +function integrationDenied(config: PermissionGroupConfig, integrationId: string): boolean { + return ( + config.allowedIntegrations !== null && + !config.allowedIntegrations.some( + (id) => normalizeIntegration(id) === normalizeIntegration(integrationId) + ) + ) +} + +/** Tests denial without copying full policies or their potentially large denylists. */ +export function isAccessRequestTargetDenied( + target: AccessRequestTarget, + config: PermissionGroupConfig, + catalog: AccessRequestCatalog +): boolean { + const canonical = validateAccessRequestTarget(target, catalog) + if (!canonical) throw new Error('Unknown access request target') + if (requiredFeatures(canonical, catalog).some((key) => config[key])) return true + switch (canonical.kind) { + case 'feature': + return false + case 'integration': + return integrationDenied(config, canonical.id) + case 'provider': + return ( + config.allowedModelProviders !== null && + !config.allowedModelProviders.includes(canonical.id) + ) + case 'model': { + const providerId = catalog.models.get(canonical.id.toLowerCase())?.providerId + return ( + config.deniedModels.some((id) => id.toLowerCase() === canonical.id.toLowerCase()) || + Boolean( + providerId && + config.allowedModelProviders !== null && + !config.allowedModelProviders.includes(providerId) + ) + ) + } + case 'tool': { + const integrationId = catalog.tools.get(canonical.id)?.integrationId + return ( + config.deniedTools.includes(canonical.id) || + Boolean(integrationId && integrationDenied(config, integrationId)) + ) + } + case 'knowledge_connector': + return ( + config.allowedKnowledgeConnectors !== null && + !config.allowedKnowledgeConnectors.includes(canonical.id) + ) + case 'file_share_auth': + return ( + config.allowedFileShareAuthTypes !== null && + !config.allowedFileShareAuthTypes.includes(canonical.id) + ) + case 'chat_deploy_auth': + return ( + config.allowedChatDeployAuthTypes !== null && + !config.allowedChatDeployAuthTypes.includes(canonical.id) + ) + case 'usage_limit': + throw new Error('Usage limit requests require a billing limit change') + } +} + +function allowIntegration(config: PermissionGroupConfig, id: string) { + const allowed = toAccessControlAllowlist(config.allowedIntegrations) + const canonical = normalizeIntegration(id) + if (allowed !== null && !allowed.has(canonical)) { + config.allowedIntegrations = [...allowed, canonical] + } +} + +/** + * Computes the complete group-policy change. Callers must display every change before applying it; + * enabling a model's provider, for example, also admits other models from that provider. + * Usage limits belong to billing and must never be fulfilled through a permission-group mutation. + */ +export function buildAccessRequestPolicyDelta( + target: AccessRequestTarget, + config: PermissionGroupConfig, + catalog: AccessRequestCatalog +): AccessRequestPolicyDelta { + const canonical = validateAccessRequestTarget(target, catalog) + const description = canonical && describeAccessRequestTarget(canonical, catalog) + if (!canonical || !description) throw new Error('Unknown access request target') + if (canonical.kind === 'usage_limit') { + throw new Error('Usage limit requests require a billing limit change') + } + const next = structuredClone(config) + for (const configKey of requiredFeatures(canonical, catalog)) next[configKey] = false + switch (canonical.kind) { + case 'feature': + break + case 'integration': + allowIntegration(next, canonical.id) + break + case 'provider': + next.allowedModelProviders = allowMember(next.allowedModelProviders, canonical.id) + break + case 'model': { + const model = catalog.models.get(canonical.id.toLowerCase()) + next.deniedModels = next.deniedModels.filter( + (id) => id.toLowerCase() !== canonical.id.toLowerCase() + ) + if (model?.providerId) { + next.allowedModelProviders = allowMember(next.allowedModelProviders, model.providerId) + } + break + } + case 'tool': { + const tool = catalog.tools.get(canonical.id) + next.deniedTools = next.deniedTools.filter((id) => id !== canonical.id) + if (tool?.integrationId) allowIntegration(next, tool.integrationId) + break + } + case 'knowledge_connector': + next.allowedKnowledgeConnectors = allowMember(next.allowedKnowledgeConnectors, canonical.id) + break + case 'file_share_auth': + next.allowedFileShareAuthTypes = allowMember(next.allowedFileShareAuthTypes, canonical.id) + break + case 'chat_deploy_auth': + next.allowedChatDeployAuthTypes = allowMember(next.allowedChatDeployAuthTypes, canonical.id) + break + } + const changes: AccessRequestPolicyChange[] = [] + for (const configKey of Object.keys(PERMISSION_GROUP_FIELDS) as PermissionGroupConfigKey[]) { + const before = config[configKey] + const after = next[configKey] + if (JSON.stringify(before) === JSON.stringify(after)) continue + const field = PERMISSION_GROUP_FIELDS[configKey] + const label = + field.kind === 'boolean-restriction' + ? field.feature.label + : LIST_FIELD_LABELS[configKey as keyof typeof LIST_FIELD_LABELS] + changes.push({ + configKey, + label, + before: structuredClone(before), + after: structuredClone(after), + }) + } + return { target: canonical, targetLabel: description.label, config: next, changes } +} diff --git a/apps/sim/lib/permission-groups/application/read-user-config.ts b/apps/sim/lib/permission-groups/application/read-user-config.ts new file mode 100644 index 00000000000..b2a7776aebd --- /dev/null +++ b/apps/sim/lib/permission-groups/application/read-user-config.ts @@ -0,0 +1,48 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { + isOrganizationPermissionRegimeActive, + resolveWorkspaceGroup, +} from '@/lib/permission-groups/resolve.server' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' + +/** + * permission-group-exempt: Members must be able to read their own restrictions. + */ +export const readUserPermissionConfigOperation = defineWorkspaceOperation({ + id: 'permission_groups.read_user_config', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + capability: 'none', +}) + +export const readUserPermissionConfig = defineAuthorizedWorkspaceUseCase({ + operation: readUserPermissionConfigOperation, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, context }) => { + const organizationId = context.workspaceOrganizationId + const [isOrgAdmin, entitled] = organizationId + ? await Promise.all([ + isOrganizationAdminOrOwner(principal.userId, organizationId), + isOrganizationPermissionRegimeActive(organizationId), + ]) + : [false, false] + const resolved = + organizationId && entitled + ? await resolveWorkspaceGroup(principal.userId, organizationId, context.workspaceId) + : null + + return { + permissionGroupId: resolved?.permissionGroupId ?? null, + groupName: resolved?.groupName ?? null, + config: resolved?.config ?? null, + entitled, + organizationId, + isOrgAdmin, + } + }, +}) diff --git a/apps/sim/lib/permission-groups/resolve.server.test.ts b/apps/sim/lib/permission-groups/resolve.server.test.ts index 786bd552044..0e313109396 100644 --- a/apps/sim/lib/permission-groups/resolve.server.test.ts +++ b/apps/sim/lib/permission-groups/resolve.server.test.ts @@ -5,13 +5,13 @@ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { DbOrTx } from '@/lib/db/types' -const { mockIsOrganizationOnEnterprisePlan, mockGetWorkspaceWithOwner } = vi.hoisted(() => ({ - mockIsOrganizationOnEnterprisePlan: vi.fn(), +const { mockIsOrganizationGovernanceActive, mockGetWorkspaceWithOwner } = vi.hoisted(() => ({ + mockIsOrganizationGovernanceActive: vi.fn(), mockGetWorkspaceWithOwner: vi.fn(), })) vi.mock('@/lib/billing/core/subscription', () => ({ - isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, + isOrganizationGovernanceActive: mockIsOrganizationGovernanceActive, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -37,12 +37,7 @@ const WORKSPACE_ID = 'workspace-1' * entitled" and these tests go red. */ function entitlementReadFails(): void { - mockIsOrganizationOnEnterprisePlan.mockImplementation( - async (_organizationId: string, onError?: string) => { - if (onError === 'throw') throw new Error('billing database unavailable') - return false - } - ) + mockIsOrganizationGovernanceActive.mockRejectedValue(new Error('billing database unavailable')) } describe('permission-group resolution under a failed entitlement read', () => { @@ -65,7 +60,7 @@ describe('permission-group resolution under a failed entitlement read', () => { await expect( resolveVerifiedUserAccessControlContext(USER_ID, WORKSPACE_ID, ORGANIZATION_ID) ).rejects.toThrow('billing database unavailable') - expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith(ORGANIZATION_ID, 'throw') + expect(mockIsOrganizationGovernanceActive).toHaveBeenCalledWith(ORGANIZATION_ID) }) it('rejects rather than resolving a null config from the workspace-lookup path', async () => { @@ -82,7 +77,7 @@ describe('permission-group resolution under a failed entitlement read', () => { await expect(getUserPermissionConfigForOrganization(ORGANIZATION_ID)).rejects.toThrow( 'billing database unavailable' ) - expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith(ORGANIZATION_ID, 'throw') + expect(mockIsOrganizationGovernanceActive).toHaveBeenCalledWith(ORGANIZATION_ID) }) /** @@ -91,7 +86,7 @@ describe('permission-group resolution under a failed entitlement read', () => { * inactive context. */ it('still resolves an inactive context when the organization is genuinely unentitled', async () => { - mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false) + mockIsOrganizationGovernanceActive.mockResolvedValue(false) await expect( resolveVerifiedUserAccessControlContext(USER_ID, WORKSPACE_ID, ORGANIZATION_ID) @@ -106,17 +101,13 @@ describe('permission-group resolution under a failed entitlement read', () => { it('rechecks entitlement on the caller transaction after an unentitled preflight', async () => { const executor = {} as DbOrTx - mockIsOrganizationOnEnterprisePlan.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + mockIsOrganizationGovernanceActive.mockResolvedValueOnce(false).mockResolvedValueOnce(true) await expect(isOrganizationPermissionRegimeActive(ORGANIZATION_ID)).resolves.toBe(false) await expect(isOrganizationPermissionRegimeActive(ORGANIZATION_ID, executor)).resolves.toBe( true ) - expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenLastCalledWith( - ORGANIZATION_ID, - 'throw', - executor - ) + expect(mockIsOrganizationGovernanceActive).toHaveBeenLastCalledWith(ORGANIZATION_ID, executor) }) it('propagates a transaction entitlement read failure instead of disabling restrictions', async () => { @@ -126,10 +117,6 @@ describe('permission-group resolution under a failed entitlement read', () => { await expect(isOrganizationPermissionRegimeActive(ORGANIZATION_ID, executor)).rejects.toThrow( 'billing database unavailable' ) - expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith( - ORGANIZATION_ID, - 'throw', - executor - ) + expect(mockIsOrganizationGovernanceActive).toHaveBeenCalledWith(ORGANIZATION_ID, executor) }) }) diff --git a/apps/sim/lib/permission-groups/resolve.server.ts b/apps/sim/lib/permission-groups/resolve.server.ts index c3844bf1c77..cc3ec9ef0f1 100644 --- a/apps/sim/lib/permission-groups/resolve.server.ts +++ b/apps/sim/lib/permission-groups/resolve.server.ts @@ -16,7 +16,7 @@ import { db } from '@sim/db' import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema' import { and, asc, eq, sql } from 'drizzle-orm' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isOrganizationGovernanceActive } from '@/lib/billing/core/subscription' import { getAllowedIntegrationsFromEnv, isAccessControlEnabled, @@ -99,7 +99,7 @@ function inactiveUserAccessControlContext(organizationId: string | null): UserAc * connection, and a default would let that caller silently check out a second * pooled connection while advisory locks are held. */ -async function resolveDefaultGroup( +export async function resolveDefaultGroup( organizationId: string, executor: DbOrTx ): Promise { @@ -155,9 +155,10 @@ async function resolveDefaultGroup( export async function resolveWorkspaceGroup( userId: string, organizationId: string, - workspaceId: string + workspaceId: string, + executor: DbOrTx = db ): Promise { - const rows = await db + const rows = await executor .select({ id: permissionGroup.id, name: permissionGroup.name, @@ -199,7 +200,7 @@ export async function resolveWorkspaceGroup( } } - return resolveDefaultGroup(organizationId, db) + return resolveDefaultGroup(organizationId, executor) } /** @@ -221,14 +222,13 @@ async function resolveUserAccessControlContextForOrganization( if (!organizationId) return inactiveUserAccessControlContext(null) /** - * `'throw'` because an unentitled organization resolves to `config: null`, - * and `null` is not a smaller permission set — it is *no* permission group at - * all: every capability allowed, every allowlist off. Under the lenient - * default a single subscription-read failure would be indistinguishable from - * a genuine plan lapse and would turn the whole regime off for the request. - * Throwing surfaces the outage as an error instead. + * The governance reader, not the feature gate: an unentitled organization resolves to + * `config: null`, and `null` is not a smaller permission set — it is *no* permission group at + * all: every capability allowed, every allowlist off. So neither a read failure nor a payment + * one may answer here; both would be indistinguishable from a genuine plan lapse and would lift + * the whole regime. It throws on the first and keeps governing through the second. */ - const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId, 'throw') + const isEnterprise = await isOrganizationGovernanceActive(organizationId) if (!isEnterprise) { return inactiveUserAccessControlContext(organizationId) } @@ -315,7 +315,7 @@ export async function getUserPermissionConfigForOrganization( * part of the entitlement cache key, so this read cannot reuse a preflight * result. A permission-group lock alone only serializes group writes. * - * `'throw'` for the same reason as in + * Reads governance rather than feature entitlement, for the same reason as in * {@link resolveUserAccessControlContextForOrganization}. */ export async function isOrganizationPermissionRegimeActive( @@ -324,8 +324,8 @@ export async function isOrganizationPermissionRegimeActive( ): Promise { if (!isHosted && !isAccessControlEnabled) return false return executor - ? isOrganizationOnEnterprisePlan(organizationId, 'throw', executor) - : isOrganizationOnEnterprisePlan(organizationId, 'throw') + ? isOrganizationGovernanceActive(organizationId, executor) + : isOrganizationGovernanceActive(organizationId) } /** diff --git a/apps/sim/lib/public-shares/share-manager.ts b/apps/sim/lib/public-shares/share-manager.ts index 52367728707..1d9cd3caa09 100644 --- a/apps/sim/lib/public-shares/share-manager.ts +++ b/apps/sim/lib/public-shares/share-manager.ts @@ -1,12 +1,5 @@ import { db } from '@sim/db' -import { - publicShare, - user, - type WorkspaceFileRow, - workspace, - workspaceFileColumns, - workspaceFiles, -} from '@sim/db/schema' +import { publicShare, user, type WorkspaceFileRow, workspace, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' @@ -276,7 +269,7 @@ export async function resolveActiveShareByToken(token: string): Promise facts.credentialType, +}) diff --git a/apps/sim/lib/resource-policies/conditions/registry.ts b/apps/sim/lib/resource-policies/conditions/registry.ts index 780dfded096..640a02936db 100644 --- a/apps/sim/lib/resource-policies/conditions/registry.ts +++ b/apps/sim/lib/resource-policies/conditions/registry.ts @@ -1,5 +1,6 @@ import { credentialGroupActorOwnsCredentialConditionDefinition } from '@/lib/resource-policies/conditions/credential-group-actor-owns-credential' import { credentialGroupOptionIdConditionDefinition } from '@/lib/resource-policies/conditions/credential-group-option' +import { credentialTypeConditionDefinition } from '@/lib/resource-policies/conditions/credential-type' import type { ResourcePolicyConditionDefinition, ResourcePolicyConditionKey, @@ -9,6 +10,7 @@ import { workflowModeResourcePolicyConditionDefinition } from '@/lib/resource-po export const RESOURCE_POLICY_CONDITION_DEFINITIONS = Object.freeze({ 'credential_group:ActorOwnsCredential': credentialGroupActorOwnsCredentialConditionDefinition, 'credential_group:OptionId': credentialGroupOptionIdConditionDefinition, + 'credential_group:CredentialType': credentialTypeConditionDefinition, 'execution:WorkflowMode': workflowModeResourcePolicyConditionDefinition, } as const satisfies Record) diff --git a/apps/sim/lib/resource-policies/conditions/types.ts b/apps/sim/lib/resource-policies/conditions/types.ts index d22c9397071..c2c9eb79974 100644 --- a/apps/sim/lib/resource-policies/conditions/types.ts +++ b/apps/sim/lib/resource-policies/conditions/types.ts @@ -3,6 +3,7 @@ export const RESOURCE_POLICY_CONDITION_OPERATORS = ['Bool', 'StringEquals'] as c export type ResourcePolicyConditionOperator = (typeof RESOURCE_POLICY_CONDITION_OPERATORS)[number] export interface ResourcePolicyConditionEvaluationFacts { + credentialType?: string credentialGroupActorEnrollmentId?: string credentialGroupCredentialEnrollmentId?: string /** The option the credential being accessed was collected under. */ @@ -36,6 +37,7 @@ export interface ResourcePolicyConditionDefinition { export type ResourcePolicyConditionKey = | 'credential_group:ActorOwnsCredential' | 'credential_group:OptionId' + | 'credential_group:CredentialType' | 'execution:WorkflowMode' export function defineResourcePolicyCondition( diff --git a/apps/sim/lib/resource-policies/registry.ts b/apps/sim/lib/resource-policies/registry.ts index 25070bcd4ab..32c42363bdc 100644 --- a/apps/sim/lib/resource-policies/registry.ts +++ b/apps/sim/lib/resource-policies/registry.ts @@ -21,6 +21,7 @@ export const RESOURCE_POLICY_DEFINITIONS = Object.freeze({ conditionKeys: [ 'credential_group:ActorOwnsCredential', 'credential_group:OptionId', + 'credential_group:CredentialType', 'execution:WorkflowMode', ], }, diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts index f8a41558c4b..e51805d591c 100644 --- a/apps/sim/lib/selectors/manifest.test.ts +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -9,8 +9,8 @@ describe('selector manifest', () => { const count = (classification: (typeof classifications)[number]) => classifications.filter((value) => value === classification).length - expect(Object.keys(selectorManifest)).toHaveLength(98) - expect(count('provider-server')).toBe(85) + expect(Object.keys(selectorManifest)).toHaveLength(107) + expect(count('provider-server')).toBe(94) expect(count('internal-server')).toBe(12) expect(count('local')).toBe(1) expect(classifications).not.toContain('provider-legacy') @@ -36,7 +36,7 @@ describe('selector manifest', () => { const rawConnectionKeys = providerKeys.filter( (key) => !serverSelectorRegistry[key as keyof typeof serverSelectorRegistry].credential ) - expect(providerKeys).toHaveLength(85) + expect(providerKeys).toHaveLength(94) expect(rawConnectionKeys.sort()).toEqual([ 'cloudwatch.logGroups', 'cloudwatch.logStreams', diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 6d65ad50ea5..7369e6f956a 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -125,6 +125,49 @@ export const selectorManifest = { any: ['folderId', 'spaceId', 'listSpaceId'], }, }), + 'coda.docs': providerSelector([], { + listMode: 'paginated', + search: true, + detail: true, + unknownDetail: true, + staleTime: SEARCH_SELECTOR_STALE_TIME, + }), + 'coda.pages': providerSelector(['docId'], { + readiness: { all: ['oauthCredential', 'docId'] }, + detail: true, + unknownDetail: true, + }), + 'coda.tables': providerSelector(['docId'], { + readiness: { all: ['oauthCredential', 'docId'] }, + detail: true, + unknownDetail: true, + }), + 'coda.columns': providerSelector(['docId', 'tableId'], { + readiness: { all: ['oauthCredential', 'docId', 'tableId'] }, + detail: true, + unknownDetail: true, + }), + 'coda.rows': providerSelector(['docId', 'tableId'], { + readiness: { all: ['oauthCredential', 'docId', 'tableId'] }, + listMode: 'paginated', + detail: true, + unknownDetail: true, + }), + 'coda.formulas': providerSelector(['docId'], { + readiness: { all: ['oauthCredential', 'docId'] }, + detail: true, + unknownDetail: true, + }), + 'coda.controls': providerSelector(['docId'], { + readiness: { all: ['oauthCredential', 'docId'] }, + detail: true, + unknownDetail: true, + }), + 'coda.folders': providerSelector([], { detail: true, unknownDetail: true }), + 'coda.permissions': providerSelector(['docId'], { + readiness: { all: ['oauthCredential', 'docId'] }, + detail: true, + }), 'confluence.spaces': providerSelector(['domain'], { readiness: { all: ['oauthCredential', 'domain'] }, listMode: 'paginated', diff --git a/apps/sim/lib/selectors/server/providers/coda.test.ts b/apps/sim/lib/selectors/server/providers/coda.test.ts new file mode 100644 index 00000000000..3aec6555415 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/coda.test.ts @@ -0,0 +1,179 @@ +/** + * @vitest-environment node + */ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetch, mockResolveCredentialBundle } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + mockResolveCredentialBundle: vi.fn(), +})) + +vi.mock('@/lib/selectors/server/providers/credential-bundle', () => ({ + resolveSelectorCredentialBundle: mockResolveCredentialBundle, +})) + +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { SelectorContextUnavailableError } from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { codaSelectorAttachments } from '@/lib/selectors/server/providers/coda' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' +import type { SelectorContext, SelectorRequest } from '@/lib/selectors/types' + +function args( + selectorKey: ServerSelectorKey, + request: SelectorRequest, + context: SelectorContext = {} +): ExecuteServerSelectorArgs { + return { + selectorKey, + context: { oauthCredential: 'credential-1', ...context }, + request, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + requesterUserId: 'user-1', + credential: { suppliedId: 'credential-1' }, + references: new Map(), + protectedValues: createSelectorProtectedValues(), + } +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }) +} + +describe('Coda server selector adapter', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + mockResolveCredentialBundle.mockResolvedValue({ accessToken: 'server-only-token' }) + }) + + afterAll(() => vi.unstubAllGlobals()) + + it('continues a doc search with only the Coda page token and returns the next cursor', async () => { + mockFetch.mockResolvedValueOnce( + json({ items: [{ id: 'doc1', name: 'Roadmap', owner: 'a@b.co' }], nextPageToken: 'tok2' }) + ) + + const result = await codaSelectorAttachments['coda.docs'].execute( + args('coda.docs', { kind: 'list', search: ' road ', cursor: 'tok1' }) + ) + + expect(result).toEqual({ + kind: 'list', + items: [{ id: 'doc1', label: 'Roadmap' }], + nextCursor: 'tok2', + }) + const [url, init] = mockFetch.mock.calls[0] + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(url).toBe('https://coda.io/apis/v1/docs?pageToken=tok1') + expect(init.headers).toEqual({ + Authorization: 'Bearer server-only-token', + Accept: 'application/json', + }) + }) + + it('sends the search term and page size on the first doc page', async () => { + mockFetch.mockResolvedValueOnce(json({ items: [] })) + + await codaSelectorAttachments['coda.docs'].execute( + args('coda.docs', { kind: 'list', search: ' road ' }) + ) + + expect(mockFetch.mock.calls[0][0]).toBe('https://coda.io/apis/v1/docs?limit=100&query=road') + }) + + it('reads every page of a doc-scoped list into one flat result', async () => { + mockFetch + .mockResolvedValueOnce( + json({ items: [{ id: 'grid-1', name: 'Tasks', tableType: 'table' }], nextPageToken: 'p2' }) + ) + .mockResolvedValueOnce(json({ items: [{ id: 'table-2', name: 'Open', tableType: 'view' }] })) + + const result = await codaSelectorAttachments['coda.tables'].execute( + args('coda.tables', { kind: 'list' }, { docId: 'AbCDeFGH' }) + ) + + expect(result).toEqual({ + kind: 'list', + items: [ + { id: 'grid-1', label: 'Tasks', meta: { tableType: 'table' } }, + { id: 'table-2', label: 'Open (view)', meta: { tableType: 'view' } }, + ], + }) + expect(mockFetch.mock.calls[1][0]).toBe( + 'https://coda.io/apis/v1/docs/AbCDeFGH/tables?pageToken=p2' + ) + }) + + it('scopes columns and rows to the selected doc and table', async () => { + mockFetch.mockResolvedValueOnce( + json({ items: [{ id: 'c-1', name: 'Status', format: { type: 'select', isArray: false } }] }) + ) + + await expect( + codaSelectorAttachments['coda.columns'].execute( + args('coda.columns', { kind: 'list' }, { docId: 'doc', tableId: 'grid 1' }) + ) + ).resolves.toEqual({ + kind: 'list', + items: [{ id: 'c-1', label: 'Status', meta: { formatType: 'select' } }], + }) + expect(mockFetch.mock.calls[0][0]).toBe( + 'https://coda.io/apis/v1/docs/doc/tables/grid%201/columns?limit=100' + ) + }) + + it('resolves a missing resource detail to no option', async () => { + mockFetch.mockResolvedValueOnce(json({ message: 'Not Found' }, 404)) + + await expect( + codaSelectorAttachments['coda.pages'].execute( + args('coda.pages', { kind: 'detail', id: 'canvas-gone' }, { docId: 'doc' }) + ) + ).resolves.toEqual({ kind: 'detail', item: null }) + expect(mockFetch.mock.calls[0][0]).toBe('https://coda.io/apis/v1/docs/doc/pages/canvas-gone') + }) + + it('labels permissions by principal and resolves details from the list', async () => { + mockFetch.mockResolvedValue( + json({ + items: [ + { id: 'perm-1', access: 'write', principal: { type: 'email', email: 'a@b.co' } }, + { id: 'perm-2', access: 'readonly', principal: { type: 'anyone' } }, + ], + }) + ) + + await expect( + codaSelectorAttachments['coda.permissions'].execute( + args('coda.permissions', { kind: 'detail', id: 'perm-2' }, { docId: 'doc' }) + ) + ).resolves.toEqual({ + kind: 'detail', + item: { + id: 'perm-2', + label: 'Anyone with the link (readonly)', + meta: { access: 'readonly', principalType: 'anyone' }, + }, + }) + }) + + it('rejects missing or traversal context before contacting Coda', async () => { + await expect( + codaSelectorAttachments['coda.pages'].execute(args('coda.pages', { kind: 'list' })) + ).rejects.toBeInstanceOf(SelectorContextUnavailableError) + await expect( + codaSelectorAttachments['coda.rows'].execute( + args('coda.rows', { kind: 'list' }, { docId: 'doc', tableId: '..' }) + ) + ).rejects.toBeInstanceOf(SelectorContextUnavailableError) + await expect( + codaSelectorAttachments['coda.docs'].execute( + args('coda.docs', { kind: 'list', cursor: 'bad token' }) + ) + ).rejects.toBeInstanceOf(SelectorContextUnavailableError) + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/coda.ts b/apps/sim/lib/selectors/server/providers/coda.ts new file mode 100644 index 00000000000..14db8f8e837 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/coda.ts @@ -0,0 +1,369 @@ +import { truncate } from '@sim/utils/string' +import { z } from 'zod' +import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { appendSelectorOptions } from '@/lib/selectors/server/option-budget' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { + fetchProviderJson, + fetchProviderJsonWithStatus, +} from '@/lib/selectors/server/providers/provider-http' +import { + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + type ServerSelectorAttachment, + type ServerSelectorAttachmentMap, + type ServerSelectorExecutionResult, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { buildCodaUrl, codaHeaders } from '@/tools/coda/utils' +import { safeUrlPathSegment } from '@/tools/url-path' + +type CodaSelectorKey = Extract + +const CODA_PAGE_SIZE = 100 +const CODA_MAX_FLAT_PAGES = 20 +const CODA_PAGE_TOKEN_PATTERN = /^[\x21-\x7e]{1,4096}$/ + +const namedItemSchema = z.object({ + id: z.string().min(1).max(512), + name: z.string().optional(), +}) + +const tableItemSchema = namedItemSchema.extend({ tableType: z.string().max(64).optional() }) + +const columnItemSchema = namedItemSchema.extend({ + format: z + .object({ type: z.string().max(64).optional() }) + .passthrough() + .optional(), +}) + +const permissionItemSchema = z.object({ + id: z.string().min(1).max(512), + access: z.string().max(64), + principal: z + .object({ + type: z.string().max(64).optional(), + email: z.string().max(1_024).optional(), + groupName: z.string().max(1_024).optional(), + domain: z.string().max(1_024).optional(), + workspaceId: z.string().max(512).optional(), + }) + .optional(), +}) + +function pageSchema(item: T) { + return z.object({ + items: z.array(item).max(1_000).optional(), + nextPageToken: z.string().max(4_096).optional(), + }) +} + +type NamedItem = z.infer +type TableItem = z.infer +type ColumnItem = z.infer +type PermissionItem = z.infer + +async function codaAccessToken(args: ExecuteServerSelectorArgs): Promise { + const { accessToken } = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + return accessToken +} + +/** Encodes a context value or requested id as one path segment, rejecting traversal input. */ +function segment(value: string | undefined, paramName: string): string { + const trimmed = value?.trim() + if (!trimmed) throw new SelectorContextUnavailableError() + try { + return safeUrlPathSegment(trimmed, paramName) + } catch { + throw new SelectorContextUnavailableError() + } +} + +function docPath(args: ExecuteServerSelectorArgs): string { + return `/docs/${segment(args.context.docId, 'docId')}` +} + +function tablePath(args: ExecuteServerSelectorArgs): string { + return `${docPath(args)}/tables/${segment(args.context.tableId, 'tableId')}` +} + +function requireCursor(cursor: string | undefined): string | undefined { + if (cursor === undefined) return undefined + if (!CODA_PAGE_TOKEN_PATTERN.test(cursor)) throw new SelectorContextUnavailableError() + return cursor +} + +async function fetchPage( + args: ExecuteServerSelectorArgs, + accessToken: string, + path: string, + schema: T, + query: Record +): Promise>>> { + const body = await fetchProviderJson(buildCodaUrl(path, query), { + headers: codaHeaders(accessToken), + signal: args.signal, + }) + const parsed = pageSchema(schema).safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return parsed.data +} + +/** Reads every page of a bounded Coda list into one flat option set. */ +async function listAllPages( + args: ExecuteServerSelectorArgs, + path: string, + schema: T, + toOption: (item: z.infer) => SafeSelectorOption +): Promise { + const accessToken = await codaAccessToken(args) + const options: SafeSelectorOption[] = [] + let pageToken: string | undefined + let truncated = false + + for (let page = 0; page < CODA_MAX_FLAT_PAGES; page++) { + const data = await fetchPage(args, accessToken, path, schema, { + limit: CODA_PAGE_SIZE, + pageToken, + }) + const appended = appendSelectorOptions(options, (data.items ?? []).map(toOption)) + pageToken = data.nextPageToken + if (!pageToken) { + if (appended.overflow) truncated = true + break + } + if (appended.full || page === CODA_MAX_FLAT_PAGES - 1) { + truncated = true + break + } + } + + return listSelectorResult( + options, + undefined, + truncated + ? { + truncated: { + reason: 'provider-cap', + limit: MAX_SELECTOR_OPTIONS, + pages: CODA_MAX_FLAT_PAGES, + }, + } + : undefined + ) +} + +/** Resolves one resource by id; a missing or deleted resource resolves to no option. */ +async function getDetail( + args: ExecuteServerSelectorArgs, + path: string, + schema: T, + toOption: (item: z.infer) => SafeSelectorOption +): Promise { + const accessToken = await codaAccessToken(args) + const result = await fetchProviderJsonWithStatus( + buildCodaUrl(path), + { headers: codaHeaders(accessToken), signal: args.signal }, + { passthroughStatuses: [404, 410] } + ) + if (!result.ok) return detailSelectorResult(null) + const parsed = schema.safeParse(result.data) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return detailSelectorResult(toOption(parsed.data)) +} + +function namedOption(item: NamedItem): SafeSelectorOption { + return { id: item.id, label: truncate(item.name?.trim() || item.id, 200) } +} + +function tableOption(item: TableItem): SafeSelectorOption { + const name = truncate(item.name?.trim() || item.id, 200) + return { + id: item.id, + label: item.tableType === 'view' ? `${name} (view)` : name, + ...(item.tableType ? { meta: { tableType: item.tableType } } : {}), + } +} + +function columnOption(item: ColumnItem): SafeSelectorOption { + const formatType = item.format?.type + return { + id: item.id, + label: truncate(item.name?.trim() || item.id, 200), + ...(formatType ? { meta: { formatType } } : {}), + } +} + +function permissionOption(item: PermissionItem): SafeSelectorOption { + const principal = item.principal + const who = + principal?.type === 'anyone' + ? 'Anyone with the link' + : principal?.email || + principal?.groupName || + principal?.domain || + principal?.workspaceId || + item.id + return { + id: item.id, + label: `${who} (${item.access})`, + meta: { access: item.access, ...(principal?.type ? { principalType: principal.type } : {}) }, + } +} + +/** + * Docs and rows can number in the thousands, so they page through the selector + * cursor instead of being read eagerly. Docs support Coda's server-side search. + */ +async function executeDocs(args: ExecuteServerSelectorArgs) { + if (args.request.kind === 'detail') { + return getDetail( + args, + `/docs/${segment(args.request.id, 'docId')}`, + namedItemSchema, + namedOption + ) + } + const accessToken = await codaAccessToken(args) + const data = await fetchPage(args, accessToken, '/docs', namedItemSchema, { + limit: CODA_PAGE_SIZE, + query: args.request.search?.trim() || undefined, + pageToken: requireCursor(args.request.cursor), + }) + return listSelectorResult((data.items ?? []).map(namedOption), data.nextPageToken) +} + +async function executeRows(args: ExecuteServerSelectorArgs) { + if (args.request.kind === 'detail') { + return getDetail( + args, + `${tablePath(args)}/rows/${segment(args.request.id, 'rowId')}`, + namedItemSchema, + namedOption + ) + } + const accessToken = await codaAccessToken(args) + const data = await fetchPage(args, accessToken, `${tablePath(args)}/rows`, namedItemSchema, { + limit: CODA_PAGE_SIZE, + pageToken: requireCursor(args.request.cursor), + }) + return listSelectorResult((data.items ?? []).map(namedOption), data.nextPageToken) +} + +function docScopedAttachment(input: { + collection: string + idParam: string + schema: T + toOption: (item: z.infer) => SafeSelectorOption + scope?: (args: ExecuteServerSelectorArgs) => string +}): ServerSelectorAttachment { + const scope = input.scope ?? docPath + return { + credential, + integrationBlockTypes, + destination: 'fixed', + execute: async (args) => + args.request.kind === 'detail' + ? getDetail( + args, + `${scope(args)}/${input.collection}/${segment(args.request.id, input.idParam)}`, + input.schema, + input.toOption + ) + : listAllPages(args, `${scope(args)}/${input.collection}`, input.schema, input.toOption), + } +} + +async function executePermissions(args: ExecuteServerSelectorArgs) { + const listed = await listAllPages( + args, + `${docPath(args)}/acl/permissions`, + permissionItemSchema, + permissionOption + ) + if (args.request.kind === 'list' || listed.kind !== 'list') return listed + const id = args.request.id + return { + ...detailSelectorResult(listed.items.find((item) => item.id === id) ?? null), + ...(listed.diagnostics ? { diagnostics: listed.diagnostics } : {}), + } +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['coda'], +} as const + +/** + * The integration this selector reaches. Declared rather than derived: Coda is an + * API-key integration with no entry in the deployment OAuth catalog, so its + * service id maps to no block type. + */ +const integrationBlockTypes = ['coda'] as const + +export const codaSelectorAttachments = { + 'coda.docs': { credential, integrationBlockTypes, destination: 'fixed', execute: executeDocs }, + 'coda.pages': docScopedAttachment({ + collection: 'pages', + idParam: 'pageId', + schema: namedItemSchema, + toOption: namedOption, + }), + 'coda.tables': docScopedAttachment({ + collection: 'tables', + idParam: 'tableId', + schema: tableItemSchema, + toOption: tableOption, + }), + 'coda.columns': docScopedAttachment({ + collection: 'columns', + idParam: 'columnId', + schema: columnItemSchema, + toOption: columnOption, + scope: tablePath, + }), + 'coda.rows': { credential, integrationBlockTypes, destination: 'fixed', execute: executeRows }, + 'coda.formulas': docScopedAttachment({ + collection: 'formulas', + idParam: 'formulaId', + schema: namedItemSchema, + toOption: namedOption, + }), + 'coda.controls': docScopedAttachment({ + collection: 'controls', + idParam: 'controlId', + schema: namedItemSchema, + toOption: namedOption, + }), + 'coda.folders': { + credential, + integrationBlockTypes, + destination: 'fixed', + execute: async (args) => + args.request.kind === 'detail' + ? getDetail( + args, + `/folders/${segment(args.request.id, 'folderId')}`, + namedItemSchema, + namedOption + ) + : listAllPages(args, '/folders', namedItemSchema, namedOption), + }, + 'coda.permissions': { + credential, + integrationBlockTypes, + destination: 'fixed', + execute: executePermissions, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/registry.ts b/apps/sim/lib/selectors/server/registry.ts index 09441960e09..d1574ae2d19 100644 --- a/apps/sim/lib/selectors/server/registry.ts +++ b/apps/sim/lib/selectors/server/registry.ts @@ -8,6 +8,7 @@ import { bitbucketSelectorAttachments } from '@/lib/selectors/server/providers/b import { calcomSelectorAttachments } from '@/lib/selectors/server/providers/calcom' import { clickupSelectorAttachments } from '@/lib/selectors/server/providers/clickup' import { cloudWatchSelectorAttachments } from '@/lib/selectors/server/providers/cloudwatch' +import { codaSelectorAttachments } from '@/lib/selectors/server/providers/coda' import { confluenceSelectorAttachments } from '@/lib/selectors/server/providers/confluence' import { githubSelectorAttachments } from '@/lib/selectors/server/providers/github' import { googleSelectorAttachments } from '@/lib/selectors/server/providers/google' @@ -45,6 +46,7 @@ export const serverSelectorRegistry = { ...calcomSelectorAttachments, ...clickupSelectorAttachments, ...cloudWatchSelectorAttachments, + ...codaSelectorAttachments, ...confluenceSelectorAttachments, ...googleSelectorAttachments, ...githubSelectorAttachments, diff --git a/apps/sim/lib/selectors/types.ts b/apps/sim/lib/selectors/types.ts index 2a20eac3cde..6d665f17c6f 100644 --- a/apps/sim/lib/selectors/types.ts +++ b/apps/sim/lib/selectors/types.ts @@ -16,6 +16,7 @@ export const selectorContextKeys = [ 'driveId', 'excludeWorkflowId', 'baseId', + 'docId', 'datasetId', 'serviceDeskId', 'impersonateUserEmail', diff --git a/apps/sim/lib/settings/application/organization-section-access.test.ts b/apps/sim/lib/settings/application/organization-section-access.test.ts index f425f2c3604..c8640ceffd2 100644 --- a/apps/sim/lib/settings/application/organization-section-access.test.ts +++ b/apps/sim/lib/settings/application/organization-section-access.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ canOpen: vi.fn(), enterprise: vi.fn(), + governance: vi.fn(), groups: vi.fn(), search: vi.fn(), })) @@ -21,6 +22,7 @@ vi.mock('@/lib/organizations/settings-access', () => ({ })) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mocks.enterprise, + isOrganizationGovernanceActive: mocks.governance, })) import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access' @@ -31,6 +33,7 @@ describe('organization settings authorization', () => { setEnvFlags({ isHosted: true, isBillingEnabled: true }) mocks.canOpen.mockResolvedValue(true) mocks.enterprise.mockResolvedValue(true) + mocks.governance.mockResolvedValue(true) mocks.groups.mockResolvedValue(true) mocks.search.mockResolvedValue(true) }) @@ -57,10 +60,53 @@ describe('organization settings authorization', () => { } ) + /** + * Access Control configures restrictions that keep applying while a payment is failing, so the + * page that edits them has to stay reachable — otherwise an organization is governed by rules + * nobody can see or loosen until the invoice clears. + */ + it('opens Access Control for an organization still being governed', async () => { + mocks.enterprise.mockResolvedValue(false) + mocks.governance.mockResolvedValue(true) + + await expect( + authorizeOrganizationSettingsSection({ + organizationId: 'target', + userId: 'viewer', + section: 'access-control', + }) + ).resolves.toBe(true) + }) + + it('closes Access Control once nothing governs the organization', async () => { + mocks.enterprise.mockResolvedValue(false) + mocks.governance.mockResolvedValue(false) + + await expect( + authorizeOrganizationSettingsSection({ + organizationId: 'target', + userId: 'viewer', + section: 'access-control', + }) + ).resolves.toBe(false) + }) + + /** Every other section keeps reading the plan gate, and pays no extra lookup for this one. */ + it('reads governance for no section but Access Control', async () => { + await authorizeOrganizationSettingsSection({ + organizationId: 'target', + userId: 'viewer', + section: 'audit-logs', + }) + + expect(mocks.governance).not.toHaveBeenCalled() + expect(mocks.enterprise).toHaveBeenCalledWith('target') + }) + it.each([ { groups: false, search: false, connectedAccounts: false, integrations: false }, { groups: true, search: false, connectedAccounts: true, integrations: false }, - { groups: true, search: true, connectedAccounts: false, integrations: true }, + { groups: true, search: true, connectedAccounts: true, integrations: true }, ])( 'selects the setup page with groups=$groups and search=$search', async ({ groups, search, connectedAccounts, integrations }) => { @@ -92,7 +138,7 @@ describe('organization settings authorization', () => { } ) - it('propagates Search availability failures instead of selecting the old UI', async () => { + it('keeps Credential Groups independent of Search availability', async () => { mocks.search.mockRejectedValue(new Error('Feature configuration unavailable')) await expect( authorizeOrganizationSettingsSection({ @@ -100,7 +146,8 @@ describe('organization settings authorization', () => { userId: 'admin', section: 'connected-accounts', }) - ).rejects.toThrow('Feature configuration unavailable') + ).resolves.toBe(true) + expect(mocks.search).not.toHaveBeenCalled() }) it('checks current target organization membership before billing reads', async () => { diff --git a/apps/sim/lib/settings/application/organization-section-access.ts b/apps/sim/lib/settings/application/organization-section-access.ts index bf762ea9e8a..6e1df504b5b 100644 --- a/apps/sim/lib/settings/application/organization-section-access.ts +++ b/apps/sim/lib/settings/application/organization-section-access.ts @@ -8,6 +8,7 @@ import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' +import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server' interface AuthorizeOrganizationSettingsSectionInput { organizationId: string @@ -24,21 +25,27 @@ export async function authorizeOrganizationSettingsSection({ if (!(await canOpenOrganizationSettingsSection(organizationId, userId, section))) return false if (section === 'connected-accounts') { - if (!(await isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }))) - return false - return !(await isKnowledgeMemberAccessAvailable({ organizationId })) + return isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }) } if (section === 'search-mcp' || section === 'search-slack' || section === 'integrations') return isKnowledgeMemberAccessAvailable({ organizationId }) const deployment = getDeploymentShape() const needsEnterprisePlan = deployment.hosted && section !== 'members' && section !== 'billing' - const hasEnterprisePlan = needsEnterprisePlan - ? await isOrganizationOnEnterprisePlan(organizationId) - : false + /** + * Access Control's availability follows the permission regime rather than the plan gate, and no + * other section reads it — so each section pays for exactly one of the two lookups. + */ + const readsRegime = needsEnterprisePlan && section === 'access-control' + const [hasEnterprisePlan, governanceActive] = await Promise.all([ + needsEnterprisePlan && !readsRegime + ? isOrganizationOnEnterprisePlan(organizationId) + : Promise.resolve(false), + readsRegime ? isOrganizationPermissionRegimeActive(organizationId) : Promise.resolve(false), + ]) return isOrganizationSettingsSectionAvailable( section, - getOrganizationSettingsFeatures(hasEnterprisePlan, deployment) + getOrganizationSettingsFeatures(hasEnterprisePlan, deployment, governanceActive) ) } diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index a8ca619c801..f1378edf865 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => ({ isScopedCredentialGroupsAvailable: vi.fn(), isKnowledgeMemberAccessAvailable: vi.fn(), isPlatformAdmin: vi.fn(), + isAccessRequestEnabled: vi.fn(), resolveVerifiedUserAccessControlContext: vi.fn(), resolveWorkspaceNavigation: vi.fn(), })) @@ -56,6 +57,10 @@ vi.mock('@/components/settings/navigation', () => ({ workspaceSectionUsesPermissionConfig: vi.fn((section: string) => ['secrets', 'api-keys', 'inbox', 'mcp', 'custom-tools'].includes(section) ), + WORKSPACE_PERMISSION_CONFIG_KEYS: { secrets: 'hideSecretsTab' }, +})) +vi.mock('@/lib/permission-access-requests/settings', () => ({ + isAccessRequestEnabled: mocks.isAccessRequestEnabled, })) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mocks.isOrganizationOnEnterprisePlan, @@ -69,6 +74,10 @@ vi.mock('@/lib/credential-groups/scoped-availability', () => ({ vi.mock('@/lib/knowledge/access/availability', () => ({ isKnowledgeMemberAccessAvailable: mocks.isKnowledgeMemberAccessAvailable, })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + /** Access Control follows the regime; these tests drive it from the same plan knob. */ + isOrganizationPermissionRegimeActive: mocks.isOrganizationOnEnterprisePlan, +})) vi.mock('@/lib/organizations/settings-access', () => ({ canOpenOrganizationSettingsSection: mocks.canOpenOrganizationSettingsSection, })) @@ -127,6 +136,7 @@ describe('authorizeWorkspaceSettingsSection', () => { mocks.isScopedCredentialGroupsAvailable.mockResolvedValue(true) mocks.isKnowledgeMemberAccessAvailable.mockResolvedValue(false) mocks.isPlatformAdmin.mockResolvedValue(true) + mocks.isAccessRequestEnabled.mockResolvedValue(false) mocks.canOpenOrganizationSettingsSection.mockResolvedValue(true) mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ config: {} }) mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'secrets' }]) @@ -196,6 +206,56 @@ describe('authorizeWorkspaceSettingsSection', () => { ) }) + it.each([true, false])( + 'offers a request-only page when requests are enabled=%s', + async (enabled) => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + config: { hideSecretsTab: true }, + }) + mocks.resolveWorkspaceNavigation.mockImplementation(({ permissionConfig }) => + permissionConfig.hideSecretsTab ? [] : [{ id: 'secrets' }] + ) + mocks.isAccessRequestEnabled.mockResolvedValue(enabled) + + await expect(authorize('secrets')).resolves.toEqual( + enabled + ? { allowed: false, disposition: 'request-access', configKey: 'hideSecretsTab' } + : { allowed: false, disposition: 'redirect-general' } + ) + expect(mocks.isAccessRequestEnabled).toHaveBeenCalledWith('organization-1') + } + ) + + it('keeps deployment and role exclusions when considering a permission request', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + config: { hideSecretsTab: true }, + }) + mocks.resolveWorkspaceNavigation.mockReturnValue([]) + mocks.isAccessRequestEnabled.mockResolvedValue(true) + + await expect(authorize('secrets')).resolves.toEqual({ + allowed: false, + disposition: 'redirect-general', + }) + expect(mocks.isAccessRequestEnabled).not.toHaveBeenCalled() + }) + + it('does not offer organization requests for personal workspace restrictions', async () => { + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + config: { hideSecretsTab: true }, + }) + mocks.resolveWorkspaceNavigation.mockImplementation(({ permissionConfig }) => + permissionConfig.hideSecretsTab ? [] : [{ id: 'secrets' }] + ) + await expect(authorize('secrets')).resolves.toEqual({ + allowed: false, + disposition: 'redirect-general', + }) + expect(mocks.isAccessRequestEnabled).not.toHaveBeenCalled() + }) + it('enforces canonical permission config independently of billing subscription state', async () => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ @@ -221,7 +281,27 @@ describe('authorizeWorkspaceSettingsSection', () => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) await expect(authorize('access-control')).resolves.toEqual({ allowed: true }) - expect(mocks.getOrganizationSettingsFeatures).toHaveBeenCalledWith(true, mocks.deploymentShape) + /** + * Access Control is gated on the permission regime rather than the plan, so the plan lookup is + * skipped for it and the regime is what reaches the navigation gate. + */ + expect(mocks.getOrganizationSettingsFeatures).toHaveBeenCalledWith( + false, + mocks.deploymentShape, + true + ) + }) + + /** + * The workspace-scoped page reads the same regime as the organization one: an organization whose + * restrictions still apply during a failing payment must not have this page taken away. + */ + it('keeps the workspace Access Control page open while the organization is governed', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.isOrganizationOnEnterprisePlan.mockResolvedValue(false) + + await expect(authorize('access-control')).resolves.toEqual({ allowed: true }) + expect(mocks.isOrganizationOnEnterprisePlan).toHaveBeenCalledTimes(1) }) it('resolves the exact entitlement source only for gated workspace sections', async () => { @@ -280,10 +360,10 @@ describe('authorizeWorkspaceSettingsSection', () => { it.each([ { groups: true, search: false, allowed: true }, { groups: false, search: false, allowed: false }, - { groups: true, search: true, allowed: false }, + { groups: true, search: true, allowed: true }, { groups: false, search: true, allowed: false }, ])( - 'gates Connected accounts with organization groups=$groups and search=$search', + 'gates Credential Groups with organization groups=$groups and search=$search', async ({ groups, search, allowed }) => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) mocks.isScopedCredentialGroupsAvailable.mockResolvedValue(groups) @@ -301,11 +381,7 @@ describe('authorizeWorkspaceSettingsSection', () => { kind: 'organization', organizationId: 'organization-1', }) - if (groups) { - expect(mocks.isKnowledgeMemberAccessAvailable).toHaveBeenCalledWith({ - organizationId: 'organization-1', - }) - } + expect(mocks.isKnowledgeMemberAccessAvailable).not.toHaveBeenCalled() expect(mocks.isOrganizationOnEnterprisePlan).not.toHaveBeenCalled() } ) @@ -329,9 +405,9 @@ describe('authorizeWorkspaceSettingsSection', () => { expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() }) - it('propagates feature lookup failures instead of opening Connected accounts', async () => { + it('propagates feature lookup failures instead of opening Credential Groups', async () => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) - mocks.isKnowledgeMemberAccessAvailable.mockRejectedValue(new Error('Feature lookup failed')) + mocks.isScopedCredentialGroupsAvailable.mockRejectedValue(new Error('Feature lookup failed')) await expect(authorize('connected-accounts')).rejects.toThrow('Feature lookup failed') }) diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index daa8201117b..01cca335dfe 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -5,12 +5,16 @@ import { UNIFIED_TO_ORGANIZATION_SECTION, UNIFIED_TO_WORKSPACE_SECTION, type UnifiedSettingsSection, + WORKSPACE_PERMISSION_CONFIG_KEYS, type WorkspaceSettingsSection, workspaceSectionUsesPermissionConfig, } from '@/components/settings/navigation' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' +import { isAccessRequestEnabled } from '@/lib/permission-access-requests/settings' +import type { BooleanPermissionGroupConfigKey } from '@/lib/permission-groups/features' +import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server' import { isPlatformAdmin } from '@/lib/permissions/super-user' import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access' import { isCustomBlocksEligibleForOrganization } from '@/lib/workflows/custom-blocks/operations' @@ -21,6 +25,7 @@ import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/linea export type WorkspaceSettingsSectionAccess = | { allowed: true } | { allowed: false; disposition: 'not-found' | 'redirect-general' } + | { allowed: false; disposition: 'request-access'; configKey: BooleanPermissionGroupConfigKey } interface AuthorizeWorkspaceSettingsSectionInput { workspaceId: string @@ -28,14 +33,14 @@ interface AuthorizeWorkspaceSettingsSectionInput { section: UnifiedSettingsSection } -async function canOpenWorkspaceSection( +async function authorizeWorkspaceSection( section: WorkspaceSettingsSection, input: AuthorizeWorkspaceSettingsSectionInput, workspace: { organizationId: string | null }, permission: NonNullable>['permission']> -): Promise { +): Promise { const [accessControl, forksAvailable, customBlocksAvailable] = await Promise.all([ workspaceSectionUsesPermissionConfig(section) ? resolveVerifiedUserAccessControlContext( @@ -53,7 +58,7 @@ async function canOpenWorkspaceSection( ]) const deployment = getDeploymentShape() - const navigation = resolveWorkspaceNavigation({ + const navigationOptions = { permission, permissionConfig: accessControl?.config ?? {}, deployment, @@ -63,8 +68,25 @@ async function canOpenWorkspaceSection( forks: forksAvailable, sandboxes: true, }, - }) - return navigation.some((item) => item.id === section) + } + if (resolveWorkspaceNavigation(navigationOptions).some((item) => item.id === section)) { + return { allowed: true } + } + + const configKey = WORKSPACE_PERMISSION_CONFIG_KEYS[section] + if ( + configKey && + accessControl?.config?.[configKey] && + workspace.organizationId && + resolveWorkspaceNavigation({ + ...navigationOptions, + permissionConfig: { ...navigationOptions.permissionConfig, [configKey]: false }, + }).some((item) => item.id === section) && + (await isAccessRequestEnabled(workspace.organizationId)) + ) { + return { allowed: false, disposition: 'request-access', configKey } + } + return { allowed: false, disposition: 'redirect-general' } } async function canOpenOrganizationSection( @@ -93,17 +115,26 @@ async function canOpenOrganizationSection( } const needsEnterprisePlan = organizationSection !== 'members' && organizationSection !== 'billing' - const [canOpenSection, isEnterpriseOrganization] = await Promise.all([ + /** Same split as the organization surface: Access Control follows the regime, everything else the plan. */ + const readsRegime = needsEnterprisePlan && organizationSection === 'access-control' + const [canOpenSection, isEnterpriseOrganization, governanceActive] = await Promise.all([ canOpenOrganizationSettingsSection(workspace.organizationId, input.userId, organizationSection), - needsEnterprisePlan + needsEnterprisePlan && !readsRegime ? isOrganizationOnEnterprisePlan(workspace.organizationId) : Promise.resolve(false), + readsRegime + ? isOrganizationPermissionRegimeActive(workspace.organizationId) + : Promise.resolve(false), ]) return ( canOpenSection && isOrganizationSettingsSectionAvailable( organizationSection, - getOrganizationSettingsFeatures(needsEnterprisePlan && isEnterpriseOrganization, deployment) + getOrganizationSettingsFeatures( + needsEnterprisePlan && isEnterpriseOrganization, + deployment, + governanceActive + ) ) ) } @@ -124,11 +155,14 @@ export async function authorizeWorkspaceSettingsSection( } const workspaceSection = UNIFIED_TO_WORKSPACE_SECTION[input.section] - if ( - workspaceSection && - !(await canOpenWorkspaceSection(workspaceSection, input, access.workspace, access.permission)) - ) { - return { allowed: false, disposition: 'redirect-general' } + if (workspaceSection) { + const sectionAccess = await authorizeWorkspaceSection( + workspaceSection, + input, + access.workspace, + access.permission + ) + if (!sectionAccess.allowed) return sectionAccess } if (!(await canOpenOrganizationSection(input, access.workspace))) { return { allowed: false, disposition: 'redirect-general' } diff --git a/apps/sim/lib/slack-search/assistant-stream.test.ts b/apps/sim/lib/slack-search/assistant-stream.test.ts index 5a3b53ce65d..0aea1374e2b 100644 --- a/apps/sim/lib/slack-search/assistant-stream.test.ts +++ b/apps/sim/lib/slack-search/assistant-stream.test.ts @@ -36,10 +36,16 @@ beforeEach(() => { api.project.mockImplementation((value: unknown) => ({ safe: true, value })) }) +function deliveredChunks() { + return [ + ...api.start.mock.calls.flatMap((call) => call[2]), + ...api.append.mock.calls.flatMap((call) => call[3]), + ] +} + function deliveredText() { - return api.append.mock.calls - .flatMap((call) => call[3]) - .map((chunk) => chunk.text) + return deliveredChunks() + .flatMap((chunk) => (chunk.type === 'markdown_text' ? [chunk.text] : [])) .join('') } @@ -111,6 +117,301 @@ function toolResult( } } +describe('Slack lazy stream lifecycle', () => { + it('uses native processing status until public content is ready', async () => { + const { stream, controller } = setup() + await stream.start() + expect(api.status).toHaveBeenCalledExactlyOnceWith( + 'test-token', + { channel: 'D1', threadTs: '1.1', initiatorUserId: 'U1' }, + 'processing', + controller.signal + ) + expect(api.start).not.toHaveBeenCalled() + await stream.onEvent({ type: 'text', payload: { channel: 'thinking', text: 'private' } }) + await stream.onEvent({ + type: 'text', + payload: { channel: 'assistant', text: 'private' }, + scope: { lane: 'subagent', agentId: 'child' }, + }) + for (const text of ['', ' \n', 'private', '{"id":"unverified"}' }, + }) + expect(api.start).not.toHaveBeenCalled() + await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'Answer.' } }) + await stream.finish(result) + expect(api.start).toHaveBeenCalledOnce() + expect(deliveredText()).toBe(' \nAnswer.') + expect(api.stop).toHaveBeenCalledOnce() + }) + + it('preserves whitespace and starts with the first safe text without waiting for a timer', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1000) + try { + const { stream } = setup() + await stream.start() + for (const text of ['', ' ', '\n', 'Hello']) { + await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text } }) + expect(api.start).not.toHaveBeenCalled() + } + await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: ' ' } }) + expect(api.start).toHaveBeenCalledOnce() + expect(deliveredText()).toBe(' \nHello ') + expect(api.append).not.toHaveBeenCalled() + await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'world. ' } }) + expect(api.append).not.toHaveBeenCalled() + vi.mocked(Date.now).mockReturnValue(1750) + await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'Next ' } }) + expect(api.append).toHaveBeenCalledOnce() + expect(deliveredText()).toBe(' \nHello world. Next ') + await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'line.' } }) + await stream.finish(result) + expect(deliveredText()).toBe(' \nHello world. Next line.') + expect(api.start).toHaveBeenCalledOnce() + } finally { + vi.restoreAllMocks() + } + }) + + it('includes a long whitespace prefix in the same start request as meaningful text', async () => { + const { stream } = setup() + await stream.start() + const text = `${' '.repeat(4001)}Answer. ` + await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text } }) + expect(api.start).toHaveBeenCalledOnce() + expect(api.start.mock.calls[0][2]).toEqual([ + { type: 'markdown_text', text: ' '.repeat(4000) }, + { type: 'markdown_text', text: ' Answer. ' }, + ]) + expect(deliveredText()).toBe(text) + }) + + it('shows tool progress immediately during tool latency, even before any answer text', async () => { + const { stream } = setup() + await stream.start() + await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: '\n' } }) + expect(api.start).not.toHaveBeenCalled() + await stream.onEvent(toolCall()) + expect(api.start).toHaveBeenCalledOnce() + expect(api.start.mock.calls[0][2]).toEqual([ + { type: 'markdown_text', text: '\n' }, + { type: 'markdown_text', text: '\n\n' }, + { + type: 'task_update', + id: expect.any(String), + title: 'Searching documents…', + status: 'in_progress', + }, + ]) + expect(api.append).not.toHaveBeenCalled() + expect(api.stop).not.toHaveBeenCalled() + await stream.onEvent(toolResult()) + await stream.finish(result) + expect(deliveredChunks().at(-1)).toEqual({ + ...api.start.mock.calls[0][2][2], + status: 'complete', + }) + expect(api.stop).toHaveBeenCalledOnce() + }) + + it.each(['', ' \n\t', 'private', 'https://unverified.test '])( + 'settles an answer with no public text without creating a blank reply: %j', + async (text) => { + const { stream, controller } = setup() + await stream.start() + await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text } }) + await stream.finish(result) + await stream.terminateAfterFailure() + expect(api.start).not.toHaveBeenCalled() + expect(api.append).not.toHaveBeenCalled() + expect(api.stop).not.toHaveBeenCalled() + expect(api.status).toHaveBeenCalledTimes(2) + expect(api.status).toHaveBeenLastCalledWith( + 'test-token', + { channel: 'D1', threadTs: '1.1' }, + 'active', + controller.signal + ) + } + ) + + it('rejects content and completion after Stop before the first visible chunk', async () => { + const { stream, controller } = setup() + await stream.start() + controller.abort(new Error('stopped')) + await expect( + stream.onEvent({ + type: 'text', + payload: { channel: 'assistant', text: 'Late answer. ' }, + }) + ).rejects.toThrow('stopped') + await expect(stream.finish(result)).rejects.toThrow('stopped') + expect(api.start).not.toHaveBeenCalled() + expect(api.append).not.toHaveBeenCalled() + expect(api.stop).not.toHaveBeenCalled() + }) + + it.each(['confirmed', 'thrown'])( + 'delivers a %s failure before content once and ends processing', + async (kind) => { + const { stream, controller, beforeCleanup } = setup() + await stream.start() + if (kind === 'thrown') { + controller.abort(new Error('private backend error')) + await stream.terminateAfterFailure() + } else { + await stream.finishWithError() + } + await stream.terminateAfterFailure() + expect(api.start).toHaveBeenCalledOnce() + expect(deliveredText()).toBe('I couldn’t complete this search. Please try again.') + expect(api.append).not.toHaveBeenCalled() + expect(api.stop).toHaveBeenCalledExactlyOnceWith( + 'test-token', + 'D1', + '1.2', + 'active', + expect.any(AbortSignal), + [], + [] + ) + const signal = api.start.mock.calls[0][4] + expect(signal.aborted).toBe(false) + if (kind === 'thrown') { + expect(signal).not.toBe(controller.signal) + expect(beforeCleanup).toHaveBeenCalledExactlyOnceWith(signal) + } + } + ) + + it.each(['confirmed', 'thrown'])( + 'ends processing when a %s failure notification fails without replaying it', + async (kind) => { + const { stream, controller, beforeCleanup } = setup() + await stream.start() + api.start.mockRejectedValueOnce(new Error('failure response lost')) + if (kind === 'thrown') { + controller.abort(new Error('private backend error')) + await expect(stream.terminateAfterFailure()).rejects.toThrow('failure response lost') + } else { + await expect(stream.finishWithError()).rejects.toThrow('failure response lost') + } + expect(api.status).toHaveBeenCalledTimes(2) + expect(api.status).toHaveBeenLastCalledWith( + 'test-token', + { channel: 'D1', threadTs: '1.1' }, + 'active', + api.start.mock.calls[0][4] + ) + if (kind === 'thrown') { + expect(api.start.mock.calls[0][4]).not.toBe(controller.signal) + expect(beforeCleanup).toHaveBeenCalledExactlyOnceWith(api.start.mock.calls[0][4]) + } + await stream.terminateAfterFailure() + expect(api.status).toHaveBeenCalledTimes(2) + expect(api.start).toHaveBeenCalledOnce() + expect(api.append).not.toHaveBeenCalled() + expect(api.stop).not.toHaveBeenCalled() + } + ) + + it('cleans up with fresh authority if the failure notification is aborted before settling', async () => { + const { stream, controller, beforeCleanup } = setup() + await stream.start() + api.start.mockImplementationOnce(async () => { + controller.abort(new Error('deadline exceeded')) + throw controller.signal.reason + }) + await expect(stream.finishWithError()).rejects.toThrow('deadline exceeded') + expect(api.status).toHaveBeenCalledOnce() + await stream.terminateAfterFailure() + await stream.terminateAfterFailure() + expect(api.start).toHaveBeenCalledOnce() + expect(api.stop).not.toHaveBeenCalled() + expect(api.status).toHaveBeenCalledTimes(2) + const cleanupSignal = api.status.mock.calls[1][3] + expect(cleanupSignal).not.toBe(controller.signal) + expect(cleanupSignal.aborted).toBe(false) + expect(beforeCleanup).toHaveBeenCalledExactlyOnceWith(cleanupSignal) + expect(api.status).toHaveBeenLastCalledWith( + 'test-token', + { channel: 'D1', threadTs: '1.1' }, + 'active', + cleanupSignal + ) + }) + + it.each([false, true])( + 'attempts status cleanup once after both notification and status fail (cleanup fails: %s)', + async (cleanupFails) => { + const { stream, controller, beforeCleanup } = setup() + await stream.start() + api.start.mockRejectedValueOnce(new Error('notification response lost')) + api.status.mockRejectedValueOnce(new Error('status response lost')) + await expect(stream.finishWithError()).rejects.toThrow('status response lost') + expect(controller.signal.aborted).toBe(true) + if (cleanupFails) { + api.status.mockRejectedValueOnce(new Error('cleanup response lost')) + await expect(stream.terminateAfterFailure()).rejects.toThrow('cleanup response lost') + } else { + await stream.terminateAfterFailure() + } + await stream.terminateAfterFailure() + expect(api.status).toHaveBeenCalledTimes(3) + const cleanupSignal = api.status.mock.calls[2][3] + expect(cleanupSignal).not.toBe(controller.signal) + expect(cleanupSignal.aborted).toBe(false) + expect(beforeCleanup).toHaveBeenCalledExactlyOnceWith(cleanupSignal) + expect(beforeCleanup.mock.invocationCallOrder[0]).toBeLessThan( + api.status.mock.invocationCallOrder[2] + ) + expect(api.status).toHaveBeenLastCalledWith( + 'test-token', + { channel: 'D1', threadTs: '1.1' }, + 'active', + cleanupSignal + ) + expect(api.start).toHaveBeenCalledOnce() + expect(api.append).not.toHaveBeenCalled() + expect(api.stop).not.toHaveBeenCalled() + } + ) + + it('requires fresh authority before retrying a failed status reset', async () => { + const { stream, beforeCleanup } = setup() + await stream.start() + api.start.mockRejectedValueOnce(new Error('notification response lost')) + api.status.mockRejectedValueOnce(new Error('status response lost')) + await expect(stream.finishWithError()).rejects.toThrow('status response lost') + beforeCleanup.mockRejectedValueOnce(new Error('authority revoked')) + await expect(stream.terminateAfterFailure()).rejects.toThrow('authority revoked') + await stream.terminateAfterFailure() + expect(api.status).toHaveBeenCalledTimes(2) + expect(api.start).toHaveBeenCalledOnce() + expect(api.stop).not.toHaveBeenCalled() + }) + + it('propagates an empty-run status failure and cleans up without posting a reply', async () => { + const { stream, controller } = setup() + await stream.start() + api.status.mockRejectedValueOnce(new Error('status response lost')) + await expect(stream.finish(result)).rejects.toThrow('status response lost') + expect(controller.signal.aborted).toBe(true) + await stream.terminateAfterFailure() + await stream.terminateAfterFailure() + expect(api.status).toHaveBeenCalledTimes(3) + expect(api.start).not.toHaveBeenCalled() + }) +}) + describe('Slack tool progress', () => { it('preserves task positions when secret projection defers delivery until completion', async () => { const { stream, registry } = setup() @@ -133,9 +434,10 @@ describe('Slack tool progress', () => { type: 'text', payload: { channel: 'assistant', text: 'Found a result.' }, }) + expect(api.start).not.toHaveBeenCalled() expect(api.append).not.toHaveBeenCalled() await stream.finish(result) - const chunks = api.append.mock.calls.flatMap((call) => call[3]) + const chunks = deliveredChunks() expect(chunks).toEqual([ { type: 'markdown_text', text: 'Checking [REDACTED_SECRET].\n\n' }, { @@ -147,7 +449,7 @@ describe('Slack tool progress', () => { { type: 'task_update', id: chunks[1].id, title: 'Searching documents…', status: 'complete' }, { type: 'markdown_text', text: 'Found a result.' }, ]) - expect(JSON.stringify(api.append.mock.calls)).not.toContain('private-token') + expect(JSON.stringify(deliveredChunks())).not.toContain('private-token') }) it('withholds tasks and following text until preceding citation evidence arrives', async () => { @@ -165,9 +467,7 @@ describe('Slack tool progress', () => { type: 'text', payload: { channel: 'assistant', text: 'Found a result. ' }, }) - expect(api.append.mock.calls.flatMap((call) => call[3])).toEqual([ - { type: 'markdown_text', text: 'Checking ' }, - ]) + expect(deliveredChunks()).toEqual([{ type: 'markdown_text', text: 'Checking ' }]) const completed = toolResult('search_workspace') await stream.onEvent({ ...completed, @@ -186,7 +486,7 @@ describe('Slack tool progress', () => { }, }, }) - const chunks = api.append.mock.calls.flatMap((call) => call[3]) + const chunks = deliveredChunks() expect(chunks).toEqual([ { type: 'markdown_text', text: 'Checking ' }, { type: 'markdown_text', text: '[Policy]() for details.\n\n' }, @@ -200,7 +500,7 @@ describe('Slack tool progress', () => { { type: 'markdown_text', text: 'Found a result. ' }, ]) await stream.finish(result) - expect(api.append.mock.calls.flatMap((call) => call[3])).toEqual(chunks) + expect(deliveredChunks()).toEqual(chunks) }) it('rejects a tool boundary whose prefix is unsafe in the complete secret projection', async () => { @@ -220,6 +520,7 @@ describe('Slack tool progress', () => { await expect(stream.finish(result)).rejects.toThrow( 'The safe answer changed at a tool boundary' ) + expect(api.start).not.toHaveBeenCalled() expect(api.append).not.toHaveBeenCalled() }) @@ -231,16 +532,17 @@ describe('Slack tool progress', () => { await stream.start() await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'Checking.' } }) await stream.onEvent(toolCall('search_workspace')) + expect(api.start).not.toHaveBeenCalled() expect(api.append).not.toHaveBeenCalled() - api.append.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('response lost')) + api.append.mockRejectedValueOnce(new Error('response lost')) await expect(stream.finish(result)).rejects.toThrow('response lost') expect(controller.signal.aborted).toBe(true) await stream.terminateAfterFailure() await stream.terminateAfterFailure() - expect(api.append).toHaveBeenCalledTimes(2) + expect(api.append).toHaveBeenCalledOnce() expect(api.stop).toHaveBeenCalledOnce() expect(api.stop.mock.calls[0][6]).toEqual([ - { ...api.append.mock.calls[1][3][0], status: 'error' }, + { ...api.append.mock.calls[0][3][0], status: 'error' }, ]) }) @@ -258,7 +560,7 @@ describe('Slack tool progress', () => { await stream.onEvent(toolResult('search_workspace')) await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'Done.' } }) await stream.finish(result) - const chunks = api.append.mock.calls.flatMap((call) => call[3]) + const chunks = deliveredChunks() expect(chunks.map((chunk) => chunk.type)).toEqual([ 'markdown_text', 'markdown_text', @@ -285,9 +587,7 @@ describe('Slack tool progress', () => { controller.abort(new Error('stopped')) await stream.terminateAfterFailure() expect(api.stop.mock.calls[0][6]).toEqual([]) - expect(api.append.mock.calls.flatMap((call) => call[3])).toEqual([ - { type: 'markdown_text', text: 'Checking ' }, - ]) + expect(deliveredChunks()).toEqual([{ type: 'markdown_text', text: 'Checking ' }]) }) it('flushes a batched sentence before starting tool progress', async () => { @@ -304,7 +604,7 @@ describe('Slack tool progress', () => { payload: { channel: 'assistant', text: 'the connected sources for the handbook.' }, }) await stream.onEvent(toolCall('search_workspace')) - const chunks = api.append.mock.calls.flatMap((call) => call[3]) + const chunks = deliveredChunks() expect(chunks).toEqual([ { type: 'markdown_text', text: "I'll search " }, { @@ -345,36 +645,33 @@ describe('Slack tool progress', () => { }) await stream.finish(result) expect(deliveredText()).toBe("I'll search the connected sources.") - expect( - api.append.mock.calls - .flatMap((call) => call[3]) - .every((chunk) => chunk.type === 'markdown_text') - ).toBe(true) + expect(deliveredChunks().every((chunk) => chunk.type === 'markdown_text')).toBe(true) }) it('serializes concurrent text and tool events without duplicating buffered text', async () => { const { stream } = setup() await stream.start() - let releaseAppend!: () => void - api.append.mockImplementationOnce( + let releaseStart!: (value: { channel: string; ts: string }) => void + api.start.mockImplementationOnce( () => - new Promise((resolve) => { - releaseAppend = resolve + new Promise<{ channel: string; ts: string }>((resolve) => { + releaseStart = resolve }) ) const text = stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: "I'll search the connected sources. " }, }) - await vi.waitFor(() => expect(api.append).toHaveBeenCalledOnce(), { interval: 1 }) + await vi.waitFor(() => expect(api.start).toHaveBeenCalledOnce(), { interval: 1 }) const call = stream.onEvent(toolCall('search_workspace')) const completed = stream.onEvent(toolResult('search_workspace')) const finished = stream.finish(result) - expect(api.append).toHaveBeenCalledOnce() + expect(api.start).toHaveBeenCalledOnce() + expect(api.append).not.toHaveBeenCalled() expect(api.stop).not.toHaveBeenCalled() - releaseAppend() + releaseStart({ channel: 'D1', ts: '1.2' }) await Promise.all([text, call, completed, finished]) - const chunks = api.append.mock.calls.flatMap((call) => call[3]) + const chunks = deliveredChunks() expect(deliveredText()).toBe("I'll search the connected sources. \n\n") expect(chunks.map((chunk) => chunk.type)).toEqual([ 'markdown_text', @@ -396,7 +693,7 @@ describe('Slack tool progress', () => { await stream.onEvent(toolResult(name)) await stream.onEvent(toolResult(name)) await stream.finish(result) - const chunks = api.append.mock.calls.flatMap((call) => call[3]) + const chunks = deliveredChunks() expect(chunks).toEqual([ { type: 'task_update', id: expect.any(String), title, status: 'in_progress' }, { type: 'task_update', id: chunks[0].id, title, status: 'complete' }, @@ -411,7 +708,7 @@ describe('Slack tool progress', () => { await stream.onEvent(toolCall('search_workspace', 'search-2')) await stream.onEvent(toolResult('search_workspace', 'search-2')) await stream.onEvent(toolResult('search_workspace', 'search-1')) - const chunks = api.append.mock.calls.flatMap((call) => call[3]) + const chunks = deliveredChunks() expect(chunks[0].id).not.toBe(chunks[1].id) expect(chunks[2]).toEqual({ ...chunks[1], status: 'complete' }) expect(chunks[3]).toEqual({ ...chunks[0], status: 'complete' }) @@ -433,8 +730,9 @@ describe('Slack tool progress', () => { await stream.onEvent(toolCall('internal_tool')) await stream.onEvent(toolResult()) expect(api.append).not.toHaveBeenCalled() + expect(api.start).not.toHaveBeenCalled() await stream.onEvent(toolCall()) - expect(api.append).toHaveBeenCalledOnce() + expect(api.start).toHaveBeenCalledOnce() }) it('reports failed tools without exposing arguments, account labels, or backend errors', async () => { @@ -454,7 +752,7 @@ describe('Slack tool progress', () => { output: { accountLabel: 'private account' }, }, }) - const chunks = api.append.mock.calls.flatMap((call) => call[3]) + const chunks = deliveredChunks() expect(chunks[1]).toEqual({ ...chunks[0], status: 'error' }) expect(JSON.stringify(chunks)).not.toContain('private') }) @@ -464,14 +762,13 @@ describe('Slack tool progress', () => { await stream.start() await stream.onEvent(toolCall()) await stream.finishWithError() - expect(api.stop.mock.calls[0][6]).toEqual([ - { ...api.append.mock.calls[0][3][0], status: 'error' }, - ]) + expect(api.stop.mock.calls[0][6]).toEqual([{ ...deliveredChunks()[0], status: 'error' }]) }) it('aborts an ambiguous progress send and cleans up once without replaying it', async () => { const { stream, controller } = setup() await stream.start() + await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'Checking.\n\n' } }) api.append.mockRejectedValueOnce(new Error('progress response lost')) await expect(stream.onEvent(toolCall())).rejects.toThrow('progress response lost') expect(controller.signal.aborted).toBe(true) @@ -491,11 +788,13 @@ describe('Slack tool progress', () => { beforeDelivery.mockRejectedValueOnce(new Error('authority revoked')) await expect(stream.onEvent(toolCall())).rejects.toThrow('authority revoked') expect(controller.signal.aborted).toBe(true) + expect(api.start).not.toHaveBeenCalled() expect(api.append).not.toHaveBeenCalled() const cancelled = setup() await cancelled.stream.start() cancelled.controller.abort(new Error('stopped')) await expect(cancelled.stream.onEvent(toolCall())).rejects.toThrow('stopped') + expect(api.start).not.toHaveBeenCalled() expect(api.append).not.toHaveBeenCalled() }) }) @@ -551,16 +850,11 @@ describe('Slack Assistant delivery', () => { expect(api.start).toHaveBeenCalledWith( 'test-token', { channel: 'D1', threadTs: '1.1' }, - [], + [{ type: 'markdown_text', text: 'Hello world. ' }], 'timeline', expect.any(AbortSignal) ) - expect( - api.append.mock.calls - .flatMap((call) => call[3]) - .map((chunk) => chunk.text) - .join('') - ).toBe('Hello world. ') + expect(deliveredText()).toBe('Hello world. ') expect(api.stop).toHaveBeenCalledOnce() }) it('aborts after an ambiguous append and closes the known stream without replaying text', async () => { @@ -568,7 +862,10 @@ describe('Slack Assistant delivery', () => { api.append.mockRejectedValueOnce(new Error('connection closed')) await stream.start() await expect( - stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'answer ' } }) + stream.onEvent({ + type: 'text', + payload: { channel: 'assistant', text: `${'a'.repeat(4000)} answer ` }, + }) ).rejects.toThrow('connection closed') expect(controller.signal.aborted).toBe(true) await expect(stream.finish(result)).rejects.toThrow('connection closed') @@ -591,13 +888,23 @@ describe('Slack Assistant delivery', () => { it('does not guess a stream identity after an ambiguous start', async () => { const { stream } = setup() api.start.mockRejectedValueOnce(new Error('start response lost')) - await expect(stream.start()).rejects.toThrow('start response lost') + await stream.start() + await expect(stream.onEvent(toolCall())).rejects.toThrow('start response lost') await stream.terminateAfterFailure() + await stream.terminateAfterFailure() + expect(api.start).toHaveBeenCalledOnce() expect(api.stop).not.toHaveBeenCalled() + expect(api.status).toHaveBeenLastCalledWith( + 'test-token', + { channel: 'D1', threadTs: '1.1' }, + 'active', + expect.any(AbortSignal) + ) }) it('does not retry an ambiguous stop during cleanup', async () => { const { stream } = setup() await stream.start() + await stream.onEvent(toolCall()) api.stop.mockRejectedValueOnce(new Error('stop response lost')) await expect(stream.finish(result)).rejects.toThrow('stop response lost') await stream.terminateAfterFailure() @@ -618,6 +925,7 @@ describe('Slack Assistant delivery', () => { controller.abort(new Error('Assistant failed')) beforeCleanup.mockRejectedValueOnce(new Error('authority revoked')) await expect(stream.terminateAfterFailure()).rejects.toThrow('authority revoked') + expect(api.start).not.toHaveBeenCalled() expect(api.stop).not.toHaveBeenCalled() }) it('separates public text before and after a tool call', async () => { @@ -636,12 +944,7 @@ describe('Slack Assistant delivery', () => { }) await stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'Found it.' } }) await stream.finish(result) - expect( - api.append.mock.calls - .flatMap((call) => call[3]) - .map((chunk) => chunk.text) - .join('') - ).toBe('Searching.\n\nFound it.') + expect(deliveredText()).toBe('Searching.\n\nFound it.') }) it.each(['options', 'question', 'thinking', 'usage_upgrade', 'credential', 'workspace_resource'])( 'withholds %s payloads across every stream boundary', @@ -656,8 +959,10 @@ describe('Slack Assistant delivery', () => { it('closes a confirmed Assistant failure with a safe error on the existing stream', async () => { const { stream, beforeDelivery } = setup() await stream.start() + await stream.onEvent(toolCall()) + await stream.onEvent(toolResult()) await stream.finishWithError() - expect(beforeDelivery).toHaveBeenCalledTimes(2) + expect(beforeDelivery).toHaveBeenCalledTimes(4) expect(api.stop).toHaveBeenCalledWith( 'test-token', 'D1', @@ -672,7 +977,7 @@ describe('Slack Assistant delivery', () => { ], [] ) - expect(api.append).not.toHaveBeenCalled() + expect(deliveredText()).toBe('') }) it('refuses delivery when installation or member access changes', async () => { const { stream, beforeDelivery } = setup() @@ -681,6 +986,7 @@ describe('Slack Assistant delivery', () => { await expect( stream.onEvent({ type: 'text', payload: { channel: 'assistant', text: 'answer ' } }) ).rejects.toThrow('membership revoked') + expect(api.start).not.toHaveBeenCalled() expect(api.append).not.toHaveBeenCalled() }) it('places cited source names beside the supported text without a source footer', async () => { @@ -935,9 +1241,13 @@ describe('Slack Assistant delivery', () => { }) const link = '[Employee policy]()' expect(deliveredText()).toBe(`${prefix}${link} Done.`) - const chunks = api.append.mock.calls.flatMap((call) => call[3]) - expect(chunks.some((chunk) => chunk.text.includes(link))).toBe(true) - expect(chunks.every((chunk) => chunk.text.length <= 4000)).toBe(true) + const chunks = deliveredChunks() + expect( + chunks.some((chunk) => chunk.type === 'markdown_text' && chunk.text.includes(link)) + ).toBe(true) + expect( + chunks.every((chunk) => chunk.type === 'markdown_text' && chunk.text.length <= 4000) + ).toBe(true) }) it.each([ ['Answer {"id":"x","url":"https://evil.test"} done ', 'Answer '], diff --git a/apps/sim/lib/slack-search/assistant-stream.ts b/apps/sim/lib/slack-search/assistant-stream.ts index c6d85a9d95d..87adf6dcd8e 100644 --- a/apps/sim/lib/slack-search/assistant-stream.ts +++ b/apps/sim/lib/slack-search/assistant-stream.ts @@ -116,12 +116,17 @@ type ToolProgress = Extract /** Serial delivery through the same provider primitives as Slack blocks; ambiguous sends are terminal. */ export class SlackSearchAssistantStream { private stream?: { channel: string; ts: string } + private sessionStarted = false + private streamStartAttempted = false + private leadingChunks: SlackStreamChunk[] = [] private text = '' + /** Safe text already delivered or buffered ahead of the first visible chunk. */ private sent = '' private lastSentAt = 0 private failure?: Error private closed = false private closeAttempted = false + private cleanupAttempted = false private pendingEvents: Promise = Promise.resolve() private evidence = new Map>() private toolProgress = new Map() @@ -151,13 +156,7 @@ export class SlackSearchAssistantStream { 'processing', controller.signal ) - this.stream = await startSlackAgentStream( - token, - { channel, threadTs }, - [], - 'timeline', - controller.signal - ) + this.sessionStarted = true }) } @@ -168,6 +167,7 @@ export class SlackSearchAssistantStream { private async handleEvent(event: StreamEvent) { if (this.failure) throw this.failure + this.options.controller.signal.throwIfAborted() if (event.type === 'tool' && 'phase' in event.payload && event.payload.phase === 'result') { const { toolName, success, status, output } = event.payload this.collectSources([ @@ -192,7 +192,7 @@ export class SlackSearchAssistantStream { if (event.type !== 'text' || event.payload.channel !== 'assistant' || event.scope) return this.text += event.payload.text if (this.text.length > 128_000) throw new Error('Slack answer exceeds the supported size') - if (Date.now() - this.lastSentAt >= 750) await this.flush(false) + if (!this.stream || Date.now() - this.lastSentAt >= 750) await this.flush(false) } /** Only static labels reach Slack; arguments, account details, and backend errors stay private. */ @@ -272,16 +272,9 @@ export class SlackSearchAssistantStream { /** Unresolved citations and partial markup must not let a task overtake withheld text. */ if (!complete && prefix !== this.projectAnswer(preceding, true, sources)) return await this.deliver(async () => { - if (!this.stream || this.closed) throw new Error('Slack stream is not active') /** Include an ambiguously started task in failure cleanup, but never an unsent task. */ if (!this.deliveredProgress.has(chunk.id)) this.deliveredProgress.set(chunk.id, chunk) - await appendSlackAgentStream( - this.options.token, - this.stream.channel, - this.stream.ts, - [chunk], - this.options.controller.signal - ) + await this.writeChunk(chunk, this.options.controller.signal) }) this.deliveredProgress.set(chunk.id, chunk) this.pendingProgress.shift() @@ -299,7 +292,7 @@ export class SlackSearchAssistantStream { private async appendText(text: string) { if (this.sent.startsWith(text)) return if (!text.startsWith(this.sent)) throw new Error('The safe answer changed after delivery') - const { token, controller } = this.options + const { controller } = this.options let pending = text.slice(this.sent.length) while (pending.length) { let end = Math.min(4000, pending.length) @@ -314,19 +307,36 @@ export class SlackSearchAssistantStream { if (end === 0) throw new Error('Slack citation exceeds the supported chunk size') const chunk = pending.slice(0, end) await this.deliver(async () => { - if (!this.stream || this.closed) throw new Error('Slack stream is not active') - await appendSlackAgentStream( - token, - this.stream.channel, - this.stream.ts, - [{ type: 'markdown_text', text: chunk }], - controller.signal - ) + await this.writeChunk({ type: 'markdown_text', text: chunk }, controller.signal) }) this.sent += chunk pending = pending.slice(chunk.length) - this.lastSentAt = Date.now() + if (this.stream) this.lastSentAt = Date.now() + } + } + + /** Start with visible content, preserving buffered whitespace and tool positions in that request. */ + private async writeChunk(chunk: SlackStreamChunk, signal: AbortSignal) { + if (!this.sessionStarted || this.closed) throw new Error('Slack session is not active') + const { token, channel, threadTs } = this.options + if (this.stream) { + await appendSlackAgentStream(token, this.stream.channel, this.stream.ts, [chunk], signal) + return + } + if (this.streamStartAttempted) throw new Error('Slack stream start was not confirmed') + if (chunk.type === 'markdown_text' && !chunk.text.trim()) { + this.leadingChunks.push(chunk) + return } + this.streamStartAttempted = true + this.stream = await startSlackAgentStream( + token, + { channel, threadTs }, + [...this.leadingChunks, chunk], + 'timeline', + signal + ) + this.leadingChunks = [] } async finish(result: OrchestratorResult) { @@ -349,48 +359,68 @@ export class SlackSearchAssistantStream { '\n\nUse the connection buttons in our DM, then reply here when you’re ready to continue.' } await this.flush(true) - await this.close([]) + await this.deliver(() => this.close(false, this.options.controller.signal)) } - /** A confirmed Assistant failure closes the established stream without exposing backend errors. */ + /** A confirmed Assistant failure is visible even if no answer stream has started. */ async finishWithError() { - await this.close(FAILURE_BLOCKS) + await this.pendingEvents + await this.deliver(() => this.close(true, this.options.controller.signal)) } - /** Closes a known stream once after abort, with fresh authority and no replay of failed sends. */ + /** Settle once after abort, with fresh authority and no replay of ambiguous sends. */ async terminateAfterFailure() { - if (!this.stream || this.closed || this.closeAttempted) return + if ( + !this.sessionStarted || + this.closed || + this.cleanupAttempted || + (this.stream && this.closeAttempted) + ) + return + this.cleanupAttempted = true const signal = AbortSignal.timeout(5000) await this.options.beforeCleanup(signal) signal.throwIfAborted() - this.closeAttempted = true - await stopSlackAgentStream( - this.options.token, - this.stream.channel, - this.stream.ts, - 'active', - signal, - FAILURE_BLOCKS, - this.interruptedToolProgress() - ) - this.closed = true + if (this.closeAttempted) { + /** Only the idempotent status reset can repeat; never replay an unconfirmed message send. */ + const { token, channel, threadTs } = this.options + await setSlackAgentSessionStatus(token, { channel, threadTs }, 'active', signal) + this.closed = true + return + } + await this.close(true, signal) } - private async close(blocks: Record[]) { - await this.deliver(async () => { - if (!this.stream || this.closed) throw new Error('Slack stream is not active') + private async close(failed: boolean, signal: AbortSignal) { + if (!this.sessionStarted || this.closed || this.closeAttempted) + throw new Error('Slack session is not active') + const { token, channel, threadTs } = this.options + let blocks = failed ? FAILURE_BLOCKS : [] + try { + if (!this.stream && failed && !this.streamStartAttempted) { + await this.writeChunk({ type: 'markdown_text', text: SLACK_SEARCH_FAILED_ANSWER }, signal) + blocks = [] + } + } finally { + /** A failed notification must still settle the session, including during failure cleanup. */ + signal.throwIfAborted() this.closeAttempted = true - await stopSlackAgentStream( - this.options.token, - this.stream.channel, - this.stream.ts, - 'active', - this.options.controller.signal, - blocks, - this.interruptedToolProgress() - ) + if (this.stream) { + await stopSlackAgentStream( + token, + this.stream.channel, + this.stream.ts, + 'active', + signal, + blocks, + this.interruptedToolProgress() + ) + } else { + /** Empty runs and unconfirmed starts still need to end the native loading state. */ + await setSlackAgentSessionStatus(token, { channel, threadTs }, 'active', signal) + } this.closed = true - }) + } } assertHealthy() { diff --git a/apps/sim/lib/slack-search/install-link.ts b/apps/sim/lib/slack-search/install-link.ts new file mode 100644 index 00000000000..11e0e7c2421 --- /dev/null +++ b/apps/sim/lib/slack-search/install-link.ts @@ -0,0 +1,5 @@ +/** The team is a selection hint; linking requires a fresh admin-bound Slack authorization. */ +export function slackSearchInstallPath(teamId: string) { + if (!/^T[A-Z0-9]{1,199}$/.test(teamId)) throw new Error('Invalid Slack workspace ID') + return `/slack-search/install/${teamId}` +} diff --git a/apps/sim/lib/slack-search/public-install-auth.test.ts b/apps/sim/lib/slack-search/public-install-auth.test.ts new file mode 100644 index 00000000000..be556dd5201 --- /dev/null +++ b/apps/sim/lib/slack-search/public-install-auth.test.ts @@ -0,0 +1,70 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' + +const m = vi.hoisted(() => ({ app: vi.fn(), exchange: vi.fn(), hosted: true })) +vi.mock('@/lib/core/config/env-flags', () => ({ + get isHosted() { + return m.hosted + }, +})) +vi.mock('@/lib/slack-search/shared-app-env', () => ({ + getSharedSlackSearchAppConfiguration: m.app, +})) +vi.mock('@/lib/internal/slack/oauth', async (importOriginal) => ({ + ...(await importOriginal()), + exchangeSlackBotAuthorization: m.exchange, +})) + +import { authenticateSlackPublicInstallation } from '@/lib/slack-search/public-install-auth' + +const grant = { + ok: true, + app_id: 'A1', + token_type: 'bot', + access_token: 'bot-token', + bot_user_id: 'UBOT', + scope: SLACK_SHARED_SEARCH_BOT_SCOPES.join(','), + team: { id: 'T1', name: 'Test' }, +} +beforeEach(() => { + vi.clearAllMocks() + m.hosted = true + m.app.mockReturnValue({ id: 'A1', clientId: 'client', clientSecret: 'secret', revision: 'r1' }) + m.exchange.mockResolvedValue(grant) +}) +describe('Slack-initiated installation authentication', () => { + it('completes the Slack install without returning tokens or asserting a Sim identity', async () => { + const result = await authenticateSlackPublicInstallation('one-use-code') + expect(m.exchange).toHaveBeenCalledWith({ + clientId: 'client', + clientSecret: 'secret', + code: 'one-use-code', + }) + expect(result).toEqual({ teamId: 'T1' }) + }) + it.each([ + { app_id: 'A2' }, + { scope: 'chat:write' }, + { is_enterprise_install: true }, + { refresh_token: 'refresh' }, + ])('rejects incompatible grants: %j', async (change) => { + m.exchange.mockResolvedValue({ ...grant, ...change }) + await expect(authenticateSlackPublicInstallation('code')).rejects.toThrow() + }) + it('fails on an expired or replayed provider code', async () => { + m.exchange.mockRejectedValue(new Error('Slack authorization failed')) + await expect(authenticateSlackPublicInstallation('used-code')).rejects.toThrow( + 'Slack authorization failed' + ) + }) + it.each(['self-hosted', 'unconfigured'])( + 'rejects %s deployments before exchange', + async (deployment) => { + if (deployment === 'self-hosted') m.hosted = false + else m.app.mockReturnValue(null) + await expect(authenticateSlackPublicInstallation('code')).rejects.toThrow('unavailable') + expect(m.exchange).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/slack-search/public-install-auth.ts b/apps/sim/lib/slack-search/public-install-auth.ts new file mode 100644 index 00000000000..6535c17fd4d --- /dev/null +++ b/apps/sim/lib/slack-search/public-install-auth.ts @@ -0,0 +1,26 @@ +import { isHosted } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + exchangeSlackBotAuthorization, + validateSlackBotAuthorization, +} from '@/lib/internal/slack/oauth' +import { SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' +import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env' + +/** + * Completes installation in Slack without binding it to Sim or retaining tokens. + * Organization setup later obtains its own grant through the admin's state-bound OAuth flow. + */ +export async function authenticateSlackPublicInstallation(code: string) { + const app = isHosted ? getSharedSlackSearchAppConfiguration() : null + if (!app) throw new OrchestrationError('forbidden', 'The Sim Search app is unavailable') + const grant = await exchangeSlackBotAuthorization({ + clientId: app.clientId, + clientSecret: app.clientSecret, + code, + }) + validateSlackBotAuthorization(grant, SLACK_SHARED_SEARCH_BOT_SCOPES) + if (grant.app_id !== app.id) + throw new OrchestrationError('forbidden', 'Slack returned a different app') + return { teamId: grant.team.id } +} diff --git a/apps/sim/lib/table/application/context.test.ts b/apps/sim/lib/table/application/context.test.ts index f5f6ee94b8c..aca566fbbad 100644 --- a/apps/sim/lib/table/application/context.test.ts +++ b/apps/sim/lib/table/application/context.test.ts @@ -9,7 +9,7 @@ const { getTableById, loadWorkspace } = vi.hoisted(() => ({ loadWorkspace: vi.fn(), })) -vi.mock('@/lib/table', () => ({ getTableById })) +vi.mock('@/lib/table/service', () => ({ getTableById })) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: loadWorkspace, })) diff --git a/apps/sim/lib/table/application/context.ts b/apps/sim/lib/table/application/context.ts index 9ad2abd3247..08132dcb5e6 100644 --- a/apps/sim/lib/table/application/context.ts +++ b/apps/sim/lib/table/application/context.ts @@ -1,6 +1,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' -import { getTableById, type TableDefinition } from '@/lib/table' import type { TableAuthorizationContext } from '@/lib/table/application/authorization' +import { getTableById } from '@/lib/table/service' +import type { TableDefinition } from '@/lib/table/types' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export type TableWorkspaceContext = TableAuthorizationContext diff --git a/apps/sim/lib/table/application/tables.test.ts b/apps/sim/lib/table/application/tables.test.ts index 405b1c73056..3af1fea3d4c 100644 --- a/apps/sim/lib/table/application/tables.test.ts +++ b/apps/sim/lib/table/application/tables.test.ts @@ -44,11 +44,11 @@ vi.mock('@/lib/folders/queries', () => ({ resolveFolderPathFilter: mocks.resolveFolderPathFilter, })) -vi.mock('@/lib/table', () => ({ +vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mocks.getLimits })) +vi.mock('@/lib/table/service', () => ({ createTable: vi.fn(), deleteTable: vi.fn(), getTableById: mocks.getTableById, - getWorkspaceTableLimits: mocks.getLimits, listTables: mocks.listDefinitions, moveTableToFolder: vi.fn(), queryTables: mocks.queryTables, diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 52974c43c4a..26b23b59ea2 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -10,21 +10,6 @@ import { loadActiveFolderPathIndex, resolveFolderPathFilter, } from '@/lib/folders/queries' -import { - createTable, - deleteTable, - getTableById, - getWorkspaceTableLimits, - listTables as listTableDefinitions, - moveTableToFolder, - queryTables, - renameTable, - restoreTable, - type TableDefinition, - type TableSchema, - type TableScope, - updateTableDescription, -} from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveActiveTableContext, @@ -37,7 +22,21 @@ import { tableFolderPathForId, } from '@/lib/table/application/folder-paths' import { tableOperations } from '@/lib/table/application/operations' +import { getWorkspaceTableLimits } from '@/lib/table/billing' import { signalTableSchemaChanged } from '@/lib/table/events' +import { + createTable, + deleteTable, + getTableById, + listTables as listTableDefinitions, + moveTableToFolder, + queryTables, + renameTable, + restoreTable, + type TableScope, + updateTableDescription, +} from '@/lib/table/service' +import type { TableDefinition, TableSchema } from '@/lib/table/types' export interface ListTablesInput { workspaceId: string diff --git a/apps/sim/lib/uploads/contexts/organization-logo/application.integration.ts b/apps/sim/lib/uploads/contexts/organization-logo/application.integration.ts index bd97af65748..4fc04c08c4e 100644 --- a/apps/sim/lib/uploads/contexts/organization-logo/application.integration.ts +++ b/apps/sim/lib/uploads/contexts/organization-logo/application.integration.ts @@ -1,7 +1,6 @@ /** Real PostgreSQL verifies cross-session registration and durable logo retention. */ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' -import { member, organization, organizationColumns, uploadSession } from '@sim/db/schema' +import { member, organization, uploadSession } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import type { Sql } from 'postgres' @@ -75,7 +74,7 @@ describe('organization logo concurrency and retention', () => { for (const table of ['user', 'member', 'organization', 'upload_session']) { await connection`CREATE TABLE ${connection(table)} (LIKE ${connection(`public.${table}`)} INCLUDING ALL)` } - await db.insert(withInsertColumns(organization, organizationColumns)).values({ + await db.insert(organization).values({ id: organizationId, name: 'Logo test organization', slug: generateId(), diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 33221719dea..b76fbdc7a17 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -5,14 +5,7 @@ import { randomBytes } from 'crypto' import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' -import { - uploadSession, - type WorkspaceFileRow, - workspace, - workspaceFileColumns, - workspaceFiles, -} from '@sim/db/schema' +import { uploadSession, type WorkspaceFileRow, workspace, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { describeError, @@ -267,7 +260,7 @@ async function insertWorkspaceFileMetadataInTx( metadata: WorkspaceFileMetadataInsert ): Promise { const [inserted] = await tx - .insert(withInsertColumns(workspaceFiles, workspaceFileColumns)) + .insert(workspaceFiles) .values({ ...omit(metadata, ['size']), sizeBytes: metadata.size, @@ -280,7 +273,7 @@ async function insertWorkspaceFileMetadataInTx( contentUpdatedAt: new Date(), }) .onConflictDoNothing() - .returning(workspaceFileColumns) + .returning() return inserted } @@ -298,7 +291,7 @@ async function findWorkspaceFileByRegistrationKey( key: string ): Promise { const files = await executor - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where(eq(workspaceFiles.key, key)) .orderBy(sql`${workspaceFiles.deletedAt} IS NULL DESC`) @@ -315,7 +308,7 @@ async function findWorkspaceFileForLifecycle( fileId: string ): Promise { const [file] = await executor - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( and( @@ -1057,7 +1050,7 @@ export async function trackChatUpload( await db.transaction(async (tx) => { const [inserted] = await tx - .insert(withInsertColumns(workspaceFiles, workspaceFileColumns)) + .insert(workspaceFiles) .values({ id: fileId, key: s3Key, @@ -1223,7 +1216,7 @@ export async function getWorkspaceFileByName( ): Promise { const folderId = options?.folderId ?? null const files = await db - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( and( @@ -1647,7 +1640,7 @@ export async function getWorkspaceFile( try { const { includeDeleted = false } = options ?? {} const files = await db - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( includeDeleted @@ -1837,7 +1830,7 @@ export async function updateWorkspaceFileContent( try { finalized = await db.transaction(async (tx) => { const [currentFile] = await tx - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( and( @@ -1906,7 +1899,7 @@ export async function updateWorkspaceFileContent( isNull(workspaceFiles.deletedAt) ) ) - .returning(workspaceFileColumns) + .returning() if (!updatedFile) { throw new OrchestrationError('not_found', 'File not found or could not be updated') } @@ -2203,7 +2196,7 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): isNull(workspaceFiles.deletedAt) ) ) - .returning(workspaceFileColumns) + .returning() if (!archived) return logger.info(`Successfully archived workspace file: ${archived.originalName}`) @@ -2352,7 +2345,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): isNotNull(workspaceFiles.deletedAt) ) ) - .returning(workspaceFileColumns) + .returning() if (!restored) return logger.info(`Successfully restored workspace file: ${newName}`) diff --git a/apps/sim/lib/uploads/server/metadata.ts b/apps/sim/lib/uploads/server/metadata.ts index 97ab3565e38..c80b1228d0d 100644 --- a/apps/sim/lib/uploads/server/metadata.ts +++ b/apps/sim/lib/uploads/server/metadata.ts @@ -1,6 +1,5 @@ import { db } from '@sim/db' -import { withInsertColumns } from '@sim/db/insert-columns' -import { type WorkspaceFileRow, workspaceFileColumns, workspaceFiles } from '@sim/db/schema' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' @@ -95,7 +94,7 @@ async function findActiveFileMetadataByKey( key: string ): Promise { const [record] = await executor - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt))) /** Wait for in-flight cleanup before accepting an active identity for newly uploaded bytes. */ @@ -141,7 +140,7 @@ async function insertFileMetadataWithExecutor( } const [existingDeleted] = await executor - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where(and(eq(workspaceFiles.key, key), isNotNull(workspaceFiles.deletedAt))) .limit(1) @@ -167,7 +166,7 @@ async function insertFileMetadataWithExecutor( contentUpdatedAt: sql`GREATEST(CURRENT_TIMESTAMP, ${workspaceFiles.contentUpdatedAt} + INTERVAL '1 millisecond')`, }) .where(and(eq(workspaceFiles.id, existingDeleted.id), isNotNull(workspaceFiles.deletedAt))) - .returning(workspaceFileColumns) + .returning() if (restored) { return restored @@ -180,7 +179,7 @@ async function insertFileMetadataWithExecutor( try { const [inserted] = await executor - .insert(withInsertColumns(workspaceFiles, workspaceFileColumns)) + .insert(workspaceFiles) .values({ id: fileId, key, @@ -196,7 +195,7 @@ async function insertFileMetadataWithExecutor( deletedAt: null, uploadedAt: new Date(), }) - .returning(workspaceFileColumns) + .returning() if (!inserted) { throw new Error(`Failed to insert file metadata for key: ${key}`) @@ -236,7 +235,7 @@ async function insertImmutableFileMetadataWithExecutor( } = options assertFileMetadataOrganizationOwner(options) const [inserted] = await executor - .insert(withInsertColumns(workspaceFiles, workspaceFileColumns)) + .insert(workspaceFiles) .values({ id: id || generateId(), key, @@ -253,7 +252,7 @@ async function insertImmutableFileMetadataWithExecutor( uploadedAt: new Date(), }) .onConflictDoNothing() - .returning(workspaceFileColumns) + .returning() if (inserted) return inserted @@ -317,7 +316,7 @@ export async function insertFileMetadataMany( const uniqueRows = [...uniqueRowsByKey.values()] const inserted = await db - .insert(withInsertColumns(workspaceFiles, workspaceFileColumns)) + .insert(workspaceFiles) .values( uniqueRows.map((row) => ({ id: row.id || generateId(), @@ -336,13 +335,13 @@ export async function insertFileMetadataMany( })) ) .onConflictDoNothing() - .returning(workspaceFileColumns) + .returning() const insertedKeys = new Set(inserted.map((record) => record.key)) const conflictingRows = uniqueRows.filter((row) => !insertedKeys.has(row.key)) if (conflictingRows.length > 0) { const activeRows = await db - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( and( @@ -384,7 +383,7 @@ export async function getFileMetadataByKey( } const [record] = await db - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where(conditions.length > 1 ? and(...conditions) : conditions[0]) // Prefer the active row when includeDeleted lets both an active and a @@ -439,7 +438,7 @@ export async function getFileMetadataByKeys( } if (options?.includeDeleted) { return executor - .selectDistinctOn([workspaceFiles.key], workspaceFileColumns) + .selectDistinctOn([workspaceFiles.key]) .from(workspaceFiles) .where(and(inArray(workspaceFiles.key, keys), eq(workspaceFiles.context, context))) .orderBy( @@ -450,7 +449,7 @@ export async function getFileMetadataByKeys( ) } const query = executor - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where( and( @@ -473,7 +472,7 @@ export async function getFileMetadataById( const conditions = [eq(workspaceFiles.id, id)] if (!includeDeleted) conditions.push(isNull(workspaceFiles.deletedAt)) const [record] = await db - .select(workspaceFileColumns) + .select() .from(workspaceFiles) .where(conditions.length > 1 ? and(...conditions) : conditions[0]) .limit(1) diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index e593b9614fc..1a086357676 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -430,3 +430,183 @@ describe('getWorkflowExecutionStatus queue projection', () => { }) }) }) + +describe('getWorkflowExecutionStatus settled resume attempts', () => { + const resumeInput = { ...input, executionId: 'resume-run-1' } + + function parentLog(overrides: Record = {}) { + return { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + level: 'info', + trigger: 'api', + startedAt: new Date('2026-08-05T11:00:00.000Z'), + endedAt: new Date('2026-08-05T12:00:05.000Z'), + totalDurationMs: 3605000, + executionData: { finalOutput: { answer: 42 } }, + costTotal: '0.5', + ...overrides, + } + } + + function settledAttempt(overrides: Record = {}) { + return { + id: 'resume-entry-1', + parentExecutionId: 'execution-1', + status: 'completed', + queuedAt: new Date('2026-08-05T12:00:00.000Z'), + claimedAt: new Date('2026-08-05T12:00:01.000Z'), + completedAt: new Date('2026-08-05T12:00:05.000Z'), + failureReason: null, + ...overrides, + } + } + + /** The attempt has no log of its own; the parent run is read second. */ + function queueSettledResume( + attempt: Record, + log: Record, + pausedRows: unknown[] = [] + ) { + queueTableRows(schemaMock.workflowExecutionLogs, []) + queueTableRows(schemaMock.resumeQueue, [attempt]) + queueTableRows(schemaMock.workflowExecutionLogs, [log]) + queueTableRows(schemaMock.resumeQueue, []) + queueTableRows(schemaMock.pausedExecutions, pausedRows) + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetJob.mockResolvedValue(null) + mockMaterializeForDisplayWithBlockOutputs.mockImplementation(async (executionData) => ({ + executionData, + blockOutputs: new Map(), + })) + }) + + it('projects a completed resume from the run it continued, under its own run ID', async () => { + queueSettledResume(settledAttempt(), parentLog()) + + const status = await getWorkflowExecutionStatus({ ...resumeInput, includeOutput: true }) + + expect(status).toEqual({ + executionId: 'resume-run-1', + workflowId: 'workflow-1', + status: 'completed', + trigger: 'api', + level: 'info', + startedAt: '2026-08-05T12:00:01.000Z', + endedAt: '2026-08-05T12:00:05.000Z', + totalDurationMs: 4000, + paused: null, + cost: { total: 0.5 }, + error: null, + finalOutput: { answer: 42 }, + blockOutputs: null, + }) + expect(mockMaterializeForDisplayWithBlockOutputs).toHaveBeenCalledWith( + expect.anything(), + { workspaceId: 'workspace-1', workflowId: 'workflow-1', executionId: 'execution-1' }, + [] + ) + expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:resume-run-1') + }) + + it('reports the next pause when a completed resume paused the run again', async () => { + queueSettledResume(settledAttempt(), parentLog({ status: 'paused', executionData: {} }), [ + { + id: 'paused-1', + status: 'partially_resumed', + pausePoints: { + 'context-2': { + contextId: 'context-2', + blockId: 'block-2', + pauseKind: 'human', + resumeStatus: 'paused', + }, + }, + metadata: {}, + resumedCount: 1, + pausedAt: new Date('2026-08-05T12:00:04.000Z'), + nextResumeAt: null, + }, + ]) + + const status = await getWorkflowExecutionStatus(resumeInput) + + expect(status).toMatchObject({ + executionId: 'resume-run-1', + status: 'paused', + endedAt: '2026-08-05T12:00:05.000Z', + paused: { contextId: 'context-2', pausedExecutionId: 'paused-1', resumedCount: 1 }, + }) + }) + + it('reports no end time while a later resume is still running the run', async () => { + queueSettledResume(settledAttempt(), parentLog({ status: 'running', endedAt: null })) + + const status = await getWorkflowExecutionStatus(resumeInput) + + expect(status).toMatchObject({ + executionId: 'resume-run-1', + status: 'running', + startedAt: '2026-08-05T12:00:01.000Z', + endedAt: null, + totalDurationMs: null, + }) + }) + + it('reports a failed resume that left the run paused as failed with its reason', async () => { + queueSettledResume( + settledAttempt({ status: 'failed', failureReason: 'Resume execution cancelled' }), + parentLog({ status: 'paused', executionData: {} }) + ) + + const status = await getWorkflowExecutionStatus({ ...resumeInput, includeOutput: true }) + + expect(status).toMatchObject({ + executionId: 'resume-run-1', + status: 'failed', + level: 'error', + error: 'Resume execution cancelled', + endedAt: '2026-08-05T12:00:05.000Z', + paused: null, + finalOutput: null, + blockOutputs: null, + }) + }) + + it("prefers the run's own error when the failed resume failed the run", async () => { + queueSettledResume( + settledAttempt({ status: 'failed', failureReason: 'Unexpected error' }), + parentLog({ status: 'failed', level: 'error', executionData: { error: 'Block 2 timed out' } }) + ) + + const status = await getWorkflowExecutionStatus(resumeInput) + + expect(status).toMatchObject({ status: 'failed', error: 'Block 2 timed out' }) + }) + + it('reports a resume that lost to cancellation as cancelled', async () => { + queueSettledResume( + settledAttempt({ status: 'failed', failureReason: 'Paused execution cancelled' }), + parentLog({ status: 'cancelled', executionData: {} }) + ) + + const status = await getWorkflowExecutionStatus(resumeInput) + + expect(status).toMatchObject({ status: 'cancelled', level: 'info', error: null }) + }) + + it('returns null when the run a settled resume continued no longer exists', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, []) + queueTableRows(schemaMock.resumeQueue, [settledAttempt()]) + queueTableRows(schemaMock.workflowExecutionLogs, []) + queueTableRows(schemaMock.resumeQueue, []) + + await expect(getWorkflowExecutionStatus(resumeInput)).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index 873dc5182ed..edac4c8649f 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, sql } from 'drizzle-orm' import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows' import { getJobQueue } from '@/lib/core/async-jobs' import type { Job } from '@/lib/core/async-jobs/types' @@ -120,6 +120,74 @@ function projectQueueJob( } } +interface ResumeAttemptRow { + id: string + parentExecutionId: string + status: string + queuedAt: Date + claimedAt: Date | null + completedAt: Date | null + failureReason: string | null +} + +type SettledResumeAttemptRow = ResumeAttemptRow & { status: 'completed' | 'failed' } + +function isSettledResumeAttempt( + attempt: ResumeAttemptRow | undefined +): attempt is SettledResumeAttemptRow { + return attempt?.status === 'completed' || attempt?.status === 'failed' +} + +/** + * Projects a finished resume attempt as its own run resource. + * + * A resume never writes a log row of its own: it continues the paused run and + * records under the parent's execution ID, so once its queue entry settles the + * run it continued is the only durable record of what it did. The attempt + * keeps its own ID and timings, and borrows the rest from that run. + * + * A `completed` attempt is one whose segment ran to its end — the workflow + * finished, failed, or paused again — so the run's state is the answer, + * including when a later resume has since moved the run on (which is why an + * active run reports no end time). A `failed` attempt never finished its + * segment and may have left the run paused for another attempt; it reads as + * failed with its recorded reason unless the run itself was cancelled or failed + * with a more specific error. + */ +function projectSettledResumeAttempt( + executionId: string, + attempt: SettledResumeAttemptRow, + run: WorkflowExecutionStatusResponse +): WorkflowExecutionStatusResponse { + const startedAt = attempt.claimedAt ?? attempt.queuedAt + const continuesInRun = + attempt.status === 'completed' && (run.status === 'queued' || run.status === 'running') + const endedAt = continuesInRun ? null : attempt.completedAt + const resource: WorkflowExecutionStatusResponse = { + ...run, + executionId, + startedAt: startedAt.toISOString(), + endedAt: endedAt?.toISOString() ?? null, + totalDurationMs: endedAt ? Math.max(0, endedAt.getTime() - startedAt.getTime()) : null, + } + if (attempt.status === 'completed') return resource + + const cancelled = run.status === 'cancelled' + return { + ...resource, + status: cancelled ? 'cancelled' : 'failed', + level: cancelled ? 'info' : 'error', + paused: null, + error: cancelled + ? null + : ((run.status === 'failed' ? run.error : null) ?? + attempt.failureReason ?? + 'Resume execution failed'), + finalOutput: null, + blockOutputs: null, + } +} + export interface GetWorkflowExecutionStatusInput { workflowId: string executionId: string @@ -246,25 +314,29 @@ async function readWorkflowExecutionStatus( ) .limit(1) - const [activeResume] = await db + const [resumeAttempt] = await db .select({ id: resumeQueue.id, + parentExecutionId: resumeQueue.parentExecutionId, status: resumeQueue.status, queuedAt: resumeQueue.queuedAt, claimedAt: resumeQueue.claimedAt, + completedAt: resumeQueue.completedAt, + failureReason: resumeQueue.failureReason, }) .from(resumeQueue) .innerJoin(pausedExecutions, eq(resumeQueue.pausedExecutionId, pausedExecutions.id)) .where( - and( - eq(resumeQueue.newExecutionId, executionId), - eq(pausedExecutions.workflowId, workflowId), - inArray(resumeQueue.status, ['pending', 'claimed'] as const) - ) + and(eq(resumeQueue.newExecutionId, executionId), eq(pausedExecutions.workflowId, workflowId)) ) - .orderBy(sql`case when ${resumeQueue.status} = 'claimed' then 0 else 1 end`) + .orderBy(sql`case ${resumeQueue.status} when 'claimed' then 0 when 'pending' then 1 else 2 end`) .limit(1) + const activeResume = + resumeAttempt?.status === 'pending' || resumeAttempt?.status === 'claimed' + ? resumeAttempt + : undefined + const hasTerminalLog = logRow?.status === 'completed' || logRow?.status === 'failed' || logRow?.status === 'cancelled' const projectedResume = hasTerminalLog ? undefined : activeResume @@ -304,7 +376,14 @@ async function readWorkflowExecutionStatus( } } - if (!logRow) return null + if (!logRow) { + if (!isSettledResumeAttempt(resumeAttempt)) return null + const run = await readWorkflowExecutionStatus({ + ...input, + executionId: resumeAttempt.parentExecutionId, + }) + return run ? projectSettledResumeAttempt(executionId, resumeAttempt, run) : null + } const [pausedRow] = await db .select({ diff --git a/apps/sim/lib/workflows/references/resources.ts b/apps/sim/lib/workflows/references/resources.ts index f1168ae7d45..e1cff65b43c 100644 --- a/apps/sim/lib/workflows/references/resources.ts +++ b/apps/sim/lib/workflows/references/resources.ts @@ -24,6 +24,7 @@ import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { parseFolderPath, ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import type { ForkMcpServerMeta, ForkRemapKind } from '@/lib/workflows/references/remap-references' +import { activeWorkspaceFileConditions } from '@/lib/workspace-files/query-scope' export interface ForkResourceCandidate { id: string @@ -238,9 +239,7 @@ const fileCandidatesQuery = (executor: DbOrTx, workspaceId: string, keys?: strin .from(workspaceFiles) .where( and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt), + ...activeWorkspaceFileConditions([workspaceId]), keys ? inArray(workspaceFiles.key, keys) : undefined ) ) @@ -300,9 +299,7 @@ const fileCandidatesWithFolderQuery = ( ) .where( and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt), + ...activeWorkspaceFileConditions([workspaceId]), page?.after ? gt(workspaceFiles.id, page.after) : undefined, keys ? inArray(workspaceFiles.key, keys) : undefined ) diff --git a/apps/sim/lib/workspace-files/query-scope.ts b/apps/sim/lib/workspace-files/query-scope.ts new file mode 100644 index 00000000000..88c533e53f8 --- /dev/null +++ b/apps/sim/lib/workspace-files/query-scope.ts @@ -0,0 +1,11 @@ +import { workspaceFiles } from '@sim/db/schema' +import { eq, inArray, isNull } from 'drizzle-orm' + +/** Durable files available to workspace resource pickers, reference mappings, and fork copies. */ +export function activeWorkspaceFileConditions(workspaceIds: string[]) { + return [ + inArray(workspaceFiles.workspaceId, workspaceIds), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt), + ] +} diff --git a/apps/sim/lib/workspace-files/search/constants.ts b/apps/sim/lib/workspace-files/search/constants.ts index 20b6047c39e..f4a2820e663 100644 --- a/apps/sim/lib/workspace-files/search/constants.ts +++ b/apps/sim/lib/workspace-files/search/constants.ts @@ -42,6 +42,10 @@ export const FILE_SEARCH_INDEX_MAX_OUTSTANDING = 100 export const FILE_SEARCH_INDEX_DISPATCH_WORKSPACES = 100 export const FILE_SEARCH_DISPATCH_INTERVAL_MS = 60 * 1000 export const FILE_SEARCH_DISPATCH_MAX_DURATION_SECONDS = 60 +/** Leave room for connection setup, rollback, and task failure reporting before the hard cutoff. */ +export const FILE_SEARCH_DISPATCH_STATEMENT_TIMEOUT_MS = 10 * 1000 +export const FILE_SEARCH_DISPATCH_LOCK_TIMEOUT_MS = 2 * 1000 +export const FILE_SEARCH_DISPATCH_TRANSACTION_TIMEOUT_MS = 20 * 1000 export const FILE_SEARCH_INDEX_MAX_DURATION_SECONDS = 15 * 60 export const FILE_SEARCH_INDEX_STALE_DISPATCH_MS = 6 * 60 * 60 * 1000 export const FILE_SEARCH_INDEX_STALE_REAP_LIMIT = 100 diff --git a/apps/sim/lib/workspace-files/search/dispatcher.integration.ts b/apps/sim/lib/workspace-files/search/dispatcher.integration.ts new file mode 100644 index 00000000000..2a9eadf2599 --- /dev/null +++ b/apps/sim/lib/workspace-files/search/dispatcher.integration.ts @@ -0,0 +1,193 @@ +/** Real PostgreSQL cancellation must roll back preparation and release its advisory lock. */ +import { withUtcTimestamps } from '@sim/db/timestamps' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const database = vi.hoisted(() => ({ current: undefined as PostgresJsDatabase | undefined })) +const mocks = vi.hoisted(() => ({ batchTrigger: vi.fn() })) +vi.mock('@sim/db', () => ({ + get db() { + if (!database.current) throw new Error('Dispatcher test database is not initialized') + return database.current + }, +})) +vi.mock('@/lib/workspace-files/search/indexing', () => ({ + indexWorkspaceFileForSearch: vi.fn(), + markWorkspaceFileSearchIndexFailed: vi.fn(), +})) +vi.mock('@trigger.dev/sdk', () => ({ tasks: { batchTrigger: mocks.batchTrigger } })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: true })) +vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: async () => 'us-east-1' })) + +import { + dispatchWorkspaceFileSearchIndexJobs, + prepareWorkspaceFileSearchDispatch, +} from '@/lib/workspace-files/search/dispatcher' + +describe('workspace file search dispatch PostgreSQL deadlines', () => { + const schemaName = `dispatch_test_${generateId().replaceAll('-', '')}` + const databaseUrl = process.env.KNOWLEDGE_ACL_TEST_DATABASE_URL + if (!databaseUrl) throw new Error('Dispatcher tests require a disposable local database') + const connection = postgres( + databaseUrl, + withUtcTimestamps({ + max: 3, + prepare: false, + fetch_types: false, + connection: { search_path: schemaName }, + onnotice: () => {}, + }) + ) + + beforeAll(async () => { + await connection`CREATE SCHEMA ${connection(schemaName)}` + await connection`CREATE TABLE workspace_file_search_backfill ( + id text PRIMARY KEY, after_workspace_id text, after_file_id text, + completed_at timestamp, updated_at timestamp NOT NULL + )` + await connection`CREATE TABLE workspace_files ( + id text PRIMARY KEY, workspace_id text NOT NULL, context text NOT NULL, + deleted_at timestamp, content_updated_at timestamp NOT NULL + )` + await connection`CREATE TABLE workspace_file_search_index ( + file_id text NOT NULL, workspace_id text NOT NULL, source_content_updated_at timestamp NOT NULL, + status text NOT NULL, dispatched_at timestamp, updated_at timestamp NOT NULL, + PRIMARY KEY (file_id, source_content_updated_at) + )` + await connection`CREATE TABLE workspace_file_search_dispatch_queue ( + workspace_id text PRIMARY KEY, enqueued_at timestamp NOT NULL, + updated_at timestamp NOT NULL, last_dispatched_at timestamp + )` + await connection`INSERT INTO workspace_file_search_backfill (id, updated_at) + VALUES ('workspace-file-search-v1', '2026-09-16 00:00:00')` + database.current = drizzle(connection) + }) + + beforeEach(async () => { + mocks.batchTrigger.mockReset() + await connection`DROP TRIGGER IF EXISTS slow_backfill ON workspace_file_search_backfill` + await connection`TRUNCATE workspace_files, workspace_file_search_index, workspace_file_search_dispatch_queue` + await connection`UPDATE workspace_file_search_backfill + SET updated_at = '2026-09-16 00:00:00', completed_at = NULL` + }) + + afterAll(async () => { + try { + await connection`DROP SCHEMA ${connection(schemaName)} CASCADE` + } finally { + await connection.end() + database.current = undefined + } + }) + + async function expectAdvisoryLockReleased() { + await connection.begin(async (tx) => { + const [row] = await tx`SELECT pg_try_advisory_xact_lock( + hashtextextended('workspace-file-search-dispatch', 0) + ) AS acquired` + expect(row.acquired).toBe(true) + }) + } + + it('fails on a locked backfill row and releases the dispatcher lock', async () => { + let release = () => {} + let locked = () => {} + const releaseLock = new Promise((resolve) => { + release = resolve + }) + const lockReady = new Promise((resolve) => { + locked = resolve + }) + const blocker = connection.begin(async (tx) => { + await tx`SELECT id FROM workspace_file_search_backfill FOR UPDATE` + locked() + await releaseLock + }) + await lockReady + try { + const failure = await prepareWorkspaceFileSearchDispatch().catch((error: unknown) => error) + expect(getPostgresErrorCode(failure)).toBe('55P03') + await expectAdvisoryLockReleased() + } finally { + release() + await blocker + } + }) + + it('cancels a slow statement and rolls back its earlier writes', async () => { + await connection`CREATE FUNCTION slow_backfill() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + UPDATE workspace_file_search_backfill SET updated_at = '2099-01-01'; + PERFORM pg_sleep(15); + RETURN NEW; + END + $$` + await connection`CREATE TRIGGER slow_backfill BEFORE INSERT ON workspace_file_search_backfill + FOR EACH ROW EXECUTE FUNCTION slow_backfill()` + + const failure = await prepareWorkspaceFileSearchDispatch().catch((error: unknown) => error) + + expect(getPostgresErrorCode(failure)).toBe('57014') + const [row] = + await connection`SELECT updated_at::text AS updated_at FROM workspace_file_search_backfill` + expect(row.updated_at).toBe('2026-09-16 00:00:00') + await expectAdvisoryLockReleased() + }, 20_000) + + it('releases committed claims without the preparation deadlines', async () => { + const fileId = generateId() + const workspaceId = generateId() + await connection`UPDATE workspace_file_search_backfill SET completed_at = now()` + await connection`INSERT INTO workspace_files (id, workspace_id, context, content_updated_at) + VALUES (${fileId}, ${workspaceId}, 'workspace', '2026-09-16')` + await connection`INSERT INTO workspace_file_search_index + (file_id, workspace_id, source_content_updated_at, status, updated_at) + VALUES (${fileId}, ${workspaceId}, '2026-09-16', 'pending', now())` + await connection`INSERT INTO workspace_file_search_dispatch_queue + (workspace_id, enqueued_at, updated_at) VALUES (${workspaceId}, now(), now())` + await connection`CREATE TABLE cleanup_timeouts ( + lock_timeout text, statement_timeout text, transaction_timeout text + )` + await connection`CREATE FUNCTION record_cleanup_timeouts() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + INSERT INTO cleanup_timeouts VALUES ( + current_setting('lock_timeout'), + current_setting('statement_timeout'), + current_setting('transaction_timeout') + ); + RETURN NEW; + END + $$` + await connection`CREATE TRIGGER record_cleanup_timeouts AFTER UPDATE OF dispatched_at + ON workspace_file_search_index FOR EACH ROW + WHEN (OLD.dispatched_at IS NOT NULL AND NEW.dispatched_at IS NULL) + EXECUTE FUNCTION record_cleanup_timeouts()` + + const enqueueError = new Error('Queue unavailable') + mocks.batchTrigger.mockRejectedValueOnce(enqueueError) + + await expect(dispatchWorkspaceFileSearchIndexJobs()).rejects.toBe(enqueueError) + expect(mocks.batchTrigger).toHaveBeenCalledWith('workspace-file-search-index', [ + expect.objectContaining({ + payload: { + fileId, + workspaceId, + sourceContentUpdatedAt: '2026-09-16T00:00:00.000Z', + }, + }), + ]) + const [index] = await connection`SELECT dispatched_at FROM workspace_file_search_index + WHERE file_id = ${fileId}` + expect(index.dispatched_at).toBeNull() + const [queued] = await connection`SELECT workspace_id FROM workspace_file_search_dispatch_queue + WHERE workspace_id = ${workspaceId}` + expect(queued.workspace_id).toBe(workspaceId) + const timeouts = await connection`SELECT * FROM cleanup_timeouts` + expect([...timeouts]).toEqual([ + { lock_timeout: '0', statement_timeout: '0', transaction_timeout: '0' }, + ]) + }) +}) diff --git a/apps/sim/lib/workspace-files/search/dispatcher.test.ts b/apps/sim/lib/workspace-files/search/dispatcher.test.ts index 98845cb5eff..937c30f4277 100644 --- a/apps/sim/lib/workspace-files/search/dispatcher.test.ts +++ b/apps/sim/lib/workspace-files/search/dispatcher.test.ts @@ -1,9 +1,43 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { + workspaceFileSearchBackfill, + workspaceFileSearchDispatchQueue, + workspaceFileSearchIndex, +} from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + batchTrigger: vi.fn(), + info: vi.fn(), + error: vi.fn(), +})) + +vi.mock('@sim/db/schema', async () => ({ + ...(await import('@sim/testing/mocks/schema.mock')).schemaMock, + workspaceFileSearchBackfill: { id: 'backfill.id' }, + workspaceFileSearchDispatchQueue: { + workspaceId: 'queue.workspaceId', + lastDispatchedAt: 'queue.lastDispatchedAt', + enqueuedAt: 'queue.enqueuedAt', + }, +})) + +vi.mock('@sim/logger', () => ({ createLogger: () => ({ info: mocks.info, error: mocks.error }) })) +vi.mock('@trigger.dev/sdk', () => ({ tasks: { batchTrigger: mocks.batchTrigger } })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: true })) +vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: async () => 'us-east-1' })) +vi.mock('@/lib/workspace-files/search/indexing', () => ({ + indexWorkspaceFileForSearch: vi.fn(), + markWorkspaceFileSearchIndexFailed: vi.fn(), +})) + import { buildWorkspaceFileSearchTriggerItems, + dispatchWorkspaceFileSearchIndexJobs, + prepareWorkspaceFileSearchDispatch, shouldUseWorkspaceFileSearchTrigger, } from '@/lib/workspace-files/search/dispatcher' @@ -34,3 +68,119 @@ describe('workspace file search dispatch policy', () => { ]) }) }) + +describe('workspace file search dispatch deadlines', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('sets local database deadlines before taking the advisory lock', async () => { + dbChainMockFns.execute.mockResolvedValueOnce([]).mockResolvedValueOnce([{ acquired: false }]) + + await expect(prepareWorkspaceFileSearchDispatch()).resolves.toEqual({ + payloads: [], + backfilledFiles: 0, + reapedClaims: 0, + lockAcquired: false, + }) + + const guards = JSON.stringify(dbChainMockFns.execute.mock.calls[0][0]) + expect(guards).toContain("set_config('statement_timeout', ") + expect(guards).toContain('10000ms') + expect(guards).toContain("set_config('lock_timeout', ") + expect(guards).toContain('2000ms') + expect(guards).toContain("'transaction_timeout'") + expect(guards).toContain('20000ms') + expect(JSON.stringify(dbChainMockFns.execute.mock.calls[1][0])).toContain( + 'pg_try_advisory_xact_lock' + ) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it.each(['57014', '55P03', '25P04'])( + 'propagates SQLSTATE %s without enqueuing an uncommitted claim', + async (code) => { + const error = new Error('Failed query\nparams: sensitive-value', { + cause: Object.assign(new Error('database timeout'), { code }), + }) + dbChainMockFns.execute.mockResolvedValueOnce([]).mockResolvedValueOnce([{ acquired: true }]) + dbChainMockFns.onConflictDoNothing.mockRejectedValueOnce(error) + + await expect(dispatchWorkspaceFileSearchIndexJobs()).rejects.toBe(error) + + expect(mocks.batchTrigger).not.toHaveBeenCalled() + expect(mocks.error).toHaveBeenCalledWith('Workspace file search dispatch phase failed', { + phase: 'backfill', + durationMs: expect.any(Number), + code, + error: 'Failed query', + }) + expect(JSON.stringify(mocks.error.mock.calls)).not.toContain('sensitive-value') + } + ) + + it('reports a transaction failure even after the transaction callback finishes', async () => { + const error = Object.assign(new Error('commit failed'), { code: '08006' }) + dbChainMockFns.execute.mockResolvedValueOnce([]).mockResolvedValueOnce([{ acquired: false }]) + dbChainMockFns.transaction.mockImplementationOnce(async (callback) => { + await callback(dbChainMock.db) + throw error + }) + + await expect(prepareWorkspaceFileSearchDispatch()).rejects.toBe(error) + + expect(mocks.error).toHaveBeenCalledWith('Workspace file search dispatch phase failed', { + phase: 'prepare-transaction', + durationMs: expect.any(Number), + code: '08006', + error: 'commit failed', + }) + }) + + it.each([false, true])( + 'preserves enqueue failures when claim release fails: %s', + async (releaseFails) => { + queueTableRows(workspaceFileSearchBackfill, [{ completedAt: new Date() }]) + queueTableRows(workspaceFileSearchIndex, []) + queueTableRows(workspaceFileSearchIndex, [{ active: 0 }]) + queueTableRows(workspaceFileSearchDispatchQueue, [{ workspaceId: 'workspace-1' }]) + dbChainMockFns.execute + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ acquired: true }]) + .mockResolvedValueOnce([ + { + workspaceId: 'workspace-1', + fileId: 'file-1', + sourceContentUpdatedAt: new Date('2026-09-16T00:00:00Z'), + }, + ]) + const error = new Error('Trigger unavailable') + const releaseError = new Error('claim release unavailable') + mocks.batchTrigger.mockRejectedValueOnce(error) + if (releaseFails) { + dbChainMockFns.transaction + .mockImplementationOnce(async (callback) => callback(dbChainMock.db)) + .mockRejectedValueOnce(releaseError) + } + + if (releaseFails) { + await expect(dispatchWorkspaceFileSearchIndexJobs()).rejects.toMatchObject({ + errors: [error, releaseError], + cause: error, + }) + } else { + await expect(dispatchWorkspaceFileSearchIndexJobs()).rejects.toBe(error) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ dispatchedAt: null }) + ) + } + + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(2) + const guards = dbChainMockFns.execute.mock.calls.filter(([query]) => + JSON.stringify(query).includes('statement_timeout') + ) + expect(guards).toHaveLength(1) + } + ) +}) diff --git a/apps/sim/lib/workspace-files/search/dispatcher.ts b/apps/sim/lib/workspace-files/search/dispatcher.ts index 3c9518a9fe1..91f4b6d1899 100644 --- a/apps/sim/lib/workspace-files/search/dispatcher.ts +++ b/apps/sim/lib/workspace-files/search/dispatcher.ts @@ -7,7 +7,8 @@ import { workspaceFiles, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { and, asc, @@ -30,6 +31,9 @@ import { runDetached } from '@/lib/core/utils/background' import type { DbTransaction } from '@/lib/db/types' import { FILE_SEARCH_BACKFILL_PAGE_SIZE, + FILE_SEARCH_DISPATCH_LOCK_TIMEOUT_MS, + FILE_SEARCH_DISPATCH_STATEMENT_TIMEOUT_MS, + FILE_SEARCH_DISPATCH_TRANSACTION_TIMEOUT_MS, FILE_SEARCH_INDEX_DISPATCH_WORKSPACES, FILE_SEARCH_INDEX_MAX_OUTSTANDING, FILE_SEARCH_INDEX_STALE_DISPATCH_MS, @@ -47,6 +51,41 @@ const logger = createLogger('WorkspaceFileSearchDispatcher') const DISPATCH_LOCK_NAME = 'workspace-file-search-dispatch' const BACKFILL_CURSOR_ID = 'workspace-file-search-v1' +async function runDispatchPhase(phase: string, operation: () => Promise): Promise { + const startedAt = Date.now() + logger.info('Workspace file search dispatch phase started', { phase }) + try { + const result = await operation() + logger.info('Workspace file search dispatch phase completed', { + phase, + durationMs: Date.now() - startedAt, + }) + return result + } catch (error) { + logger.error('Workspace file search dispatch phase failed', { + phase, + durationMs: Date.now() - startedAt, + code: getPostgresErrorCode(error), + error: truncate(getErrorMessage(error).split('\nparams: ')[0], 500), + }) + throw error + } +} + +/** Preparation must roll back before the worker's hard deadline. */ +async function configureDispatchTimeouts(tx: DbTransaction): Promise { + await tx.execute(sql` + SELECT + set_config('statement_timeout', ${`${FILE_SEARCH_DISPATCH_STATEMENT_TIMEOUT_MS}ms`}, true), + set_config('lock_timeout', ${`${FILE_SEARCH_DISPATCH_LOCK_TIMEOUT_MS}ms`}, true), + set_config( + 'transaction_timeout', + ${`${FILE_SEARCH_DISPATCH_TRANSACTION_TIMEOUT_MS}ms`}, + true + ) + `) +} + interface RevisionIdentity { fileId: string sourceContentUpdatedAt: Date @@ -276,7 +315,7 @@ async function claimQueuedWorkspaceJobs( const rows = await tx.execute<{ workspaceId: string fileId: string - sourceContentUpdatedAt: Date + sourceContentUpdatedAt: string }>(sql` WITH selected_workspace(workspace_id) AS ( VALUES ${workspaceValues} @@ -335,7 +374,7 @@ async function claimQueuedWorkspaceJobs( RETURNING search_index.workspace_id AS "workspaceId", search_index.file_id AS "fileId", - search_index.source_content_updated_at AS "sourceContentUpdatedAt" + search_index.source_content_updated_at AT TIME ZONE 'UTC' AS "sourceContentUpdatedAt" `) const remainingForWorkspace = tx @@ -384,50 +423,60 @@ async function claimQueuedWorkspaceJobs( } export async function prepareWorkspaceFileSearchDispatch(): Promise { - return db.transaction(async (tx) => { - const [lock] = await tx.execute<{ acquired: boolean }>( - sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${DISPATCH_LOCK_NAME}, 0)) AS acquired` - ) - if (!lock?.acquired) { - return { payloads: [], backfilledFiles: 0, reapedClaims: 0, lockAcquired: false } - } + return runDispatchPhase('prepare-transaction', () => + db.transaction(async (tx) => { + await runDispatchPhase('configure-timeouts', () => configureDispatchTimeouts(tx)) + return runDispatchPhase('prepare', async () => { + const [lock] = await tx.execute<{ acquired: boolean }>( + sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${DISPATCH_LOCK_NAME}, 0)) AS acquired` + ) + if (!lock?.acquired) { + return { payloads: [], backfilledFiles: 0, reapedClaims: 0, lockAcquired: false } + } - const now = new Date() - const backfilledFiles = await seedBackfillPage(tx, now) - const reapedClaims = await reapStaleClaims(tx, now) - const [{ active }] = await tx - .select({ active: count() }) - .from(workspaceFileSearchIndex) - .where( - and( - eq(workspaceFileSearchIndex.status, 'pending'), - isNotNull(workspaceFileSearchIndex.dispatchedAt) + const now = new Date() + const backfilledFiles = await runDispatchPhase('backfill', () => seedBackfillPage(tx, now)) + const reapedClaims = await runDispatchPhase('reap', () => reapStaleClaims(tx, now)) + const [{ active }] = await tx + .select({ active: count() }) + .from(workspaceFileSearchIndex) + .where( + and( + eq(workspaceFileSearchIndex.status, 'pending'), + isNotNull(workspaceFileSearchIndex.dispatchedAt) + ) + ) + const remainingGlobalCapacity = Math.max( + 0, + FILE_SEARCH_INDEX_MAX_OUTSTANDING - Number(active) ) - ) - const remainingGlobalCapacity = Math.max(0, FILE_SEARCH_INDEX_MAX_OUTSTANDING - Number(active)) - if (remainingGlobalCapacity === 0) { - return { payloads: [], backfilledFiles, reapedClaims, lockAcquired: true } - } + if (remainingGlobalCapacity === 0) { + return { payloads: [], backfilledFiles, reapedClaims, lockAcquired: true } + } - const workspaces = await tx - .select({ workspaceId: workspaceFileSearchDispatchQueue.workspaceId }) - .from(workspaceFileSearchDispatchQueue) - .orderBy( - sql`${workspaceFileSearchDispatchQueue.lastDispatchedAt} ASC NULLS FIRST`, - asc(workspaceFileSearchDispatchQueue.enqueuedAt), - asc(workspaceFileSearchDispatchQueue.workspaceId) - ) - .limit(Math.min(FILE_SEARCH_INDEX_DISPATCH_WORKSPACES, remainingGlobalCapacity)) - .for('update', { skipLocked: true }) + const workspaces = await tx + .select({ workspaceId: workspaceFileSearchDispatchQueue.workspaceId }) + .from(workspaceFileSearchDispatchQueue) + .orderBy( + sql`${workspaceFileSearchDispatchQueue.lastDispatchedAt} ASC NULLS FIRST`, + asc(workspaceFileSearchDispatchQueue.enqueuedAt), + asc(workspaceFileSearchDispatchQueue.workspaceId) + ) + .limit(Math.min(FILE_SEARCH_INDEX_DISPATCH_WORKSPACES, remainingGlobalCapacity)) + .for('update', { skipLocked: true }) - const payloads = await claimQueuedWorkspaceJobs( - tx, - workspaces.map((workspace) => workspace.workspaceId), - remainingGlobalCapacity, - now - ) - return { payloads, backfilledFiles, reapedClaims, lockAcquired: true } - }) + const payloads = await runDispatchPhase('claim', () => + claimQueuedWorkspaceJobs( + tx, + workspaces.map((workspace) => workspace.workspaceId), + remainingGlobalCapacity, + now + ) + ) + return { payloads, backfilledFiles, reapedClaims, lockAcquired: true } + }) + }) + ) } async function releaseDispatchClaims(payloads: readonly WorkspaceFileSearchIndexPayload[]) { @@ -437,20 +486,22 @@ async function releaseDispatchClaims(payloads: readonly WorkspaceFileSearchIndex fileId: payload.fileId, sourceContentUpdatedAt: new Date(payload.sourceContentUpdatedAt), })) - await db.transaction(async (tx) => { - const filter = revisionFilter(rows) - if (filter) { - await tx - .update(workspaceFileSearchIndex) - .set({ dispatchedAt: null, updatedAt: new Date() }) - .where(and(filter, eq(workspaceFileSearchIndex.status, 'pending'))) - } - await enqueueWorkspaces( - tx, - rows.map((row) => row.workspaceId), - new Date() - ) - }) + await runDispatchPhase('release-claims', () => + db.transaction(async (tx) => { + const filter = revisionFilter(rows) + if (filter) { + await tx + .update(workspaceFileSearchIndex) + .set({ dispatchedAt: null, updatedAt: new Date() }) + .where(and(filter, eq(workspaceFileSearchIndex.status, 'pending'))) + } + await enqueueWorkspaces( + tx, + rows.map((row) => row.workspaceId), + new Date() + ) + }) + ) } async function dispatchPreparedJobs( @@ -497,7 +548,9 @@ export async function dispatchWorkspaceFileSearchIndexJobs(): Promise + dispatchPreparedJobs(prepared.payloads) + ) return { dispatchedFiles, backfilledFiles: prepared.backfilledFiles, @@ -505,11 +558,21 @@ export async function dispatchWorkspaceFileSearchIndexJobs(): Promise { }) }) +describe('getStaticProviderModels', () => { + it('retains public built-in models after private models are discovered', () => { + const originalModels = PROVIDER_DEFINITIONS.fireworks.models + const publicModels = getStaticProviderModels('fireworks') + try { + updateFireworksModels(['fireworks/private-test-model']) + + expect(publicModels.length).toBeGreaterThan(0) + expect(getProviderModels('fireworks')).toContain('fireworks/private-test-model') + expect(getStaticProviderModels('fireworks')).toEqual(publicModels) + } finally { + PROVIDER_DEFINITIONS.fireworks.models = originalModels + } + }) + + it("excludes discovered names even when they match another provider's public model", () => { + const originalModels = PROVIDER_DEFINITIONS.ollama.models + try { + updateOllamaModels(['private-local-model', 'fireworks/glm-5.2']) + + expect(getStaticProviderModels('ollama')).toEqual([]) + } finally { + PROVIDER_DEFINITIONS.ollama.models = originalModels + } + }) + + it('returns no models for an unknown provider', () => { + expect(getStaticProviderModels('unknown-provider')).toEqual([]) + }) +}) + describe('isModelDeprecated', () => { it('returns true for a catalogued deprecated model (case-insensitive)', () => { const id = firstDeprecatedModelId() diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index edd140d3b28..21d4203639d 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -4989,9 +4989,8 @@ interface ModelCatalogEntry { /** * Lowercased model ID → catalog position metadata, built once from the static - * provider catalog. Dynamic providers contribute nothing here because their model - * lists are populated at runtime (not at module load), and only catalog models are - * ever reordered by release date. + * provider catalog, including built-in models of dynamic providers. Models added + * by runtime discovery are excluded. */ const MODEL_CATALOG_INDEX: Map = new Map( Object.entries(PROVIDER_DEFINITIONS).flatMap(([providerId, provider]) => @@ -5009,6 +5008,13 @@ const MODEL_CATALOG_INDEX: Map = new Map( ) ) +/** Returns built-in public models, excluding names added by runtime discovery. */ +export function getStaticProviderModels(providerId: string): ModelDefinition[] { + return (PROVIDER_DEFINITIONS[providerId]?.models ?? []).filter( + (model) => MODEL_CATALOG_INDEX.get(model.id.toLowerCase())?.providerId === providerId + ) +} + /** * Reorders model IDs so that, within each provider, newer models (by release date) * come first — while preserving the caller's existing provider grouping order. The diff --git a/apps/sim/public/library/ai-personal-assistant-vs-ai-agent-builder/cover.jpg b/apps/sim/public/library/ai-personal-assistant-vs-ai-agent-builder/cover.jpg new file mode 100644 index 00000000000..96c0024fa8b Binary files /dev/null and b/apps/sim/public/library/ai-personal-assistant-vs-ai-agent-builder/cover.jpg differ diff --git a/apps/sim/scripts/block-registry-snapshot.test.ts b/apps/sim/scripts/block-registry-snapshot.test.ts new file mode 100644 index 00000000000..331d472ad0a --- /dev/null +++ b/apps/sim/scripts/block-registry-snapshot.test.ts @@ -0,0 +1,133 @@ +/** @vitest-environment node */ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readBlockRegistryAtRef } from '@/scripts/block-registry-snapshot' + +let root: string + +function write(path: string, content: string) { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, content) +} + +function git(...args: string[]) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' }).trim() +} + +function commit() { + git('add', '.') + git( + '-c', + 'user.name=Test', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + '-c', + 'user.email=test@example.test', + 'commit', + '-m', + 'Baseline fixture' + ) + return git('rev-parse', 'HEAD') +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'registry-snapshot-test-')) + git('init', '--quiet') + write('.gitignore', 'node_modules\n') + write('apps/sim/package.json', JSON.stringify({ name: '@sim/app', type: 'module' })) + write( + 'apps/sim/tsconfig.json', + JSON.stringify({ compilerOptions: { paths: { '@/*': ['./*'] } } }) + ) +}) + +afterEach(() => rmSync(root, { recursive: true, force: true })) + +describe('readBlockRegistryAtRef', () => { + it('reads effective IDs from spreads, local arrays, helpers, and derived blocks at the base revision', () => { + write( + 'apps/sim/blocks/registry.ts', + ` +import { sharedFields } from '@sim/fields' +import { triggerFields } from '@/triggers/fields' +const localFields = [{ id: 'operation', options: [{ id: 'nested-option' }] }, { id: 'encoding' }] +const LegacyBlock = { type: 'legacy', subBlocks: localFields } satisfies { type: string; subBlocks: { id: string }[] } +const CurrentBlock = { ...LegacyBlock, type: 'current', subBlocks: LegacyBlock.subBlocks.filter(field => field.id !== 'encoding') } +const makeFields = () => [...sharedFields, ...triggerFields] +const SpreadBlock = { type: 'spread', subBlocks: [...localFields, ...makeFields()] } +export const getBlockRegistry = () => ({ legacy: LegacyBlock, current: CurrentBlock, spread: SpreadBlock }) +` + ) + write('apps/sim/triggers/fields.ts', "export const triggerFields = [{ id: 'trigger' }]\n") + write( + 'packages/fields/package.json', + JSON.stringify({ name: '@sim/fields', type: 'module', exports: './index.ts' }) + ) + write('packages/fields/index.ts', "export const sharedFields = [{ id: 'shared' }]\n") + const base = commit() + mkdirSync(join(root, 'node_modules/@sim'), { recursive: true }) + symlinkSync(join(root, 'packages/fields'), join(root, 'node_modules/@sim/fields'), 'dir') + write( + 'packages/fields/index.ts', + "export const sharedFields = [{ id: 'changed-after-base' }]\n" + ) + write('apps/sim/triggers/fields.ts', 'export const triggerFields = []\n') + const statusBefore = git('status', '--porcelain') + + expect(readBlockRegistryAtRef(root, base)).toEqual({ + legacy: ['operation', 'encoding'], + current: ['operation'], + spread: ['operation', 'encoding', 'shared', 'trigger'], + }) + expect(git('status', '--porcelain')).toBe(statusBefore) + expect(readFileSync(join(root, 'packages/fields/index.ts'), 'utf8')).toContain( + 'changed-after-base' + ) + }) + + it('keeps installed third-party dependencies available without treating their output as registry JSON', () => { + write( + 'apps/sim/blocks/registry.ts', + ` +import { field } from 'fixture-provider' +console.log('Registry initialization diagnostic') +export const getBlockRegistry = () => ({ block: { type: 'block', subBlocks: [field] } }) +` + ) + const base = commit() + write( + 'node_modules/fixture-provider/package.json', + JSON.stringify({ name: 'fixture-provider', type: 'module', exports: './index.js' }) + ) + write( + 'node_modules/fixture-provider/index.js', + "export const field = { id: 'installed-field' }\n" + ) + + expect(readBlockRegistryAtRef(root, base)).toEqual({ block: ['installed-field'] }) + }) + + it('fails instead of returning partial IDs when a derived definition cannot load', () => { + write( + 'apps/sim/blocks/registry.ts', + ` +import { missingFields } from './missing' +export const getBlockRegistry = () => ({ block: { type: 'block', subBlocks: missingFields } }) +` + ) + const base = commit() + expect(() => readBlockRegistryAtRef(root, base)).toThrow() + }) + + it('fails when the requested base revision is unavailable', () => { + write('apps/sim/blocks/registry.ts', 'export const getBlockRegistry = () => ({})\n') + commit() + expect(() => readBlockRegistryAtRef(root, 'missing-base')).toThrow() + }) +}) diff --git a/apps/sim/scripts/block-registry-snapshot.ts b/apps/sim/scripts/block-registry-snapshot.ts new file mode 100644 index 00000000000..0fa0d9de387 --- /dev/null +++ b/apps/sim/scripts/block-registry-snapshot.ts @@ -0,0 +1,134 @@ +import { execFileSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve, sep } from 'node:path' +import { z } from 'zod' + +const registryIdsSchema = z.record(z.string().min(1), z.array(z.string().min(1))) + +interface WorkspacePackage { + name: string + path: string + relativePath: string +} + +function readWorkspacePackages(snapshot: string): WorkspacePackage[] { + const workspaces: WorkspacePackage[] = [] + for (const group of ['apps', 'packages']) { + const directory = join(snapshot, group) + if (!existsSync(directory)) continue + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const relativePath = join(group, entry.name) + const path = join(snapshot, relativePath) + const manifestPath = join(path, 'package.json') + if (!existsSync(manifestPath)) continue + const { name } = z + .object({ name: z.string().min(1) }) + .parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) + workspaces.push({ name, path, relativePath }) + } + } + return workspaces +} + +function linkInstalledDependencies( + source: string, + target: string, + root: string, + workspaceNames: Set, + scope = '' +) { + if (!existsSync(source)) return + mkdirSync(target, { recursive: true }) + for (const entry of readdirSync(source, { withFileTypes: true })) { + const sourcePath = join(source, entry.name) + const targetPath = join(target, entry.name) + if (entry.name.startsWith('@')) { + linkInstalledDependencies(sourcePath, targetPath, root, workspaceNames, `${entry.name}/`) + continue + } + if (workspaceNames.has(scope + entry.name)) continue + const resolved = realpathSync(sourcePath) + if (['apps', 'packages'].some((group) => resolved.startsWith(join(root, group) + sep))) continue + symlinkSync(sourcePath, targetPath, 'dir') + } +} + +function linkWorkspaceDependencies(root: string, snapshot: string) { + const workspaces = readWorkspacePackages(snapshot) + const workspaceNames = new Set(workspaces.map(({ name }) => name)) + const modules = join(snapshot, 'node_modules') + linkInstalledDependencies(join(root, 'node_modules'), modules, root, workspaceNames) + for (const workspace of workspaces) { + const packageLink = resolve(modules, workspace.name) + if (!packageLink.startsWith(modules + sep)) { + throw new Error(`Invalid workspace package name: ${workspace.name}`) + } + mkdirSync(dirname(packageLink), { recursive: true }) + symlinkSync(workspace.path, packageLink, 'dir') + linkInstalledDependencies( + join(root, workspace.relativePath, 'node_modules'), + join(workspace.path, 'node_modules'), + root, + workspaceNames + ) + } +} + +/** + * Reads effective subblock IDs from the base revision's complete source tree. + * Workspace packages resolve inside the snapshot; only installed third-party + * dependencies are shared. A failed import or missing revision fails the audit. + */ +export function readBlockRegistryAtRef(root: string, ref: string): Record { + root = realpathSync(root) + const gitOptions = { cwd: root, encoding: 'utf8' as const, stdio: 'pipe' as const } + const commit = execFileSync( + 'git', + ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], + gitOptions + ).trim() + const temporary = mkdtempSync(join(tmpdir(), 'sim-block-registry-')) + try { + const archive = join(temporary, 'source.tar') + const snapshot = join(temporary, 'source') + mkdirSync(snapshot) + execFileSync('git', ['archive', '--format=tar', `--output=${archive}`, commit], gitOptions) + execFileSync('tar', ['-xf', archive, '-C', snapshot]) + linkWorkspaceDependencies(root, snapshot) + + const script = join(snapshot, 'apps/sim/.block-registry-snapshot.ts') + const output = join(temporary, 'ids.json') + writeFileSync( + script, + ` +import { writeFileSync } from 'node:fs' +import { getBlockRegistry } from '@/blocks/registry' + +const entries = Object.values(getBlockRegistry()).map(block => [block.type, block.subBlocks.map(field => field.id)]) +writeFileSync(process.argv[2], JSON.stringify(Object.fromEntries(entries))) +` + ) + execFileSync('bun', ['--no-env-file', 'run', script, output], { + cwd: join(snapshot, 'apps/sim'), + encoding: 'utf8', + stdio: 'pipe', + timeout: 60_000, + maxBuffer: 4 * 1024 * 1024, + }) + return registryIdsSchema.parse(JSON.parse(readFileSync(output, 'utf8'))) + } finally { + rmSync(temporary, { recursive: true, force: true }) + } +} diff --git a/apps/sim/scripts/check-block-registry.ts b/apps/sim/scripts/check-block-registry.ts index 6264d8ebaad..8c453be49ca 100644 --- a/apps/sim/scripts/check-block-registry.ts +++ b/apps/sim/scripts/check-block-registry.ts @@ -24,126 +24,37 @@ * bun run apps/sim/scripts/check-block-registry.ts origin/main */ -import { execSync } from 'child_process' +import { execFileSync } from 'node:child_process' import { SUBBLOCK_ID_MIGRATIONS } from '@/lib/workflows/migrations/subblock-migrations' -import { getAllBlocks, getBlock, getBlockMeta } from '@/blocks/registry' +import { getAllBlocks, getBlock, getBlockMeta, getBlockRegistry } from '@/blocks/registry' +import { readBlockRegistryAtRef } from '@/scripts/block-registry-snapshot' import { getToolParams } from '@/tools/metadata' const baseRef = process.argv[2] || 'HEAD~1' -const gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim() +const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8' }).trim() const gitOpts = { encoding: 'utf-8' as const, cwd: gitRoot } type IdMap = Record> -/** - * Extracts subblock IDs from the `subBlocks: [ ... ]` section of a block - * definition. Only grabs the top-level `id:` of each subblock object — - * ignores nested IDs inside `options`, `columns`, etc. - */ -function extractSubBlockIds(source: string): string[] { - const startIdx = source.indexOf('subBlocks:') - if (startIdx === -1) return [] - - const bracketStart = source.indexOf('[', startIdx) - if (bracketStart === -1) return [] - - const ids: string[] = [] - let braceDepth = 0 - let bracketDepth = 0 - let i = bracketStart + 1 - bracketDepth = 1 - - while (i < source.length && bracketDepth > 0) { - const ch = source[i] - - if (ch === '[') bracketDepth++ - else if (ch === ']') { - bracketDepth-- - if (bracketDepth === 0) break - } else if (ch === '{') { - braceDepth++ - if (braceDepth === 1) { - const ahead = source.slice(i, i + 200) - const idMatch = ahead.match(/{\s*(?:\/\/[^\n]*\n\s*)*id:\s*['"]([^'"]+)['"]/) - if (idMatch) { - ids.push(idMatch[1]) - } - } - } else if (ch === '}') { - braceDepth-- - } - - i++ - } - - return ids -} - function getCurrentIds(): IdMap { const map: IdMap = {} - for (const block of getAllBlocks()) { + for (const block of Object.values(getBlockRegistry())) { map[block.type] = new Set(block.subBlocks.map((sb) => sb.id)) } return map } -type PreviousIdsResult = - | { kind: 'skip'; reason: string } - | { kind: 'noop' } - | { kind: 'ok'; map: IdMap } - -function getPreviousIds(): PreviousIdsResult { - const registryPath = 'apps/sim/blocks/registry.ts' - const blocksDir = 'apps/sim/blocks/blocks' - - let hasChanges = false - try { - const diff = execSync( - `git diff --name-only ${baseRef} -- ${registryPath} ${blocksDir}`, - gitOpts - ).trim() - hasChanges = diff.length > 0 - } catch { - return { kind: 'skip', reason: 'Could not diff against base ref' } - } +function getPreviousIds(): IdMap | null { + const changed = execFileSync( + 'git', + ['diff', '--name-only', baseRef, '--', 'apps/sim/blocks', 'apps/sim/triggers'], + gitOpts + ).trim() + if (!changed) return null - if (!hasChanges) { - return { kind: 'noop' } - } - - const map: IdMap = {} - - try { - const blockFiles = execSync(`git ls-tree -r --name-only ${baseRef} -- ${blocksDir}`, gitOpts) - .trim() - .split('\n') - .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')) - - for (const filePath of blockFiles) { - let content: string - try { - content = execSync(`git show ${baseRef}:${filePath}`, gitOpts) - } catch { - continue - } - - const typeMatch = content.match( - /BlockConfig(?:<[^>]*>)?\s*=\s*\{[\s\S]*?type:\s*['"]([^'"]+)['"]/ - ) - if (!typeMatch) continue - const blockType = typeMatch[1] - - const ids = extractSubBlockIds(content) - if (ids.length === 0) continue - - map[blockType] = new Set(ids) - } - } catch (err) { - return { kind: 'skip', reason: `Could not read previous block files from ${baseRef}: ${err}` } - } - - return { kind: 'ok', map } + const previous = readBlockRegistryAtRef(gitRoot, baseRef) + return Object.fromEntries(Object.entries(previous).map(([type, ids]) => [type, new Set(ids)])) } type CheckResult = @@ -154,10 +65,7 @@ type CheckResult = function checkSubblockIdStability(): CheckResult { const previous = getPreviousIds() - if (previous.kind === 'skip') { - return { kind: 'skip', message: `${previous.reason} — skipping subblock ID stability check` } - } - if (previous.kind === 'noop') { + if (previous === null) { return { kind: 'skip', message: 'No block definition changes detected — skipping subblock ID stability check', @@ -167,7 +75,7 @@ function checkSubblockIdStability(): CheckResult { const current = getCurrentIds() const errors: string[] = [] - for (const [blockType, prevIds] of Object.entries(previous.map)) { + for (const [blockType, prevIds] of Object.entries(previous)) { const currIds = current[blockType] if (!currIds) continue diff --git a/apps/sim/tools/coda/add_custom_domain.ts b/apps/sim/tools/coda/add_custom_domain.ts new file mode 100644 index 00000000000..b0bea6607f2 --- /dev/null +++ b/apps/sim/tools/coda/add_custom_domain.ts @@ -0,0 +1,47 @@ +import type { CodaCustomDomainParams, CodaCustomDomainResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + CUSTOM_DOMAIN_PARAM, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaAddCustomDomainTool: ToolConfig = + { + id: 'coda_add_custom_domain', + name: 'Coda Add Custom Domain', + description: + 'Connect a custom domain to a published Coda doc. Requires a Coda plan with custom domains.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM, customDocDomain: CUSTOM_DOMAIN_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'domains')), + method: 'POST', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => ({ customDocDomain: String(params.customDocDomain ?? '').trim() }), + }, + + transformResponse: async (_response, params) => ({ + success: true, + output: { + docId: String(params?.docId ?? '').trim(), + customDocDomain: String(params?.customDocDomain ?? '').trim(), + }, + }), + + outputs: { + docId: { type: 'string', description: 'ID of the doc' }, + customDocDomain: { type: 'string', description: 'The custom domain that was added' }, + }, + } diff --git a/apps/sim/tools/coda/add_permission.ts b/apps/sim/tools/coda/add_permission.ts new file mode 100644 index 00000000000..4ea78b6a6a1 --- /dev/null +++ b/apps/sim/tools/coda/add_permission.ts @@ -0,0 +1,107 @@ +import type { CodaAddPermissionParams, CodaAddPermissionResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + optionalTrimmed, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +const PRINCIPAL_FIELD = { + email: 'email', + group: 'groupId', + domain: 'domain', + workspace: 'workspaceId', +} as const + +export const codaAddPermissionTool: ToolConfig = + { + id: 'coda_add_permission', + name: 'Coda Share Doc', + description: + 'Share a Coda doc with a user, group, domain, workspace, or anyone with the link. Sharing with an email sends a notification unless suppressed.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + access: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Access level to grant: "readonly", "comment", or "write"', + }, + principalType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Who to share with: "email", "group", "domain", "workspace", or "anyone"', + }, + principal: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Email address, group ID, domain, or workspace ID matching principalType. Not used for "anyone".', + }, + suppressEmail: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Do not send a sharing notification email', + }, + }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'acl', 'permissions')), + method: 'POST', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const type = params.principalType + let principal: Record + if (type === 'anyone') { + principal = { type } + } else { + const field = Object.hasOwn(PRINCIPAL_FIELD, type) + ? PRINCIPAL_FIELD[type as keyof typeof PRINCIPAL_FIELD] + : undefined + if (!field) { + throw new Error('principalType must be one of: email, group, domain, workspace, anyone') + } + const value = optionalTrimmed(params.principal) + if (!value) throw new Error(`principal is required when principalType is "${type}"`) + principal = { type, [field]: value } + } + return { + access: params.access, + principal, + ...(typeof params.suppressEmail === 'boolean' + ? { suppressEmail: params.suppressEmail } + : {}), + } + }, + }, + + transformResponse: async (_response, params) => ({ + success: true, + output: { + docId: String(params?.docId ?? '').trim(), + access: params?.access ?? '', + principalType: params?.principalType ?? '', + }, + }), + + outputs: { + docId: { type: 'string', description: 'ID of the shared doc' }, + access: { type: 'string', description: 'Access level granted' }, + principalType: { type: 'string', description: 'Type of principal the doc was shared with' }, + }, + } diff --git a/apps/sim/tools/coda/change_user_role.ts b/apps/sim/tools/coda/change_user_role.ts new file mode 100644 index 00000000000..46251403548 --- /dev/null +++ b/apps/sim/tools/coda/change_user_role.ts @@ -0,0 +1,69 @@ +import type { CodaChangeUserRoleParams, CodaChangeUserRoleResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + codaPath, + WORKSPACE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaChangeUserRoleTool: ToolConfig< + CodaChangeUserRoleParams, + CodaChangeUserRoleResponse +> = { + id: 'coda_change_user_role', + name: 'Coda Change User Role', + description: + 'Change the workspace role of a Coda user. Requires Admin access in a workspace that belongs to an organization.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + workspaceId: WORKSPACE_ID_PARAM, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address of the workspace member', + }, + newRole: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New role: "Admin", "DocMaker", or "Editor"', + }, + }, + + request: { + url: (params) => + buildCodaUrl(codaPath('workspaces', [params.workspaceId, 'workspaceId'], 'users', 'role')), + method: 'POST', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => ({ email: String(params.email ?? '').trim(), newRole: params.newRole }), + }, + + transformResponse: async (response, params) => { + const data = (await response.json()) as { roleChangedAt: string } + return { + success: true, + output: { + email: String(params?.email ?? '').trim(), + newRole: params?.newRole ?? '', + roleChangedAt: data.roleChangedAt, + }, + } + }, + + outputs: { + email: { type: 'string', description: 'Email address of the member' }, + newRole: { type: 'string', description: 'Role assigned' }, + roleChangedAt: { type: 'string', description: 'When the role change took effect' }, + }, +} diff --git a/apps/sim/tools/coda/coda.live.test.ts b/apps/sim/tools/coda/coda.live.test.ts new file mode 100644 index 00000000000..9f5f01cbaa7 --- /dev/null +++ b/apps/sim/tools/coda/coda.live.test.ts @@ -0,0 +1,1135 @@ +/** + * Live end-to-end verification of the Coda integration against a real Coda account. + * + * Skipped unless `CODA_LIVE=1` and `CODA_API_TOKEN` are set, so it is inert in CI. Every + * operation runs the way a workflow run does: block field values go through the block's + * `tools.config.params`, then the real `executeTool` pipeline (request building, fetch, + * error extraction, `transformResponse`). Each output is checked against the tool's + * declared output schema. Selector attachments and the credential validator also run live. + * + * The suite only mutates resources it creates (a folder, a doc, and a copy of Coda's public + * API guide doc, whose button it pushes) and deletes them at the end. Sharing with an email + * address runs only when `CODA_LIVE_SHARE_EMAIL` is set; notifications are suppressed. + * + * CODA_LIVE=1 CODA_API_TOKEN=... ../../node_modules/.bin/vitest run tools/coda/coda.live.test.ts + * + * @vitest-environment node + */ +import { sleep } from '@sim/utils/helpers' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.unmock('@/tools/registry') + +import { validateCodaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/coda' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { codaSelectorAttachments } from '@/lib/selectors/server/providers/coda' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' +import type { SelectorContext, SelectorRequest } from '@/lib/selectors/types' +import { CodaBlock } from '@/blocks/blocks/coda' +import { executeTool } from '@/tools' +import { tools as toolRegistry } from '@/tools/registry' +import type { OutputProperty } from '@/tools/types' + +const LIVE = process.env.CODA_LIVE === '1' && Boolean(process.env.CODA_API_TOKEN) +const token = process.env.CODA_API_TOKEN ?? '' +const shareEmail = process.env.CODA_LIVE_SHARE_EMAIL +const TIMEOUT = 300_000 + +interface RunResult { + success: boolean + output: Record + error?: string +} + +function log(label: string, value: unknown) { + const rendered = typeof value === 'string' ? value : JSON.stringify(value) + process.stdout.write(` [coda-live] ${label}: ${rendered?.slice(0, 1200)}\n`) +} + +/** Recursively checks a tool output against its declared output schema. */ +function schemaViolations( + value: unknown, + schema: OutputProperty, + path: string, + violations: string[] +): void { + if (value === null) { + if (!schema.nullable) violations.push(`${path}: null but not declared nullable`) + return + } + if (value === undefined) { + if (!schema.optional) violations.push(`${path}: missing but not declared optional`) + return + } + switch (schema.type) { + case 'string': + if (typeof value !== 'string') + violations.push(`${path}: expected string, got ${typeof value}`) + return + case 'number': + if (typeof value !== 'number') + violations.push(`${path}: expected number, got ${typeof value}`) + return + case 'boolean': + if (typeof value !== 'boolean') + violations.push(`${path}: expected boolean, got ${typeof value}`) + return + case 'array': { + if (!Array.isArray(value)) { + violations.push(`${path}: expected array`) + return + } + if (schema.items) { + value.forEach((item, index) => + schemaViolations(item, schema.items as OutputProperty, `${path}[${index}]`, violations) + ) + } + return + } + case 'object': { + if (typeof value !== 'object' || Array.isArray(value)) { + violations.push(`${path}: expected object`) + return + } + if (schema.properties) { + objectViolations(value as Record, schema.properties, path, violations) + } + return + } + default: + return + } +} + +function objectViolations( + value: Record, + properties: Record, + path: string, + violations: string[] +) { + for (const [key, property] of Object.entries(properties)) { + schemaViolations(value[key], property, `${path}.${key}`, violations) + } + for (const key of Object.keys(value)) { + if (!(key in properties)) violations.push(`${path}.${key}: not declared in outputs`) + } +} + +/** + * Runs one block operation exactly like the generic block handler: the serialized field + * values are merged with `tools.config.params`, and the selected tool runs through + * `executeTool` with the credential's resolved access token. + */ +async function run(values: Record): Promise { + const config = CodaBlock.tools.config! + const toolId = config.tool!(values) as string + const mapped = config.params ? config.params(values) : {} + const result = (await executeTool(toolId, { + ...values, + ...mapped, + accessToken: token, + })) as RunResult + if (result.success) { + const tool = toolRegistry[toolId] + const violations: string[] = [] + objectViolations(result.output, tool.outputs ?? {}, toolId, violations) + expect(violations, `${toolId} output schema`).toEqual([]) + } + log(`${values.operation}`, result.success ? result.output : `ERROR ${result.error}`) + return result +} + +async function runOk(values: Record): Promise> { + const result = await run(values) + expect(result.error, `${values.operation} failed`).toBeUndefined() + expect(result.success).toBe(true) + return result.output +} + +async function waitForMutation(requestId: string) { + for (let attempt = 0; attempt < 60; attempt++) { + const status = await runOk({ operation: 'get_mutation_status', mutationRequestId: requestId }) + if (status.completed) return status + await sleep(2_000) + } + throw new Error(`mutation ${requestId} did not complete`) +} + +/** New docs return 409 until Coda finishes provisioning them for the API. */ +async function waitForDocReady(docId: string) { + for (let attempt = 0; attempt < 60; attempt++) { + const result = await executeTool('coda_list_pages', { accessToken: token, docId }) + if (result.success) return + await sleep(2_000) + } + throw new Error(`doc ${docId} never became accessible`) +} + +async function waitFor(label: string, probe: () => Promise): Promise { + for (let attempt = 0; attempt < 45; attempt++) { + const value = await probe() + if (value !== undefined) return value + await sleep(2_000) + } + throw new Error(`timed out waiting for ${label}`) +} + +function selectorArgs( + selectorKey: ExecuteServerSelectorArgs['selectorKey'], + request: SelectorRequest, + context: SelectorContext = {} +): ExecuteServerSelectorArgs { + return { + selectorKey, + context: { oauthCredential: 'live-credential', ...context }, + request, + scope: { kind: 'workspace', workspaceId: 'workspace-live' }, + workspaceId: 'workspace-live', + principal: { kind: 'session', userId: 'user-live', sessionId: 'session-live' }, + requesterUserId: 'user-live', + credential: { suppliedId: 'live-credential', fixedToken: token, providerId: 'coda' }, + references: new Map(), + protectedValues: createSelectorProtectedValues(), + } +} + +const state: { + workspaceId?: string + loginId?: string + myDocsFolderId?: string + folderId?: string + docId?: string + docBrowserLink?: string + homePageId?: string + subPageId?: string + embedPageId?: string + syncPageId?: string + tableId?: string + nameColumnId?: string + statusColumnId?: string + rowIds: string[] + permissionId?: string + published?: boolean + copyDocId?: string +} = { rowIds: [] } + +describe.skipIf(!LIVE).sequential('coda live end-to-end', () => { + beforeAll(() => { + vi.unstubAllGlobals() + expect(vi.isMockFunction(globalThis.fetch)).toBe(false) + }) + + afterAll(async () => { + if (!LIVE) return + if (state.docId) + await executeTool('coda_delete_doc', { accessToken: token, docId: state.docId }) + if (state.copyDocId) { + await executeTool('coda_delete_doc', { accessToken: token, docId: state.copyDocId }) + } + if (state.folderId) { + await sleep(3_000) + await executeTool('coda_delete_folder', { accessToken: token, folderId: state.folderId }) + } + }, TIMEOUT) + + it( + 'validates the token like the credential connect flow', + async () => { + const result = await validateCodaServiceAccount({ apiToken: token }) + log('validator', result) + expect(result.principal).toMatchObject({ kind: 'user' }) + await expect( + validateCodaServiceAccount({ apiToken: 'not-a-real-token' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }, + TIMEOUT + ) + + it( + 'reads the account, categories, and folders', + async () => { + const me = await runOk({ operation: 'whoami' }) + state.workspaceId = me.workspace.id + state.loginId = me.loginId + const categories = await runOk({ operation: 'list_categories' }) + expect(categories.categories.length).toBeGreaterThan(0) + const folders = await runOk({ operation: 'list_folders', limit: '50' }) + state.myDocsFolderId = folders.folders[0]?.id + expect(state.myDocsFolderId).toBeTruthy() + await runOk({ + operation: 'list_folders', + workspaceFilter: state.workspaceId, + starred: 'false', + }) + await runOk({ operation: 'get_folder', folderId: state.myDocsFolderId }) + await runOk({ operation: 'get_analytics_last_updated' }) + }, + TIMEOUT + ) + + it( + 'creates, updates, reads, and lists a folder', + async () => { + const created = await runOk({ + operation: 'create_folder', + workspaceId: state.workspaceId, + folderName: ' Sim Coda E2E ', + folderDescription: 'Created by the Sim live test', + }) + state.folderId = created.folder.id + expect(created.folder.name).toBe('Sim Coda E2E') + const updated = await runOk({ + operation: 'update_folder', + folderId: state.folderId, + folderName: 'Sim Coda E2E (renamed)', + folderDescription: '', + }) + expect(updated.folder.id).toBe(state.folderId) + const renamed = await waitFor('folder rename', async () => { + const read = await runOk({ operation: 'get_folder', folderId: state.folderId }) + return read.folder.name === 'Sim Coda E2E (renamed)' ? read.folder : undefined + }) + expect(renamed.description).toBe('Created by the Sim live test') + await runOk({ + operation: 'list_folder_children', + folderId: state.myDocsFolderId, + limit: '10', + }) + }, + TIMEOUT + ) + + it( + 'creates a doc with an initial HTML page and table, then reads and updates it', + async () => { + const created = await runOk({ + operation: 'create_doc', + docTitle: 'Sim Coda E2E Doc', + folderId: state.folderId, + timezone: 'America/Los_Angeles', + pageName: 'Home', + pageSubtitle: 'Live test home', + iconName: 'rocket', + pageType: 'canvas', + contentFormat: 'html', + pageContent: + '

Tasks

Intro paragraph

NameStatus
SeedOpen
', + }) + state.docId = created.doc.id + state.docBrowserLink = created.doc.browserLink + expect(created.doc.folder?.id).toBe(state.folderId) + await waitForDocReady(state.docId!) + + const doc = await runOk({ operation: 'get_doc', docId: state.docId }) + expect(doc.doc.name).toBe('Sim Coda E2E Doc') + await runOk({ operation: 'update_doc', docId: state.docId, docTitle: 'Sim Coda E2E Doc v2' }) + await waitFor('doc rename', async () => { + const again = await runOk({ operation: 'get_doc', docId: state.docId }) + return again.doc.name === 'Sim Coda E2E Doc v2' ? true : undefined + }) + await runOk({ operation: 'update_doc', docId: state.docId, iconName: 'rocket' }) + const listed = await runOk({ + operation: 'list_docs', + docSearch: 'Sim Coda E2E', + isOwner: true, + folderId: state.folderId, + workspaceFilter: state.workspaceId, + starred: 'any', + limit: '5', + }) + expect(listed.docs.map((d: { id: string }) => d.id)).toContain(state.docId) + await runOk({ operation: 'list_docs', sourceDoc: state.docId, inGallery: false }) + }, + TIMEOUT + ) + + it( + 'lists docs, pages, tables, and folders through the dropdown selectors', + async () => { + const docs = await codaSelectorAttachments['coda.docs'].execute( + selectorArgs('coda.docs', { kind: 'list', search: 'Sim Coda E2E' }) + ) + log('selector coda.docs', docs) + expect(docs.kind === 'list' && docs.items.some((item) => item.id === state.docId)).toBe(true) + const docDetail = await codaSelectorAttachments['coda.docs'].execute( + selectorArgs('coda.docs', { kind: 'detail', id: state.docId! }) + ) + expect(docDetail).toMatchObject({ kind: 'detail', item: { id: state.docId } }) + + const pages = await codaSelectorAttachments['coda.pages'].execute( + selectorArgs('coda.pages', { kind: 'list' }, { docId: state.docId }) + ) + log('selector coda.pages', pages) + expect(pages.kind === 'list' && pages.items.length).toBeGreaterThan(0) + if (pages.kind === 'list') state.homePageId = pages.items[0].id + + const tables = await codaSelectorAttachments['coda.tables'].execute( + selectorArgs('coda.tables', { kind: 'list' }, { docId: state.docId }) + ) + log('selector coda.tables', tables) + if (tables.kind === 'list') state.tableId = tables.items[0]?.id + expect(state.tableId).toBeTruthy() + + const folders = await codaSelectorAttachments['coda.folders'].execute( + selectorArgs('coda.folders', { kind: 'list' }) + ) + expect(folders.kind === 'list' && folders.items.some((f) => f.id === state.folderId)).toBe( + true + ) + await expect( + codaSelectorAttachments['coda.pages'].execute( + selectorArgs( + 'coda.pages', + { kind: 'detail', id: 'canvas-doesnotexist' }, + { + docId: state.docId, + } + ) + ) + ).resolves.toEqual({ kind: 'detail', item: null }) + }, + TIMEOUT + ) + + it( + 'creates, updates, reads, exports, and deletes pages and content', + async () => { + const home = await runOk({ + operation: 'get_page', + docId: state.docId, + pageId: state.homePageId, + }) + expect(home.page.subtitle).toBe('Live test home') + + const sub = await runOk({ + operation: 'create_page', + docId: state.docId, + pageName: 'Child page', + parentPageId: state.homePageId, + pageType: 'canvas', + contentFormat: 'markdown', + pageContent: '# Child\n\n- one\n- two', + pageSubtitle: 'child subtitle', + iconName: 'star', + }) + state.subPageId = sub.pageId + await waitForMutation(sub.requestId) + + const embed = await runOk({ + operation: 'create_page', + docId: state.docId, + pageName: 'Embed page', + pageType: 'embed', + embedUrl: 'https://example.com', + renderMethod: 'standard', + }) + state.embedPageId = embed.pageId + await waitForMutation(embed.requestId) + + const sync = await run({ + operation: 'create_page', + docId: state.docId, + pageName: 'Sync page', + pageType: 'syncPage', + syncSourceDocId: state.docId, + syncMode: 'page', + syncSourcePageId: state.subPageId, + includeSubpages: false, + }) + if (sync.success) { + state.syncPageId = sync.output.pageId + await waitForMutation(sync.output.requestId) + } + + const pages = await runOk({ operation: 'list_pages', docId: state.docId, limit: '50' }) + expect(pages.pages.map((p: { id: string }) => p.id)).toContain(state.subPageId) + const child = await runOk({ + operation: 'get_page', + docId: state.docId, + pageId: state.subPageId, + }) + expect(child.page.parent?.id).toBe(state.homePageId) + + const updated = await runOk({ + operation: 'update_page', + docId: state.docId, + pageId: state.subPageId, + pageName: 'Child page renamed', + pageSubtitle: 'new subtitle', + pageVisibility: 'unchanged', + insertionMode: 'append', + contentFormat: 'markdown', + pageContent: 'Appended paragraph', + }) + await waitForMutation(updated.requestId) + const renamedPage = await runOk({ + operation: 'get_page', + docId: state.docId, + pageId: state.subPageId, + }) + expect(renamedPage.page.name).toBe('Child page renamed') + expect(renamedPage.page.subtitle).toBe('new subtitle') + + const hide = await run({ + operation: 'update_page', + docId: state.docId, + pageId: state.subPageId, + pageVisibility: 'hidden', + }) + if (hide.success) { + await waitForMutation(hide.output.requestId) + } else { + expect(hide.error).toMatch(/plan/i) + } + + const content = await runOk({ + operation: 'get_page_content', + docId: state.docId, + pageId: state.subPageId, + limit: '100', + }) + expect( + content.items.some((i: { content: string }) => i.content === 'Appended paragraph') + ).toBe(true) + const target = content.items.find( + (i: { content: string }) => i.content === 'Appended paragraph' + ) + + const replaced = await runOk({ + operation: 'update_page', + docId: state.docId, + pageId: state.subPageId, + insertionMode: 'replace', + elementId: target.id, + contentFormat: 'html', + pageContent: '

Replaced paragraph

', + }) + await waitForMutation(replaced.requestId) + const afterReplace = await runOk({ + operation: 'get_page_content', + docId: state.docId, + pageId: state.subPageId, + }) + const replacedItem = afterReplace.items.find( + (i: { content: string }) => i.content === 'Replaced paragraph' + ) + expect(replacedItem).toBeTruthy() + + const guard = await run({ + operation: 'delete_page_content', + docId: state.docId, + pageId: state.subPageId, + }) + expect(guard.success).toBe(false) + expect(guard.error).toContain('deleteAll') + + const deletedOne = await runOk({ + operation: 'delete_page_content', + docId: state.docId, + pageId: state.subPageId, + elementIds: replacedItem.id, + }) + await waitForMutation(deletedOne.requestId) + + const exported = await runOk({ + operation: 'export_page', + docId: state.docId, + pageId: state.homePageId, + outputFormat: 'markdown', + }) + const finished = await waitFor('export', async () => { + const status = await runOk({ + operation: 'get_page_export_status', + docId: state.docId, + pageId: state.homePageId, + exportId: exported.exportId, + }) + return status.status === 'complete' || status.status === 'failed' ? status : undefined + }) + expect(finished.status).toBe('complete') + const markdown = await (await fetch(finished.downloadLink)).text() + log('export markdown', markdown) + expect(markdown).toContain('Tasks') + + const clearAll = await runOk({ + operation: 'delete_page_content', + docId: state.docId, + pageId: state.subPageId, + deleteAllContent: true, + }) + await waitForMutation(clearAll.requestId) + + const removed = await runOk({ + operation: 'delete_page', + docId: state.docId, + pageId: state.embedPageId, + }) + await waitForMutation(removed.requestId) + }, + TIMEOUT + ) + + it( + 'reads the table schema and inserts, upserts, updates, and deletes rows', + async () => { + await runOk({ + operation: 'list_tables', + docId: state.docId, + tableTypes: 'table', + listSortBy: 'name', + }) + const allTables = await runOk({ operation: 'list_tables', docId: state.docId }) + expect(allTables.tables.map((t: { id: string }) => t.id)).toContain(state.tableId) + await runOk({ + operation: 'get_table', + docId: state.docId, + tableId: state.tableId, + useUpdatedTableLayouts: true, + }) + const columns = await runOk({ + operation: 'list_columns', + docId: state.docId, + tableId: state.tableId, + visibleOnly: true, + }) + state.nameColumnId = columns.columns.find((c: { name: string }) => c.name === 'Name')?.id + state.statusColumnId = columns.columns.find((c: { name: string }) => c.name === 'Status')?.id + expect(state.nameColumnId && state.statusColumnId).toBeTruthy() + await runOk({ + operation: 'get_column', + docId: state.docId, + tableId: state.tableId, + columnId: state.nameColumnId, + }) + + const columnOptions = await codaSelectorAttachments['coda.columns'].execute( + selectorArgs( + 'coda.columns', + { kind: 'list' }, + { docId: state.docId, tableId: state.tableId } + ) + ) + log('selector coda.columns', columnOptions) + expect(columnOptions.kind === 'list' && columnOptions.items.length).toBeGreaterThanOrEqual(2) + + const inserted = await runOk({ + operation: 'upsert_rows', + docId: state.docId, + tableId: state.tableId, + rows: JSON.stringify([ + { Name: 'Alpha', Status: 'Open' }, + { cells: [{ column: state.nameColumnId, value: 'Beta' }] }, + ]), + }) + expect(inserted.addedRowIds).toHaveLength(2) + await waitForMutation(inserted.requestId) + + const upserted = await runOk({ + operation: 'upsert_rows', + docId: state.docId, + tableId: state.tableId, + rows: [{ Name: 'Alpha', Status: 'Done' }], + keyColumns: 'Name', + disableParsing: true, + }) + expect(upserted.addedRowIds).toEqual([]) + await waitForMutation(upserted.requestId) + + const rows = await runOk({ + operation: 'list_rows', + docId: state.docId, + tableId: state.tableId, + useColumnNames: true, + rowSortBy: 'updatedAt', + valueFormat: 'simpleWithArrays', + limit: '50', + }) + const alpha = rows.rows.find( + (r: { values: Record }) => r.values.Name === 'Alpha' + ) + expect(alpha?.values.Status).toBe('Done') + expect(rows.nextSyncToken).toBeTruthy() + state.rowIds = rows.rows.map((r: { id: string }) => r.id) + + const filtered = await runOk({ + operation: 'list_rows', + docId: state.docId, + tableId: state.tableId, + rowFilter: `"Name":"Beta"`, + visibleOnly: true, + }) + expect(filtered.rows).toHaveLength(1) + + const rowOptions = await codaSelectorAttachments['coda.rows'].execute( + selectorArgs('coda.rows', { kind: 'list' }, { docId: state.docId, tableId: state.tableId }) + ) + log('selector coda.rows', rowOptions) + expect(rowOptions.kind === 'list' && rowOptions.items.length).toBe(state.rowIds.length) + + const one = await runOk({ + operation: 'get_row', + docId: state.docId, + tableId: state.tableId, + rowId: alpha.id, + valueFormat: 'rich', + }) + expect(one.row.parentTable?.id).toBe(state.tableId) + + const updated = await runOk({ + operation: 'update_row', + docId: state.docId, + tableId: state.tableId, + rowId: alpha.id, + cells: '{"Status": "Blocked"}', + }) + await waitForMutation(updated.requestId) + const changed = await runOk({ + operation: 'list_rows', + docId: state.docId, + tableId: state.tableId, + syncToken: rows.nextSyncToken, + useColumnNames: true, + }) + log('rows changed since sync token', changed.rows.length) + + const button = await run({ + operation: 'push_button', + docId: state.docId, + tableId: state.tableId, + rowId: alpha.id, + columnId: state.nameColumnId, + }) + expect(button.success).toBe(false) + expect(button.error).not.toContain('[object Object]') + + const deletedOne = await runOk({ + operation: 'delete_row', + docId: state.docId, + tableId: state.tableId, + rowId: alpha.id, + }) + await waitForMutation(deletedOne.requestId) + const remaining = state.rowIds.filter((id) => id !== alpha.id) + const deletedMany = await runOk({ + operation: 'delete_rows', + docId: state.docId, + tableId: state.tableId, + rowIds: remaining.join(', '), + }) + expect(deletedMany.rowIds).toEqual(remaining) + await waitForMutation(deletedMany.requestId) + }, + TIMEOUT + ) + + it( + 'reads formulas and controls and surfaces missing ones as readable errors', + async () => { + const formulas = await runOk({ + operation: 'list_formulas', + docId: state.docId, + listSortBy: 'name', + }) + const controls = await runOk({ operation: 'list_controls', docId: state.docId }) + for (const formula of formulas.formulas) { + await runOk({ operation: 'get_formula', docId: state.docId, formulaId: formula.id }) + } + for (const control of controls.controls) { + await runOk({ operation: 'get_control', docId: state.docId, controlId: control.id }) + } + const missingFormula = await run({ + operation: 'get_formula', + docId: state.docId, + formulaId: 'f-missing', + }) + expect(missingFormula.success).toBe(false) + const missingControl = await run({ + operation: 'get_control', + docId: state.docId, + controlId: 'ctrl-missing', + }) + expect(missingControl.success).toBe(false) + const automation = await run({ + operation: 'trigger_automation', + docId: state.docId, + ruleId: 'grid-auto-missing', + payload: '{"hello":"world"}', + }) + expect(automation.success).toBe(false) + expect(automation.error).not.toContain('[object Object]') + for (const key of ['coda.formulas', 'coda.controls'] as const) { + const result = await codaSelectorAttachments[key].execute( + selectorArgs(key, { kind: 'list' }, { docId: state.docId }) + ) + expect(result.kind).toBe('list') + } + }, + TIMEOUT + ) + + it( + 'manages sharing settings and permissions', + async () => { + const metadata = await runOk({ operation: 'get_sharing_metadata', docId: state.docId }) + expect(metadata.canShare).toBe(true) + const before = await runOk({ operation: 'get_acl_settings', docId: state.docId }) + const flipped = await runOk({ + operation: 'update_acl_settings', + docId: state.docId, + allowCopying: before.allowCopying ? 'false' : 'true', + allowEditorsToChangePermissions: 'unchanged', + allowViewersToRequestEditing: 'unchanged', + }) + expect(flipped.allowCopying).toBe(!before.allowCopying) + expect(flipped.allowViewersToRequestEditing).toBe(before.allowViewersToRequestEditing) + await runOk({ + operation: 'update_acl_settings', + docId: state.docId, + allowCopying: before.allowCopying ? 'true' : 'false', + }) + + await runOk({ + operation: 'search_principals', + docId: state.docId, + principalQuery: state.loginId?.split('@')[0], + }) + + const anyone = await run({ + operation: 'add_permission', + docId: state.docId, + access: 'readonly', + principalType: 'anyone', + }) + if (!anyone.success) expect(anyone.error).toMatch(/limit/i) + if (shareEmail) { + const shared = await runOk({ + operation: 'add_permission', + docId: state.docId, + access: 'comment', + principalType: 'email', + principal: shareEmail, + suppressEmail: true, + }) + expect(shared).toMatchObject({ access: 'comment', principalType: 'email' }) + const permissions = await waitFor('permission', async () => { + const listed = await runOk({ + operation: 'list_permissions', + docId: state.docId, + limit: '20', + }) + return listed.permissions.find( + (p: { principal: { email?: string } }) => p.principal.email === shareEmail + ) + }) + expect(permissions.access).toBe('comment') + state.permissionId = permissions.id + + const permissionOptions = await codaSelectorAttachments['coda.permissions'].execute( + selectorArgs('coda.permissions', { kind: 'list' }, { docId: state.docId }) + ) + log('selector coda.permissions', permissionOptions) + + expect( + permissionOptions.kind === 'list' && + permissionOptions.items.some((item) => item.id === state.permissionId) + ).toBe(true) + await runOk({ + operation: 'delete_permission', + docId: state.docId, + permissionId: state.permissionId, + }) + await waitFor('permission removal', async () => { + const listed = await runOk({ operation: 'list_permissions', docId: state.docId }) + return listed.permissions.some((p: { id: string }) => p.id === state.permissionId) + ? undefined + : true + }) + } + const bad = await run({ + operation: 'add_permission', + docId: state.docId, + access: 'readonly', + principalType: 'email', + principal: ' ', + }) + expect(bad.success).toBe(false) + }, + TIMEOUT + ) + + it( + 'publishes, inspects custom domains, and unpublishes', + async () => { + const categories = await runOk({ operation: 'list_categories' }) + const publish = await run({ + operation: 'publish_doc', + docId: state.docId, + slug: `sim-coda-e2e-${Date.now()}`, + publishMode: 'view', + discoverable: 'false', + categoryNames: categories.categories[0], + }) + if (!publish.success) expect(publish.error).toMatch(/maker profile/i) + if (publish.success) { + state.published = true + await waitForMutation(publish.output.requestId) + const doc = await runOk({ operation: 'get_doc', docId: state.docId }) + log('published doc', doc.doc.published) + } + const domains = await runOk({ operation: 'list_custom_domains', docId: state.docId }) + expect(domains.nextPageToken).toBeNull() + const provider = await runOk({ + operation: 'get_custom_domain_provider', + customDocDomain: 'example.com', + }) + expect(provider.provider).toBeTruthy() + const add = await run({ + operation: 'add_custom_domain', + docId: state.docId, + customDocDomain: 'coda-e2e.sim-test.invalid', + }) + if (!add.success) expect(add.error).toMatch(/plan/i) + if (add.success) { + await run({ + operation: 'delete_custom_domain', + docId: state.docId, + customDocDomain: 'coda-e2e.sim-test.invalid', + }) + } + const unpublish = await run({ operation: 'unpublish_doc', docId: state.docId }) + if (state.published) expect(unpublish.success).toBe(true) + else if (!unpublish.success) expect(unpublish.error).not.toContain('[object Object]') + }, + TIMEOUT + ) + + it( + 'reads workspace membership, roles, and analytics', + async () => { + const members = await run({ + operation: 'list_workspace_members', + workspaceId: state.workspaceId, + }) + if (!members.success) expect(members.error).toMatch(/organization/i) + await run({ + operation: 'list_workspace_members', + workspaceId: state.workspaceId, + includedRoles: 'Admin, DocMaker', + }) + await run({ operation: 'list_workspace_roles', workspaceId: state.workspaceId }) + if (members.success) { + const me = members.output.members[0] + await run({ + operation: 'change_user_role', + workspaceId: state.workspaceId, + memberEmail: me.email, + newRole: me.role, + }) + } + await run({ + operation: 'list_doc_analytics', + docIds: state.docId, + sinceDate: '2026-01-01', + untilDate: '2026-12-31', + analyticsScale: 'cumulative', + analyticsOrderBy: 'views', + analyticsDirection: 'descending', + limit: '10', + }) + await run({ operation: 'list_doc_analytics', docSearch: 'Sim', isPublished: false }) + await run({ operation: 'list_page_analytics', docId: state.docId, sinceDate: '2026-01-01' }) + await run({ + operation: 'get_doc_analytics_summary', + sinceDate: '2026-01-01', + workspaceFilter: state.workspaceId, + }) + }, + TIMEOUT + ) + + it( + 'copies a doc with formulas, controls, views, and button columns and exercises them', + async () => { + const copy = await runOk({ + operation: 'create_doc', + docTitle: 'Sim Coda E2E Copy', + sourceDoc: 'BynGmkjg07', + folderId: state.folderId, + }) + state.copyDocId = copy.doc.id + expect(copy.doc.sourceDoc?.id).toBe('BynGmkjg07') + await waitForDocReady(state.copyDocId!) + + const copies = await runOk({ operation: 'list_docs', sourceDoc: 'BynGmkjg07' }) + expect(copies.docs.map((d: { id: string }) => d.id)).toContain(state.copyDocId) + + const formulas = await runOk({ + operation: 'list_formulas', + docId: state.copyDocId, + limit: '100', + }) + expect(formulas.formulas.length).toBeGreaterThan(0) + const formula = await runOk({ + operation: 'get_formula', + docId: state.copyDocId, + formulaId: formulas.formulas[0].id, + }) + expect(formula.formula.id).toBe(formulas.formulas[0].id) + const byName = await runOk({ + operation: 'get_formula', + docId: state.copyDocId, + formulaId: formulas.formulas[0].name, + }) + expect(byName.formula.id).toBe(formulas.formulas[0].id) + + const controls = await runOk({ + operation: 'list_controls', + docId: state.copyDocId, + listSortBy: 'name', + }) + expect(controls.controls.length).toBeGreaterThan(0) + for (const control of controls.controls) { + const detail = await runOk({ + operation: 'get_control', + docId: state.copyDocId, + controlId: control.id, + }) + expect(detail.control.controlType).toBeTruthy() + } + for (const key of ['coda.formulas', 'coda.controls'] as const) { + const options = await codaSelectorAttachments[key].execute( + selectorArgs(key, { kind: 'list' }, { docId: state.copyDocId }) + ) + log(`selector ${key}`, options) + expect(options.kind === 'list' && options.items.length).toBeGreaterThan(0) + } + + const views = await runOk({ + operation: 'list_tables', + docId: state.copyDocId, + tableTypes: 'view', + }) + expect(views.tables.length).toBeGreaterThan(0) + expect(views.tables.every((t: { tableType: string }) => t.tableType === 'view')).toBe(true) + const view = await runOk({ + operation: 'get_table', + docId: state.copyDocId, + tableId: views.tables[0].id, + }) + expect(view.table.parentTable?.id).toBeTruthy() + const tableOptions = await codaSelectorAttachments['coda.tables'].execute( + selectorArgs('coda.tables', { kind: 'list' }, { docId: state.copyDocId }) + ) + expect( + tableOptions.kind === 'list' && + tableOptions.items.some((item) => item.label.endsWith('(view)')) + ).toBe(true) + + const calendar = await runOk({ + operation: 'list_tables', + docId: state.copyDocId, + tableTypes: 'table', + }) + const calendarTable = calendar.tables.find((t: { name: string }) => t.name === 'My Calendar') + const richRows = await runOk({ + operation: 'list_rows', + docId: state.copyDocId, + tableId: calendarTable.id, + valueFormat: 'rich', + rowSortBy: 'natural', + limit: '5', + }) + log('rich calendar rows', richRows.rows.slice(0, 2)) + const calendarColumns = await runOk({ + operation: 'list_columns', + docId: state.copyDocId, + tableId: 'My Calendar', + }) + expect( + calendarColumns.columns.some((c: { format: { type: string } }) => c.format.type === 'date') + ).toBe(true) + + const tasks = calendar.tables.find((t: { name: string }) => t.name === 'Tasks') + const taskColumns = await runOk({ + operation: 'list_columns', + docId: state.copyDocId, + tableId: tasks.id, + }) + const buttonColumn = taskColumns.columns.find( + (c: { format: { type: string } }) => c.format.type === 'button' + ) + const buttonDetail = await runOk({ + operation: 'get_column', + docId: state.copyDocId, + tableId: tasks.id, + columnId: buttonColumn.id, + }) + expect(buttonDetail.column.format.type).toBe('button') + const taskRows = await runOk({ + operation: 'list_rows', + docId: state.copyDocId, + tableId: tasks.id, + limit: '1', + }) + const rowOptions = await codaSelectorAttachments['coda.rows'].execute( + selectorArgs( + 'coda.rows', + { kind: 'detail', id: taskRows.rows[0].id }, + { + docId: state.copyDocId, + tableId: tasks.id, + } + ) + ) + expect(rowOptions).toMatchObject({ kind: 'detail', item: { id: taskRows.rows[0].id } }) + const pushed = await runOk({ + operation: 'push_button', + docId: state.copyDocId, + tableId: tasks.id, + rowId: taskRows.rows[0].id, + columnId: buttonColumn.id, + }) + expect(pushed).toMatchObject({ rowId: taskRows.rows[0].id, columnId: buttonColumn.id }) + await waitForMutation(pushed.requestId) + + const pagesCopy = await runOk({ operation: 'list_pages', docId: state.copyDocId, limit: '3' }) + if (pagesCopy.nextPageToken) { + const next = await runOk({ + operation: 'list_pages', + docId: state.copyDocId, + limit: '3', + pageToken: pagesCopy.nextPageToken, + }) + expect(next.pages[0]?.id).not.toBe(pagesCopy.pages[0]?.id) + } + + await runOk({ operation: 'delete_doc', docId: state.copyDocId }) + state.copyDocId = undefined + }, + TIMEOUT + ) + + it( + 'resolves browser links and cleans up', + async () => { + const resolved = await runOk({ + operation: 'resolve_browser_link', + browserUrl: state.docBrowserLink, + }) + expect(resolved.resource.id).toBe(state.docId) + await runOk({ + operation: 'resolve_browser_link', + browserUrl: state.docBrowserLink, + degradeGracefully: true, + }) + + const deleted = await runOk({ operation: 'delete_doc', docId: state.docId }) + expect(deleted.docId).toBe(state.docId) + state.docId = undefined + await sleep(5_000) + const folder = await run({ operation: 'delete_folder', folderId: state.folderId }) + if (folder.success) state.folderId = undefined + }, + TIMEOUT + ) +}) diff --git a/apps/sim/tools/coda/coda.test.ts b/apps/sim/tools/coda/coda.test.ts new file mode 100644 index 00000000000..c618bfd41d9 --- /dev/null +++ b/apps/sim/tools/coda/coda.test.ts @@ -0,0 +1,424 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { CodaBlock } from '@/blocks/blocks/coda' +import * as codaTools from '@/tools/coda' +import { codaAddPermissionTool } from '@/tools/coda/add_permission' +import { codaCreateDocTool } from '@/tools/coda/create_doc' +import { codaCreatePageTool } from '@/tools/coda/create_page' +import { codaDeletePageContentTool } from '@/tools/coda/delete_page_content' +import { codaDeleteRowsTool } from '@/tools/coda/delete_rows' +import { codaListDocsTool } from '@/tools/coda/list_docs' +import { codaListRowsTool } from '@/tools/coda/list_rows' +import { codaPublishDocTool } from '@/tools/coda/publish_doc' +import { codaResolveBrowserLinkTool } from '@/tools/coda/resolve_browser_link' +import { codaUpdateAclSettingsTool } from '@/tools/coda/update_acl_settings' +import { codaUpdatePageTool } from '@/tools/coda/update_page' +import { codaUpdateRowTool } from '@/tools/coda/update_row' +import { codaUpsertRowsTool } from '@/tools/coda/upsert_rows' +import { buildCodaUrl, CODA_FIELD_UPDATE_RETRY, CODA_RETRY } from '@/tools/coda/utils' +import { codaWhoamiTool } from '@/tools/coda/whoami' +import { ErrorExtractorId, extractErrorMessageWithId } from '@/tools/error-extractors' +import type { OutputProperty } from '@/tools/types' + +const table = { accessToken: 'token', docId: 'AbCDeFGH', tableId: 'grid-pqRst-U' } + +/** Lists output paths a tool returned as null whose schema does not declare `nullable`. */ +function findUndeclaredNulls( + value: unknown, + properties: Record | undefined, + path: string +): string[] { + if (!properties || value === null || typeof value !== 'object') return [] + return Object.entries(properties).flatMap(([key, schema]) => { + const child = (value as Record)[key] + const childPath = `${path}.${key}` + if (child === null) return schema.nullable ? [] : [childPath] + if (Array.isArray(child)) { + return child.flatMap((item) => findUndeclaredNulls(item, schema.items?.properties, childPath)) + } + return findUndeclaredNulls(child, schema.properties, childPath) + }) +} + +function resolveUrl

(url: string | ((params: P) => string), params: P): string { + return typeof url === 'function' ? url(params) : url +} + +describe('Coda request URLs', () => { + it('encodes path segments and omits unset query params', () => { + const url = resolveUrl(codaListRowsTool.request.url, { + ...table, + tableId: 'My Table', + query: '"Status":"Done"', + useColumnNames: true, + limit: 10, + }) + expect(url).toBe( + 'https://coda.io/apis/v1/docs/AbCDeFGH/tables/My%20Table/rows?query=%22Status%22%3A%22Done%22&useColumnNames=true&limit=10' + ) + }) + + it('rejects path traversal in resource identifiers', () => { + expect(() => resolveUrl(codaListRowsTool.request.url, { ...table, tableId: '..' })).toThrow( + 'path traversal' + ) + }) + + it('rejects a blank browser link instead of sending no url', () => { + expect(() => + resolveUrl(codaResolveBrowserLinkTool.request.url, { accessToken: 'token', url: ' ' }) + ).toThrow('url is required') + }) + + it('builds list docs URLs without a doc path', () => { + expect( + resolveUrl(codaListDocsTool.request.url, { + accessToken: 'token', + query: 'Roadmap', + isOwner: true, + }) + ).toBe('https://coda.io/apis/v1/docs?query=Roadmap&isOwner=true') + }) +}) + +describe('Coda row bodies', () => { + it('converts column maps to cells and parses key columns', () => { + const body = codaUpsertRowsTool.request.body!({ + ...table, + rows: JSON.stringify([{ 'c-name': 'Apple', 'c-price': 1.25 }]), + keyColumns: 'c-name, c-sku', + }) + expect(body).toEqual({ + rows: [ + { + cells: [ + { column: 'c-name', value: 'Apple' }, + { column: 'c-price', value: 1.25 }, + ], + }, + ], + keyColumns: ['c-name', 'c-sku'], + }) + }) + + it('passes through rows already in Coda cell format', () => { + const cells = [{ column: 'c-name', value: 'Pear' }] + expect(codaUpsertRowsTool.request.body!({ ...table, rows: [{ cells }] })).toEqual({ + rows: [{ cells }], + }) + }) + + it('maps a column named cells instead of reading it as the cells wrapper', () => { + expect( + codaUpdateRowTool.request.body!({ + ...table, + rowId: 'i-1', + cells: { cells: 'x', Status: 'Done' }, + }) + ).toEqual({ + row: { + cells: [ + { column: 'cells', value: 'x' }, + { column: 'Status', value: 'Done' }, + ], + }, + }) + expect( + codaUpdateRowTool.request.body!({ ...table, rowId: 'i-1', cells: { cells: 'x' } }) + ).toEqual({ row: { cells: [{ column: 'cells', value: 'x' }] } }) + }) + + it('rejects an empty upsert', () => { + expect(() => codaUpsertRowsTool.request.body!({ ...table, rows: '[]' })).toThrow( + 'at least one row' + ) + }) + + it('wraps row updates in a row object', () => { + expect( + codaUpdateRowTool.request.body!({ ...table, rowId: 'i-1', cells: { Status: 'Done' } }) + ).toEqual({ row: { cells: [{ column: 'Status', value: 'Done' }] } }) + }) + + it('accepts row IDs as a JSON array string', () => { + expect(codaDeleteRowsTool.request.body!({ ...table, rowIds: '["i-1", "i-2"]' })).toEqual({ + rowIds: ['i-1', 'i-2'], + }) + }) +}) + +describe('Coda page and permission bodies', () => { + const page = { accessToken: 'token', docId: 'AbCDeFGH', pageId: 'canvas-1' } + + it('requires an insertion mode when updating content', () => { + expect(() => codaUpdatePageTool.request.body!({ ...page, content: '# Hi' })).toThrow( + 'insertionMode' + ) + }) + + it('builds a content update with a default markdown format', () => { + expect( + codaUpdatePageTool.request.body!({ ...page, content: '# Hi', insertionMode: 'append' }) + ).toEqual({ + contentUpdate: { + insertionMode: 'append', + canvasContent: { format: 'markdown', content: '# Hi' }, + }, + }) + }) + + it('maps each principal type to its field', () => { + const base = { accessToken: 'token', docId: 'AbCDeFGH', access: 'write' as const } + expect( + codaAddPermissionTool.request.body!({ ...base, principalType: 'group', principal: 'grp-1' }) + ).toEqual({ access: 'write', principal: { type: 'group', groupId: 'grp-1' } }) + expect(codaAddPermissionTool.request.body!({ ...base, principalType: 'anyone' })).toEqual({ + access: 'write', + principal: { type: 'anyone' }, + }) + expect(() => + codaAddPermissionTool.request.body!({ ...base, principalType: 'email', principal: ' ' }) + ).toThrow('principal is required') + }) +}) + +describe('Coda block params', () => { + const mapParams = CodaBlock.tools.config!.params! + + it('maps operation-specific inputs onto tool params', () => { + const result = mapParams({ + operation: 'list_rows', + rowFilter: '"Status":"Done"', + docSearch: 'ignored', + rowSortBy: 'updatedAt', + limit: '50', + useColumnNames: true, + visibleOnly: false, + }) + expect(result).toMatchObject({ + query: '"Status":"Done"', + sortBy: 'updatedAt', + limit: 50, + useColumnNames: true, + visibleOnly: undefined, + }) + }) + + it('routes shared params by operation and maps page visibility', () => { + expect( + mapParams({ + operation: 'get_mutation_status', + mutationRequestId: 'req-1', + workspaceFilter: 'ws-f', + }) + ).toMatchObject({ requestId: 'req-1', workspaceId: 'ws-f' }) + expect( + mapParams({ operation: 'create_folder', folderName: 'Plans', workspaceId: 'ws-1' }) + ).toMatchObject({ name: 'Plans', workspaceId: 'ws-1' }) + expect(mapParams({ operation: 'update_page', pageVisibility: 'hidden' })).toMatchObject({ + isHidden: true, + }) + expect(mapParams({ operation: 'update_page', pageVisibility: 'unchanged' })).toMatchObject({ + isHidden: undefined, + }) + }) + + it('selects the tool from the operation', () => { + expect(CodaBlock.tools.config!.tool!({ operation: 'upsert_rows' })).toBe('coda_upsert_rows') + }) +}) + +describe('Coda page content and publishing bodies', () => { + it('builds embed and sync page content', () => { + expect( + codaCreatePageTool.request.body!({ + accessToken: 'token', + docId: 'AbCDeFGH', + pageType: 'embed', + embedUrl: ' https://example.com ', + }) + ).toEqual({ pageContent: { type: 'embed', url: 'https://example.com' } }) + expect( + codaCreatePageTool.request.body!({ + accessToken: 'token', + docId: 'AbCDeFGH', + pageType: 'syncPage', + sourceDocId: 'src', + syncMode: 'document', + }) + ).toEqual({ pageContent: { type: 'syncPage', mode: 'document', sourceDocId: 'src' } }) + expect(() => + codaCreatePageTool.request.body!({ + accessToken: 'token', + docId: 'AbCDeFGH', + pageType: 'syncPage', + sourceDocId: 'src', + }) + ).toThrow('sourcePageId') + }) + + it('nests initial page settings when creating a doc', () => { + expect( + codaCreateDocTool.request.body!({ + accessToken: 'token', + title: 'Plan', + pageName: 'Overview', + content: '# Hi', + }) + ).toEqual({ + title: 'Plan', + initialPage: { + name: 'Overview', + pageContent: { type: 'canvas', canvasContent: { format: 'markdown', content: '# Hi' } }, + }, + }) + }) + + it('only sends explicitly set sharing settings', () => { + expect( + codaUpdateAclSettingsTool.request.body!({ + accessToken: 'token', + docId: 'AbCDeFGH', + allowCopying: false, + }) + ).toEqual({ allowCopying: false }) + expect(() => + codaUpdateAclSettingsTool.request.body!({ accessToken: 'token', docId: 'AbCDeFGH' }) + ).toThrow('at least one') + }) + + it('splits publish categories and omits unset fields', () => { + expect( + codaPublishDocTool.request.body!({ + accessToken: 'token', + docId: 'AbCDeFGH', + categoryNames: 'Project management, Engineering', + mode: 'view', + }) + ).toEqual({ categoryNames: ['Project management', 'Engineering'], mode: 'view' }) + }) + + it('sends the bearer token from the resolved credential', () => { + expect(codaWhoamiTool.request.headers({ accessToken: 'secret' })).toEqual({ + Authorization: 'Bearer secret', + Accept: 'application/json', + }) + expect(codaWhoamiTool.oauth).toEqual({ required: true, provider: 'coda' }) + }) +}) + +describe('Coda delete page content', () => { + const page = { accessToken: 'token', docId: 'AbCDeFGH', pageId: 'canvas-1' } + + it('never clears a whole page without an explicit deleteAll', () => { + expect(() => codaDeletePageContentTool.request.body!({ ...page })).toThrow('deleteAll') + expect(codaDeletePageContentTool.request.body!({ ...page, deleteAll: true })).toEqual({}) + expect( + codaDeletePageContentTool.request.body!({ + ...page, + elementIds: 'cl-1, cl-2', + deleteAll: true, + }) + ).toEqual({ elementIds: ['cl-1', 'cl-2'] }) + }) +}) + +describe('Coda pagination and errors', () => { + it('sends only the page token when continuing a list', () => { + expect( + buildCodaUrl('/docs/doc/tables/grid/rows', { + limit: 10, + useColumnNames: true, + pageToken: 'eyJsaW1pd', + }) + ).toBe('https://coda.io/apis/v1/docs/doc/tables/grid/rows?pageToken=eyJsaW1pd') + expect(buildCodaUrl('/docs', { limit: 10, pageToken: ' ' })).toBe( + 'https://coda.io/apis/v1/docs?limit=10' + ) + }) + + it('surfaces Coda schema validation detail instead of a generic Bad Request', () => { + const data = { + statusCode: 400, + statusMessage: 'Bad Request', + message: 'Bad Request', + codaType: 'RequestSchemaValidationFailed', + codaDetail: { + issues: [ + { + code: 'invalid_union', + errors: [ + [{ code: 'custom', message: 'Invalid pageToken', path: ['pageToken'] }], + [ + { + code: 'unrecognized_keys', + keys: ['limit'], + path: [], + message: 'Unrecognized key: "limit"', + }, + ], + ], + path: [], + message: 'Invalid input', + }, + ], + }, + } + expect(extractErrorMessageWithId({ status: 400, data }, ErrorExtractorId.CODA_ERRORS)).toBe( + 'Bad Request: pageToken: Invalid pageToken; Unrecognized key: "limit"' + ) + expect( + extractErrorMessageWithId( + { status: 404, data: { statusMessage: 'Not Found', message: 'Doc has been deleted.' } }, + ErrorExtractorId.CODA_ERRORS + ) + ).toBe('Doc has been deleted.') + }) +}) + +describe('Coda tool registration', () => { + const allTools = Object.entries(codaTools).filter(([name]) => name.endsWith('Tool')) + + it('exposes all 60 tools through the barrel', () => { + expect(allTools).toHaveLength(60) + }) + + it.each(allTools)( + '%s declares every output it can return as null as nullable', + async (_, tool) => { + const config = tool as { + outputs: Record + transformResponse: (response: Response, params: object) => Promise<{ output: unknown }> + } + const sparseBody = { + items: [{ doc: {}, page: {}, metrics: [{}] }], + customDocDomains: [{}], + resource: {}, + id: 'x', + } + const { output } = await config.transformResponse( + new Response(JSON.stringify(sparseBody)), + table + ) + expect(findUndeclaredNulls(output, config.outputs, '')).toEqual([]) + } + ) + + it.each(allTools)( + '%s retries safely repeatable calls and authenticates with the Coda credential', + (_, tool) => { + const config = tool as { + request: { method: unknown; retry?: unknown } + oauth?: unknown + params: Record + } + expect(config.request.retry).toBe( + config.request.method === 'PATCH' ? CODA_FIELD_UPDATE_RETRY : CODA_RETRY + ) + expect(config.oauth).toEqual({ required: true, provider: 'coda' }) + expect(config.params.accessToken).toMatchObject({ required: true, visibility: 'hidden' }) + } + ) +}) diff --git a/apps/sim/tools/coda/create_doc.ts b/apps/sim/tools/coda/create_doc.ts new file mode 100644 index 00000000000..ac7669cf00d --- /dev/null +++ b/apps/sim/tools/coda/create_doc.ts @@ -0,0 +1,171 @@ +import type { CodaCreateDocParams, CodaCreateDocResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + buildPageCreateContent, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + DOC_PROPERTIES, + mapDoc, + optionalTrimmed, + type RawCodaDoc, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaCreateDocTool: ToolConfig = { + id: 'coda_create_doc', + name: 'Coda Create Doc', + description: + 'Create a Coda doc, optionally copying an existing doc and setting up its first page with Markdown, HTML, an embed, or a sync page. Requires Doc Maker access in the workspace.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Title of the new doc (defaults to "Untitled")', + }, + sourceDoc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of an existing doc to copy', + }, + timezone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Timezone for the new doc (e.g., "America/Los_Angeles")', + }, + folderId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the folder to create the doc in (defaults to "My docs")', + }, + pageName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name of the initial page', + }, + pageSubtitle: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Subtitle of the initial page', + }, + iconName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Icon name for the initial page (e.g., "rocket")', + }, + imageUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Cover image URL for the initial page', + }, + pageType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Initial page content type: "canvas" (default), "embed", or "syncPage"', + }, + contentFormat: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canvas content format: "markdown" (default) or "html"', + }, + content: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canvas content for the initial page in the chosen format', + }, + embedUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL to embed as a full page (pageType "embed")', + }, + renderMethod: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Embed render method: "standard" or "compatibility"', + }, + sourceDocId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Doc to sync from (pageType "syncPage")', + }, + sourcePageId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page to sync (pageType "syncPage" with syncMode "page")', + }, + syncMode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sync page mode: "page" (default) or "document"', + }, + includeSubpages: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include subpages in a single-page sync page', + }, + }, + + request: { + url: () => buildCodaUrl('/docs'), + method: 'POST', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const pageContent = buildPageCreateContent(params) + const initialPage = { + ...(optionalTrimmed(params.pageName) ? { name: optionalTrimmed(params.pageName) } : {}), + ...(params.pageSubtitle ? { subtitle: params.pageSubtitle } : {}), + ...(optionalTrimmed(params.iconName) ? { iconName: optionalTrimmed(params.iconName) } : {}), + ...(optionalTrimmed(params.imageUrl) ? { imageUrl: optionalTrimmed(params.imageUrl) } : {}), + ...(pageContent ? { pageContent } : {}), + } + return { + ...(optionalTrimmed(params.title) ? { title: optionalTrimmed(params.title) } : {}), + ...(optionalTrimmed(params.sourceDoc) + ? { sourceDoc: optionalTrimmed(params.sourceDoc) } + : {}), + ...(optionalTrimmed(params.timezone) ? { timezone: optionalTrimmed(params.timezone) } : {}), + ...(optionalTrimmed(params.folderId) ? { folderId: optionalTrimmed(params.folderId) } : {}), + ...(Object.keys(initialPage).length > 0 ? { initialPage } : {}), + } + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaDoc & { requestId?: string } + return { success: true, output: { doc: mapDoc(data), requestId: data.requestId ?? null } } + }, + + outputs: { + doc: { type: 'object', description: 'The created doc', properties: DOC_PROPERTIES }, + requestId: { + type: 'string', + description: 'Coda request ID for the doc creation', + nullable: true, + }, + }, +} diff --git a/apps/sim/tools/coda/create_folder.ts b/apps/sim/tools/coda/create_folder.ts new file mode 100644 index 00000000000..c0bc25577d4 --- /dev/null +++ b/apps/sim/tools/coda/create_folder.ts @@ -0,0 +1,62 @@ +import type { CodaCreateFolderParams, CodaFolderResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + FOLDER_PROPERTIES, + mapFolder, + optionalTrimmed, + type RawCodaFolder, + WORKSPACE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaCreateFolderTool: ToolConfig = { + id: 'coda_create_folder', + name: 'Coda Create Folder', + description: 'Create a folder in a Coda workspace', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the folder', + }, + workspaceId: WORKSPACE_ID_PARAM, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the folder', + }, + }, + + request: { + url: () => buildCodaUrl('/folders'), + method: 'POST', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => ({ + name: String(params.name ?? '').trim(), + workspaceId: String(params.workspaceId ?? '').trim(), + ...(optionalTrimmed(params.description) ? { description: params.description } : {}), + }), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaFolder + return { success: true, output: { folder: mapFolder(data) } } + }, + + outputs: { + folder: { type: 'object', description: 'The created folder', properties: FOLDER_PROPERTIES }, + }, +} diff --git a/apps/sim/tools/coda/create_page.ts b/apps/sim/tools/coda/create_page.ts new file mode 100644 index 00000000000..7308c349be1 --- /dev/null +++ b/apps/sim/tools/coda/create_page.ts @@ -0,0 +1,144 @@ +import type { CodaCreatePageParams, CodaPageMutationResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + buildPageCreateContent, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + optionalTrimmed, + REQUEST_ID_OUTPUT, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaCreatePageTool: ToolConfig = { + id: 'coda_create_page', + name: 'Coda Create Page', + description: + 'Create a page in a Coda doc, optionally as a subpage, with Markdown or HTML content, a full-page embed, or a sync page from another doc. The page is created asynchronously. Requires Doc Maker access.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name of the page', + }, + subtitle: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Subtitle of the page', + }, + iconName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name of the page icon (e.g., "rocket")', + }, + imageUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL of a cover image for the page', + }, + parentPageId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the parent page, to create this page as a subpage', + }, + pageType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page content type: "canvas" (default), "embed", or "syncPage"', + }, + contentFormat: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canvas content format: "markdown" (default) or "html"', + }, + content: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canvas page content in the chosen format', + }, + embedUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL to embed as a full page (pageType "embed")', + }, + renderMethod: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Embed render method: "standard" or "compatibility"', + }, + sourceDocId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Doc to sync from (pageType "syncPage")', + }, + sourcePageId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page to sync (pageType "syncPage" with syncMode "page")', + }, + syncMode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sync page mode: "page" (default) or "document"', + }, + includeSubpages: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include subpages in a single-page sync page', + }, + }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'pages')), + method: 'POST', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const pageContent = buildPageCreateContent(params) + return { + ...(optionalTrimmed(params.name) ? { name: optionalTrimmed(params.name) } : {}), + ...(params.subtitle ? { subtitle: params.subtitle } : {}), + ...(optionalTrimmed(params.iconName) ? { iconName: optionalTrimmed(params.iconName) } : {}), + ...(optionalTrimmed(params.imageUrl) ? { imageUrl: optionalTrimmed(params.imageUrl) } : {}), + ...(optionalTrimmed(params.parentPageId) + ? { parentPageId: optionalTrimmed(params.parentPageId) } + : {}), + ...(pageContent ? { pageContent } : {}), + } + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string; id: string } + return { success: true, output: { requestId: data.requestId, pageId: data.id } } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + pageId: { type: 'string', description: 'ID of the created page' }, + }, +} diff --git a/apps/sim/tools/coda/delete_custom_domain.ts b/apps/sim/tools/coda/delete_custom_domain.ts new file mode 100644 index 00000000000..efb18baac18 --- /dev/null +++ b/apps/sim/tools/coda/delete_custom_domain.ts @@ -0,0 +1,50 @@ +import type { CodaCustomDomainParams, CodaCustomDomainResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + CUSTOM_DOMAIN_PARAM, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaDeleteCustomDomainTool: ToolConfig< + CodaCustomDomainParams, + CodaCustomDomainResponse +> = { + id: 'coda_delete_custom_domain', + name: 'Coda Delete Custom Domain', + description: 'Remove a custom domain from a published Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM, customDocDomain: CUSTOM_DOMAIN_PARAM }, + + request: { + url: (params) => + buildCodaUrl( + codaDocPath(params.docId, 'domains', [params.customDocDomain, 'customDocDomain']) + ), + method: 'DELETE', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (_response, params) => ({ + success: true, + output: { + docId: String(params?.docId ?? '').trim(), + customDocDomain: String(params?.customDocDomain ?? '').trim(), + }, + }), + + outputs: { + docId: { type: 'string', description: 'ID of the doc' }, + customDocDomain: { type: 'string', description: 'The custom domain that was removed' }, + }, +} diff --git a/apps/sim/tools/coda/delete_doc.ts b/apps/sim/tools/coda/delete_doc.ts new file mode 100644 index 00000000000..8a1b0d9d668 --- /dev/null +++ b/apps/sim/tools/coda/delete_doc.ts @@ -0,0 +1,39 @@ +import type { CodaDocIdResponse, CodaDocParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaDeleteDocTool: ToolConfig = { + id: 'coda_delete_doc', + name: 'Coda Delete Doc', + description: 'Delete a Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId)), + method: 'DELETE', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (_response, params) => ({ + success: true, + output: { docId: String(params?.docId ?? '').trim() }, + }), + + outputs: { + docId: { type: 'string', description: 'ID of the deleted doc' }, + }, +} diff --git a/apps/sim/tools/coda/delete_folder.ts b/apps/sim/tools/coda/delete_folder.ts new file mode 100644 index 00000000000..20eb2bc6e20 --- /dev/null +++ b/apps/sim/tools/coda/delete_folder.ts @@ -0,0 +1,39 @@ +import type { CodaDeleteFolderResponse, CodaFolderParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + codaPath, + FOLDER_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaDeleteFolderTool: ToolConfig = { + id: 'coda_delete_folder', + name: 'Coda Delete Folder', + description: 'Delete an empty Coda folder (it must contain no docs)', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, folderId: FOLDER_ID_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaPath('folders', [params.folderId, 'folderId'])), + method: 'DELETE', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (_response, params) => ({ + success: true, + output: { folderId: String(params?.folderId ?? '').trim() }, + }), + + outputs: { + folderId: { type: 'string', description: 'ID of the deleted folder' }, + }, +} diff --git a/apps/sim/tools/coda/delete_page.ts b/apps/sim/tools/coda/delete_page.ts new file mode 100644 index 00000000000..6db84298b33 --- /dev/null +++ b/apps/sim/tools/coda/delete_page.ts @@ -0,0 +1,42 @@ +import type { CodaPageMutationResponse, CodaPageParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + PAGE_ID_PARAM, + REQUEST_ID_OUTPUT, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaDeletePageTool: ToolConfig = { + id: 'coda_delete_page', + name: 'Coda Delete Page', + description: 'Delete a page from a Coda doc. Applied asynchronously.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM, pageId: PAGE_ID_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'pages', [params.pageId, 'pageId'])), + method: 'DELETE', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string; id: string } + return { success: true, output: { requestId: data.requestId, pageId: data.id } } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + pageId: { type: 'string', description: 'ID of the deleted page' }, + }, +} diff --git a/apps/sim/tools/coda/delete_page_content.ts b/apps/sim/tools/coda/delete_page_content.ts new file mode 100644 index 00000000000..020325aa1f8 --- /dev/null +++ b/apps/sim/tools/coda/delete_page_content.ts @@ -0,0 +1,73 @@ +import type { CodaDeletePageContentParams, CodaPageMutationResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + PAGE_ID_PARAM, + parseStringList, + REQUEST_ID_OUTPUT, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaDeletePageContentTool: ToolConfig< + CodaDeletePageContentParams, + CodaPageMutationResponse +> = { + id: 'coda_delete_page_content', + name: 'Coda Delete Page Content', + description: + 'Delete specific content elements from a Coda page, or all of its content when no element IDs are given. Applied asynchronously.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + pageId: PAGE_ID_PARAM, + elementIds: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Element IDs to delete (from Get Page Content), as an array or comma-separated list', + }, + deleteAll: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Set to true, with no element IDs, to delete all content from the page', + }, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'pages', [params.pageId, 'pageId'], 'content')), + method: 'DELETE', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const elementIds = parseStringList(params.elementIds, 'elementIds') + if (elementIds.length > 0) return { elementIds } + if (params.deleteAll !== true) { + throw new Error('Provide elementIds, or set deleteAll to true to delete all page content') + } + return {} + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string; id: string } + return { success: true, output: { requestId: data.requestId, pageId: data.id } } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + pageId: { type: 'string', description: 'ID of the page whose content was deleted' }, + }, +} diff --git a/apps/sim/tools/coda/delete_permission.ts b/apps/sim/tools/coda/delete_permission.ts new file mode 100644 index 00000000000..52682da97bc --- /dev/null +++ b/apps/sim/tools/coda/delete_permission.ts @@ -0,0 +1,58 @@ +import type { CodaDeletePermissionParams, CodaDeletePermissionResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaDeletePermissionTool: ToolConfig< + CodaDeletePermissionParams, + CodaDeletePermissionResponse +> = { + id: 'coda_delete_permission', + name: 'Coda Remove Permission', + description: 'Revoke a sharing permission on a Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + permissionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the permission to remove (from List Permissions)', + }, + }, + + request: { + url: (params) => + buildCodaUrl( + codaDocPath(params.docId, 'acl', 'permissions', [params.permissionId, 'permissionId']) + ), + method: 'DELETE', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (_response, params) => ({ + success: true, + output: { + docId: String(params?.docId ?? '').trim(), + permissionId: String(params?.permissionId ?? '').trim(), + }, + }), + + outputs: { + docId: { type: 'string', description: 'ID of the doc' }, + permissionId: { type: 'string', description: 'ID of the removed permission' }, + }, +} diff --git a/apps/sim/tools/coda/delete_row.ts b/apps/sim/tools/coda/delete_row.ts new file mode 100644 index 00000000000..8861092e66c --- /dev/null +++ b/apps/sim/tools/coda/delete_row.ts @@ -0,0 +1,49 @@ +import type { CodaRowMutationResponse, CodaRowParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + REQUEST_ID_OUTPUT, + ROW_ID_PARAM, + TABLE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaDeleteRowTool: ToolConfig = { + id: 'coda_delete_row', + name: 'Coda Delete Row', + description: 'Delete a row from a Coda table or view. Applied asynchronously.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM, tableId: TABLE_ID_PARAM, rowId: ROW_ID_PARAM }, + + request: { + url: (params) => + buildCodaUrl( + codaDocPath(params.docId, 'tables', [params.tableId, 'tableId'], 'rows', [ + params.rowId, + 'rowId', + ]) + ), + method: 'DELETE', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string; id: string } + return { success: true, output: { requestId: data.requestId, rowId: data.id } } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + rowId: { type: 'string', description: 'ID of the deleted row' }, + }, +} diff --git a/apps/sim/tools/coda/delete_rows.ts b/apps/sim/tools/coda/delete_rows.ts new file mode 100644 index 00000000000..29ce1bb5a2e --- /dev/null +++ b/apps/sim/tools/coda/delete_rows.ts @@ -0,0 +1,64 @@ +import type { CodaDeleteRowsParams, CodaDeleteRowsResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + parseStringList, + REQUEST_ID_OUTPUT, + TABLE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaDeleteRowsTool: ToolConfig = { + id: 'coda_delete_rows', + name: 'Coda Delete Rows', + description: 'Delete multiple rows from a Coda table or view by ID. Applied asynchronously.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + tableId: TABLE_ID_PARAM, + rowIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Row IDs to delete, as an array or comma-separated list (e.g., ["i-bCdeFgh", "i-CdEfgHi"])', + }, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'tables', [params.tableId, 'tableId'], 'rows')), + method: 'DELETE', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const rowIds = parseStringList(params.rowIds, 'rowIds') + if (rowIds.length === 0) throw new Error('rowIds must contain at least one row ID') + return { rowIds } + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string; rowIds?: string[] } + return { success: true, output: { requestId: data.requestId, rowIds: data.rowIds ?? [] } } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + rowIds: { + type: 'array', + description: 'IDs of the rows queued for deletion', + items: { type: 'string', description: 'Row ID' }, + }, + }, +} diff --git a/apps/sim/tools/coda/export_page.ts b/apps/sim/tools/coda/export_page.ts new file mode 100644 index 00000000000..4f78d348636 --- /dev/null +++ b/apps/sim/tools/coda/export_page.ts @@ -0,0 +1,55 @@ +import type { CodaExportPageParams, CodaExportPageResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + PAGE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaExportPageTool: ToolConfig = { + id: 'coda_export_page', + name: 'Coda Export Page', + description: + 'Start exporting a Coda page as HTML or Markdown. Poll Get Page Export Status with the returned export ID for the download link.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + pageId: PAGE_ID_PARAM, + outputFormat: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Export format: "markdown" or "html"', + }, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'pages', [params.pageId, 'pageId'], 'export')), + method: 'POST', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => ({ outputFormat: params.outputFormat }), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { id: string; status: string; href: string } + return { success: true, output: { exportId: data.id, status: data.status, href: data.href } } + }, + + outputs: { + exportId: { type: 'string', description: 'ID of the export request' }, + status: { type: 'string', description: 'Export status (inProgress, failed, complete)' }, + href: { type: 'string', description: 'API link that reports the export status' }, + }, +} diff --git a/apps/sim/tools/coda/get_acl_settings.ts b/apps/sim/tools/coda/get_acl_settings.ts new file mode 100644 index 00000000000..fe4df28451e --- /dev/null +++ b/apps/sim/tools/coda/get_acl_settings.ts @@ -0,0 +1,45 @@ +import type { CodaAclSettingsResponse, CodaDocParams } from '@/tools/coda/types' +import { + ACL_SETTINGS_OUTPUTS, + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetAclSettingsTool: ToolConfig = { + id: 'coda_get_acl_settings', + name: 'Coda Get Sharing Settings', + description: 'Get the sharing settings of a Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'acl', 'settings')), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as CodaAclSettingsResponse['output'] + return { + success: true, + output: { + allowEditorsToChangePermissions: data.allowEditorsToChangePermissions, + allowCopying: data.allowCopying, + allowViewersToRequestEditing: data.allowViewersToRequestEditing, + }, + } + }, + + outputs: ACL_SETTINGS_OUTPUTS, +} diff --git a/apps/sim/tools/coda/get_analytics_last_updated.ts b/apps/sim/tools/coda/get_analytics_last_updated.ts new file mode 100644 index 00000000000..d7bf9ba6431 --- /dev/null +++ b/apps/sim/tools/coda/get_analytics_last_updated.ts @@ -0,0 +1,53 @@ +import type { CodaAnalyticsLastUpdatedResponse, CodaAuthParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetAnalyticsLastUpdatedTool: ToolConfig< + CodaAuthParams, + CodaAnalyticsLastUpdatedResponse +> = { + id: 'coda_get_analytics_last_updated', + name: 'Coda Get Analytics Last Updated', + description: + 'Get the dates (Pacific time) Coda analytics were last refreshed, to know how current analytics data is', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams }, + + request: { + url: () => buildCodaUrl('/analytics/updated'), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as CodaAnalyticsLastUpdatedResponse['output'] + return { + success: true, + output: { + docAnalyticsLastUpdated: data.docAnalyticsLastUpdated, + packAnalyticsLastUpdated: data.packAnalyticsLastUpdated, + packFormulaAnalyticsLastUpdated: data.packFormulaAnalyticsLastUpdated, + }, + } + }, + + outputs: { + docAnalyticsLastUpdated: { type: 'string', description: 'Date doc analytics last updated' }, + packAnalyticsLastUpdated: { type: 'string', description: 'Date Pack analytics last updated' }, + packFormulaAnalyticsLastUpdated: { + type: 'string', + description: 'Date Pack formula analytics last updated', + }, + }, +} diff --git a/apps/sim/tools/coda/get_column.ts b/apps/sim/tools/coda/get_column.ts new file mode 100644 index 00000000000..fa1cf7cfc10 --- /dev/null +++ b/apps/sim/tools/coda/get_column.ts @@ -0,0 +1,59 @@ +import type { CodaColumnResponse, CodaGetColumnParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + COLUMN_PROPERTIES, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + mapColumn, + type RawCodaColumn, + TABLE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetColumnTool: ToolConfig = { + id: 'coda_get_column', + name: 'Coda Get Column', + description: 'Get details about a column in a Coda table, including its full format settings', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + tableId: TABLE_ID_PARAM, + columnId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID or name of the column (IDs are recommended, e.g., "c-tuVwxYz")', + }, + }, + + request: { + url: (params) => + buildCodaUrl( + codaDocPath(params.docId, 'tables', [params.tableId, 'tableId'], 'columns', [ + params.columnId, + 'columnId', + ]) + ), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaColumn + return { success: true, output: { column: mapColumn(data) } } + }, + + outputs: { + column: { type: 'object', description: 'Column details', properties: COLUMN_PROPERTIES }, + }, +} diff --git a/apps/sim/tools/coda/get_control.ts b/apps/sim/tools/coda/get_control.ts new file mode 100644 index 00000000000..80e364e8d55 --- /dev/null +++ b/apps/sim/tools/coda/get_control.ts @@ -0,0 +1,72 @@ +import type { CodaControlResponse, CodaGetControlParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + mapControl, + NAMED_REFERENCE_PROPERTIES, + type RawCodaNamedReference, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetControlTool: ToolConfig = { + id: 'coda_get_control', + name: 'Coda Get Control', + description: 'Get the type and current value of a control in a Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + controlId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID or name of the control (IDs are recommended, e.g., "ctrl-cDefGhij")', + }, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'controls', [params.controlId, 'controlId'])), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaNamedReference & { + controlType?: string + value?: unknown + } + return { success: true, output: { control: mapControl(data) } } + }, + + outputs: { + control: { + type: 'object', + description: 'Control details', + properties: { + ...NAMED_REFERENCE_PROPERTIES, + controlType: { + type: 'string', + description: + 'Control type (aiBlock, button, checkbox, datePicker, dateRangePicker, dateTimePicker, lookup, multiselect, select, scale, slider, reaction, textbox, timePicker)', + nullable: true, + }, + value: { + type: 'json', + description: 'Current value (string, number, boolean, or array of these)', + nullable: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/coda/get_custom_domain_provider.ts b/apps/sim/tools/coda/get_custom_domain_provider.ts new file mode 100644 index 00000000000..ec601098d9a --- /dev/null +++ b/apps/sim/tools/coda/get_custom_domain_provider.ts @@ -0,0 +1,54 @@ +import type { + CodaGetCustomDomainProviderParams, + CodaGetCustomDomainProviderResponse, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + CUSTOM_DOMAIN_PARAM, + codaAuthParams, + codaHeaders, + codaOAuth, + codaPath, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetCustomDomainProviderTool: ToolConfig< + CodaGetCustomDomainProviderParams, + CodaGetCustomDomainProviderResponse +> = { + id: 'coda_get_custom_domain_provider', + name: 'Coda Get Custom Domain Provider', + description: + 'Look up the DNS provider (GoDaddy, Namecheap, Hover, Network Solutions, Google Domains, or Other) of a custom domain', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, customDocDomain: CUSTOM_DOMAIN_PARAM }, + + request: { + url: (params) => + buildCodaUrl(codaPath('domains', 'provider', [params.customDocDomain, 'customDocDomain'])), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response, params) => { + const data = (await response.json()) as { provider: string } + return { + success: true, + output: { + customDocDomain: String(params?.customDocDomain ?? '').trim(), + provider: data.provider, + }, + } + }, + + outputs: { + customDocDomain: { type: 'string', description: 'The custom domain' }, + provider: { type: 'string', description: 'DNS provider of the domain' }, + }, +} diff --git a/apps/sim/tools/coda/get_doc.ts b/apps/sim/tools/coda/get_doc.ts new file mode 100644 index 00000000000..56b82aada5a --- /dev/null +++ b/apps/sim/tools/coda/get_doc.ts @@ -0,0 +1,43 @@ +import type { CodaDocParams, CodaDocResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + DOC_PROPERTIES, + mapDoc, + type RawCodaDoc, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetDocTool: ToolConfig = { + id: 'coda_get_doc', + name: 'Coda Get Doc', + description: + 'Get metadata for a Coda doc, including its owner, workspace, folder, size, and publishing settings', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId)), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaDoc + return { success: true, output: { doc: mapDoc(data) } } + }, + + outputs: { + doc: { type: 'object', description: 'Doc metadata', properties: DOC_PROPERTIES }, + }, +} diff --git a/apps/sim/tools/coda/get_doc_analytics_summary.ts b/apps/sim/tools/coda/get_doc_analytics_summary.ts new file mode 100644 index 00000000000..82543342c68 --- /dev/null +++ b/apps/sim/tools/coda/get_doc_analytics_summary.ts @@ -0,0 +1,76 @@ +import type { + CodaDocAnalyticsSummaryParams, + CodaDocAnalyticsSummaryResponse, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + optionalTrimmed, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetDocAnalyticsSummaryTool: ToolConfig< + CodaDocAnalyticsSummaryParams, + CodaDocAnalyticsSummaryResponse +> = { + id: 'coda_get_doc_analytics_summary', + name: 'Coda Get Doc Analytics Summary', + description: 'Get the total number of sessions across the Coda docs the user can access', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + isPublished: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only include published docs', + }, + sinceDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include activity on or after this date (YYYY-MM-DD)', + }, + untilDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include activity on or before this date (YYYY-MM-DD)', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include docs in this workspace', + }, + }, + + request: { + url: (params) => + buildCodaUrl('/analytics/docs/summary', { + isPublished: params.isPublished, + sinceDate: optionalTrimmed(params.sinceDate), + untilDate: optionalTrimmed(params.untilDate), + workspaceId: optionalTrimmed(params.workspaceId), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { totalSessions: number } + return { success: true, output: { totalSessions: data.totalSessions } } + }, + + outputs: { + totalSessions: { type: 'number', description: 'Total sessions across all matching docs' }, + }, +} diff --git a/apps/sim/tools/coda/get_folder.ts b/apps/sim/tools/coda/get_folder.ts new file mode 100644 index 00000000000..7a3db5aace6 --- /dev/null +++ b/apps/sim/tools/coda/get_folder.ts @@ -0,0 +1,42 @@ +import type { CodaFolderParams, CodaFolderResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + codaPath, + FOLDER_ID_PARAM, + FOLDER_PROPERTIES, + mapFolder, + type RawCodaFolder, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetFolderTool: ToolConfig = { + id: 'coda_get_folder', + name: 'Coda Get Folder', + description: 'Get details about a Coda folder', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, folderId: FOLDER_ID_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaPath('folders', [params.folderId, 'folderId'])), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaFolder + return { success: true, output: { folder: mapFolder(data) } } + }, + + outputs: { + folder: { type: 'object', description: 'Folder details', properties: FOLDER_PROPERTIES }, + }, +} diff --git a/apps/sim/tools/coda/get_formula.ts b/apps/sim/tools/coda/get_formula.ts new file mode 100644 index 00000000000..3f66c9fc83c --- /dev/null +++ b/apps/sim/tools/coda/get_formula.ts @@ -0,0 +1,63 @@ +import type { CodaFormulaResponse, CodaGetFormulaParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + mapFormula, + NAMED_REFERENCE_PROPERTIES, + type RawCodaNamedReference, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetFormulaTool: ToolConfig = { + id: 'coda_get_formula', + name: 'Coda Get Formula', + description: 'Get the current computed value of a named formula in a Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + formulaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID or name of the formula (IDs are recommended, e.g., "f-fgHijkLm")', + }, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'formulas', [params.formulaId, 'formulaId'])), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaNamedReference & { value?: unknown } + return { success: true, output: { formula: mapFormula(data) } } + }, + + outputs: { + formula: { + type: 'object', + description: 'Formula details', + properties: { + ...NAMED_REFERENCE_PROPERTIES, + value: { + type: 'json', + description: 'Computed value (string, number, boolean, or array of these)', + nullable: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/coda/get_mutation_status.ts b/apps/sim/tools/coda/get_mutation_status.ts new file mode 100644 index 00000000000..99fdc4354a2 --- /dev/null +++ b/apps/sim/tools/coda/get_mutation_status.ts @@ -0,0 +1,55 @@ +import type { CodaGetMutationStatusParams, CodaGetMutationStatusResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + codaPath, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetMutationStatusTool: ToolConfig< + CodaGetMutationStatusParams, + CodaGetMutationStatusResponse +> = { + id: 'coda_get_mutation_status', + name: 'Coda Get Mutation Status', + description: + 'Check whether a queued Coda change (row, page, publish, or automation request) has been applied. Status is kept for about a day.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + requestId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Request ID returned by a Coda write operation', + }, + }, + + request: { + url: (params) => buildCodaUrl(codaPath('mutationStatus', [params.requestId, 'requestId'])), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { completed: boolean; warning?: string } + return { success: true, output: { completed: data.completed, warning: data.warning ?? null } } + }, + + outputs: { + completed: { type: 'boolean', description: 'Whether the change has been applied' }, + warning: { + type: 'string', + description: 'Warning if the change completed with caveats', + nullable: true, + }, + }, +} diff --git a/apps/sim/tools/coda/get_page.ts b/apps/sim/tools/coda/get_page.ts new file mode 100644 index 00000000000..0a8e48625de --- /dev/null +++ b/apps/sim/tools/coda/get_page.ts @@ -0,0 +1,43 @@ +import type { CodaPageParams, CodaPageResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + mapPage, + PAGE_ID_PARAM, + PAGE_PROPERTIES, + type RawCodaPage, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetPageTool: ToolConfig = { + id: 'coda_get_page', + name: 'Coda Get Page', + description: 'Get metadata for a page in a Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM, pageId: PAGE_ID_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'pages', [params.pageId, 'pageId'])), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaPage + return { success: true, output: { page: mapPage(data) } } + }, + + outputs: { + page: { type: 'object', description: 'Page metadata', properties: PAGE_PROPERTIES }, + }, +} diff --git a/apps/sim/tools/coda/get_page_content.ts b/apps/sim/tools/coda/get_page_content.ts new file mode 100644 index 00000000000..686fe4cf034 --- /dev/null +++ b/apps/sim/tools/coda/get_page_content.ts @@ -0,0 +1,110 @@ +import type { + CodaGetPageContentParams, + CodaGetPageContentResponse, + CodaPageContentItem, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_ID_PARAM, + PAGE_TOKEN_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +interface RawPageContentItem { + id: string + type: string + itemContent?: { style?: string; format?: string; content?: string; lineLevel?: number } +} + +export const codaGetPageContentTool: ToolConfig< + CodaGetPageContentParams, + CodaGetPageContentResponse +> = { + id: 'coda_get_page_content', + name: 'Coda Get Page Content', + description: + 'Read the content of a Coda canvas page as plain-text lines with their styles (headings, paragraphs, lists, quotes, code) and element IDs', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + pageId: PAGE_ID_PARAM, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of content items to return (1-500, default 50)', + }, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'pages', [params.pageId, 'pageId'], 'content'), { + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + items?: RawPageContentItem[] + nextPageToken?: string + } + const items: CodaPageContentItem[] = (data.items ?? []).map((item) => ({ + id: item.id, + type: item.type, + style: item.itemContent?.style ?? null, + format: item.itemContent?.format ?? null, + content: item.itemContent?.content ?? null, + lineLevel: item.itemContent?.lineLevel ?? null, + })) + return { success: true, output: { items, nextPageToken: data.nextPageToken || null } } + }, + + outputs: { + items: { + type: 'array', + description: 'Content elements on the page, in order', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Element ID, usable with Update Page and Delete Page Content', + }, + type: { type: 'string', description: 'Element type (line)' }, + style: { + type: 'string', + description: + 'Line style (paragraph, h1, h2, h3, bulletedList, numberedList, checkboxList, collapsibleList, blockQuote, pullQuote, code)', + nullable: true, + }, + format: { type: 'string', description: 'Content format (plainText)', nullable: true }, + content: { type: 'string', description: 'Element text', nullable: true }, + lineLevel: { + type: 'number', + description: 'Indentation level for paragraphs, quotes, and list items', + nullable: true, + }, + }, + }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/get_page_export_status.ts b/apps/sim/tools/coda/get_page_export_status.ts new file mode 100644 index 00000000000..c218548e512 --- /dev/null +++ b/apps/sim/tools/coda/get_page_export_status.ts @@ -0,0 +1,90 @@ +import type { + CodaGetPageExportStatusParams, + CodaPageExportStatusResponse, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + PAGE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetPageExportStatusTool: ToolConfig< + CodaGetPageExportStatusParams, + CodaPageExportStatusResponse +> = { + id: 'coda_get_page_export_status', + name: 'Coda Get Page Export Status', + description: + 'Check a Coda page export and get its download link once complete. Download links expire shortly after they are issued.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + pageId: PAGE_ID_PARAM, + exportId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Export ID returned by Export Page', + }, + }, + + request: { + url: (params) => + buildCodaUrl( + codaDocPath(params.docId, 'pages', [params.pageId, 'pageId'], 'export', [ + params.exportId, + 'exportId', + ]) + ), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + id: string + status: string + href: string + downloadLink?: string + error?: string + } + return { + success: true, + output: { + exportId: data.id, + status: data.status, + href: data.href, + downloadLink: data.downloadLink ?? null, + exportError: data.error ?? null, + }, + } + }, + + outputs: { + exportId: { type: 'string', description: 'ID of the export request' }, + status: { type: 'string', description: 'Export status (inProgress, failed, complete)' }, + href: { type: 'string', description: 'API link that reports the export status' }, + downloadLink: { + type: 'string', + description: 'Short-lived download link for the exported file, once complete', + nullable: true, + }, + exportError: { + type: 'string', + description: 'Error message if the export failed', + nullable: true, + }, + }, +} diff --git a/apps/sim/tools/coda/get_row.ts b/apps/sim/tools/coda/get_row.ts new file mode 100644 index 00000000000..750230327e0 --- /dev/null +++ b/apps/sim/tools/coda/get_row.ts @@ -0,0 +1,68 @@ +import type { CodaGetRowParams, CodaRowResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + mapRow, + type RawCodaRow, + ROW_ID_PARAM, + ROW_PROPERTIES, + TABLE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetRowTool: ToolConfig = { + id: 'coda_get_row', + name: 'Coda Get Row', + description: 'Get a single row from a Coda table, including all of its cell values', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + tableId: TABLE_ID_PARAM, + rowId: ROW_ID_PARAM, + useColumnNames: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Key cell values by column name instead of column ID', + }, + valueFormat: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Cell value format: "simple" (default), "simpleWithArrays", or "rich"', + }, + }, + + request: { + url: (params) => + buildCodaUrl( + codaDocPath(params.docId, 'tables', [params.tableId, 'tableId'], 'rows', [ + params.rowId, + 'rowId', + ]), + { useColumnNames: params.useColumnNames, valueFormat: params.valueFormat } + ), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaRow + return { success: true, output: { row: mapRow(data) } } + }, + + outputs: { + row: { type: 'object', description: 'Row details and values', properties: ROW_PROPERTIES }, + }, +} diff --git a/apps/sim/tools/coda/get_sharing_metadata.ts b/apps/sim/tools/coda/get_sharing_metadata.ts new file mode 100644 index 00000000000..f4722b22cf3 --- /dev/null +++ b/apps/sim/tools/coda/get_sharing_metadata.ts @@ -0,0 +1,57 @@ +import type { CodaDocParams, CodaSharingMetadataResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetSharingMetadataTool: ToolConfig = { + id: 'coda_get_sharing_metadata', + name: 'Coda Get Sharing Metadata', + description: + 'Check whether the connected user can share or copy a Coda doc, and whether they can share it with the workspace or organization', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'acl', 'metadata')), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as CodaSharingMetadataResponse['output'] + return { + success: true, + output: { + canShare: data.canShare, + canShareWithWorkspace: data.canShareWithWorkspace, + canShareWithOrg: data.canShareWithOrg, + canCopy: data.canCopy, + }, + } + }, + + outputs: { + canShare: { type: 'boolean', description: 'Whether the user can share the doc' }, + canShareWithWorkspace: { + type: 'boolean', + description: 'Whether the user can share the doc with the workspace', + }, + canShareWithOrg: { + type: 'boolean', + description: 'Whether the user can share the doc with the organization', + }, + canCopy: { type: 'boolean', description: 'Whether the user can copy the doc' }, + }, +} diff --git a/apps/sim/tools/coda/get_table.ts b/apps/sim/tools/coda/get_table.ts new file mode 100644 index 00000000000..82dbf132270 --- /dev/null +++ b/apps/sim/tools/coda/get_table.ts @@ -0,0 +1,58 @@ +import type { CodaGetTableParams, CodaTableResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + mapTable, + type RawCodaTable, + TABLE_ID_PARAM, + TABLE_PROPERTIES, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaGetTableTool: ToolConfig = { + id: 'coda_get_table', + name: 'Coda Get Table', + description: + 'Get details about a table or view in a Coda doc, including its row count, sorts, layout, and filter', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + tableId: TABLE_ID_PARAM, + useUpdatedTableLayouts: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Report detail and form layouts as "detail" and "form" instead of "masterDetail" for both', + }, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'tables', [params.tableId, 'tableId']), { + useUpdatedTableLayouts: params.useUpdatedTableLayouts, + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaTable + return { success: true, output: { table: mapTable(data) } } + }, + + outputs: { + table: { type: 'object', description: 'Table details', properties: TABLE_PROPERTIES }, + }, +} diff --git a/apps/sim/tools/coda/index.ts b/apps/sim/tools/coda/index.ts new file mode 100644 index 00000000000..585386d8954 --- /dev/null +++ b/apps/sim/tools/coda/index.ts @@ -0,0 +1,125 @@ +import { codaAddCustomDomainTool } from '@/tools/coda/add_custom_domain' +import { codaAddPermissionTool } from '@/tools/coda/add_permission' +import { codaChangeUserRoleTool } from '@/tools/coda/change_user_role' +import { codaCreateDocTool } from '@/tools/coda/create_doc' +import { codaCreateFolderTool } from '@/tools/coda/create_folder' +import { codaCreatePageTool } from '@/tools/coda/create_page' +import { codaDeleteCustomDomainTool } from '@/tools/coda/delete_custom_domain' +import { codaDeleteDocTool } from '@/tools/coda/delete_doc' +import { codaDeleteFolderTool } from '@/tools/coda/delete_folder' +import { codaDeletePageTool } from '@/tools/coda/delete_page' +import { codaDeletePageContentTool } from '@/tools/coda/delete_page_content' +import { codaDeletePermissionTool } from '@/tools/coda/delete_permission' +import { codaDeleteRowTool } from '@/tools/coda/delete_row' +import { codaDeleteRowsTool } from '@/tools/coda/delete_rows' +import { codaExportPageTool } from '@/tools/coda/export_page' +import { codaGetAclSettingsTool } from '@/tools/coda/get_acl_settings' +import { codaGetAnalyticsLastUpdatedTool } from '@/tools/coda/get_analytics_last_updated' +import { codaGetColumnTool } from '@/tools/coda/get_column' +import { codaGetControlTool } from '@/tools/coda/get_control' +import { codaGetCustomDomainProviderTool } from '@/tools/coda/get_custom_domain_provider' +import { codaGetDocTool } from '@/tools/coda/get_doc' +import { codaGetDocAnalyticsSummaryTool } from '@/tools/coda/get_doc_analytics_summary' +import { codaGetFolderTool } from '@/tools/coda/get_folder' +import { codaGetFormulaTool } from '@/tools/coda/get_formula' +import { codaGetMutationStatusTool } from '@/tools/coda/get_mutation_status' +import { codaGetPageTool } from '@/tools/coda/get_page' +import { codaGetPageContentTool } from '@/tools/coda/get_page_content' +import { codaGetPageExportStatusTool } from '@/tools/coda/get_page_export_status' +import { codaGetRowTool } from '@/tools/coda/get_row' +import { codaGetSharingMetadataTool } from '@/tools/coda/get_sharing_metadata' +import { codaGetTableTool } from '@/tools/coda/get_table' +import { codaListCategoriesTool } from '@/tools/coda/list_categories' +import { codaListColumnsTool } from '@/tools/coda/list_columns' +import { codaListControlsTool } from '@/tools/coda/list_controls' +import { codaListCustomDomainsTool } from '@/tools/coda/list_custom_domains' +import { codaListDocAnalyticsTool } from '@/tools/coda/list_doc_analytics' +import { codaListDocsTool } from '@/tools/coda/list_docs' +import { codaListFolderChildrenTool } from '@/tools/coda/list_folder_children' +import { codaListFoldersTool } from '@/tools/coda/list_folders' +import { codaListFormulasTool } from '@/tools/coda/list_formulas' +import { codaListPageAnalyticsTool } from '@/tools/coda/list_page_analytics' +import { codaListPagesTool } from '@/tools/coda/list_pages' +import { codaListPermissionsTool } from '@/tools/coda/list_permissions' +import { codaListRowsTool } from '@/tools/coda/list_rows' +import { codaListTablesTool } from '@/tools/coda/list_tables' +import { codaListWorkspaceMembersTool } from '@/tools/coda/list_workspace_members' +import { codaListWorkspaceRolesTool } from '@/tools/coda/list_workspace_roles' +import { codaPublishDocTool } from '@/tools/coda/publish_doc' +import { codaPushButtonTool } from '@/tools/coda/push_button' +import { codaResolveBrowserLinkTool } from '@/tools/coda/resolve_browser_link' +import { codaSearchPrincipalsTool } from '@/tools/coda/search_principals' +import { codaTriggerAutomationTool } from '@/tools/coda/trigger_automation' +import { codaUnpublishDocTool } from '@/tools/coda/unpublish_doc' +import { codaUpdateAclSettingsTool } from '@/tools/coda/update_acl_settings' +import { codaUpdateDocTool } from '@/tools/coda/update_doc' +import { codaUpdateFolderTool } from '@/tools/coda/update_folder' +import { codaUpdatePageTool } from '@/tools/coda/update_page' +import { codaUpdateRowTool } from '@/tools/coda/update_row' +import { codaUpsertRowsTool } from '@/tools/coda/upsert_rows' +import { codaWhoamiTool } from '@/tools/coda/whoami' + +export { + codaAddCustomDomainTool, + codaAddPermissionTool, + codaChangeUserRoleTool, + codaCreateDocTool, + codaCreateFolderTool, + codaCreatePageTool, + codaDeleteCustomDomainTool, + codaDeleteDocTool, + codaDeleteFolderTool, + codaDeletePageContentTool, + codaDeletePageTool, + codaDeletePermissionTool, + codaDeleteRowTool, + codaDeleteRowsTool, + codaExportPageTool, + codaGetAclSettingsTool, + codaGetAnalyticsLastUpdatedTool, + codaGetColumnTool, + codaGetControlTool, + codaGetCustomDomainProviderTool, + codaGetDocAnalyticsSummaryTool, + codaGetDocTool, + codaGetFolderTool, + codaGetFormulaTool, + codaGetMutationStatusTool, + codaGetPageContentTool, + codaGetPageExportStatusTool, + codaGetPageTool, + codaGetRowTool, + codaGetSharingMetadataTool, + codaGetTableTool, + codaListCategoriesTool, + codaListColumnsTool, + codaListControlsTool, + codaListCustomDomainsTool, + codaListDocAnalyticsTool, + codaListDocsTool, + codaListFolderChildrenTool, + codaListFoldersTool, + codaListFormulasTool, + codaListPageAnalyticsTool, + codaListPagesTool, + codaListPermissionsTool, + codaListRowsTool, + codaListTablesTool, + codaListWorkspaceMembersTool, + codaListWorkspaceRolesTool, + codaPublishDocTool, + codaPushButtonTool, + codaResolveBrowserLinkTool, + codaSearchPrincipalsTool, + codaTriggerAutomationTool, + codaUnpublishDocTool, + codaUpdateAclSettingsTool, + codaUpdateDocTool, + codaUpdateFolderTool, + codaUpdatePageTool, + codaUpdateRowTool, + codaUpsertRowsTool, + codaWhoamiTool, +} + +export * from './types' diff --git a/apps/sim/tools/coda/list_categories.ts b/apps/sim/tools/coda/list_categories.ts new file mode 100644 index 00000000000..21d1cd8e960 --- /dev/null +++ b/apps/sim/tools/coda/list_categories.ts @@ -0,0 +1,48 @@ +import type { CodaAuthParams, CodaListCategoriesResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListCategoriesTool: ToolConfig = { + id: 'coda_list_categories', + name: 'Coda List Doc Categories', + description: 'List the categories that can be applied to a published Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams }, + + request: { + url: () => buildCodaUrl('/categories'), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { items?: Array<{ name?: string }> } + return { + success: true, + output: { + categories: (data.items ?? []) + .map((category) => category.name) + .filter((name): name is string => typeof name === 'string'), + }, + } + }, + + outputs: { + categories: { + type: 'array', + description: 'Category names usable when publishing a doc', + items: { type: 'string', description: 'Category name' }, + }, + }, +} diff --git a/apps/sim/tools/coda/list_columns.ts b/apps/sim/tools/coda/list_columns.ts new file mode 100644 index 00000000000..8ab4f5d4a94 --- /dev/null +++ b/apps/sim/tools/coda/list_columns.ts @@ -0,0 +1,80 @@ +import type { CodaListColumnsParams, CodaListColumnsResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + COLUMN_PROPERTIES, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + mapColumn, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, + type RawCodaColumn, + TABLE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListColumnsTool: ToolConfig = { + id: 'coda_list_columns', + name: 'Coda List Columns', + description: + 'List the columns of a Coda table with their IDs, formats, and formulas. Use column IDs when reading and writing rows.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + tableId: TABLE_ID_PARAM, + visibleOnly: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only return visible columns (applies to base tables, not views)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of columns to return (1-100, default 25)', + }, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'tables', [params.tableId, 'tableId'], 'columns'), { + visibleOnly: params.visibleOnly, + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { items?: RawCodaColumn[]; nextPageToken?: string } + return { + success: true, + output: { + columns: (data.items ?? []).map(mapColumn), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + columns: { + type: 'array', + description: 'Columns in the table', + items: { type: 'object', properties: COLUMN_PROPERTIES }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_controls.ts b/apps/sim/tools/coda/list_controls.ts new file mode 100644 index 00000000000..7daa3d63410 --- /dev/null +++ b/apps/sim/tools/coda/list_controls.ts @@ -0,0 +1,73 @@ +import type { CodaListControlsResponse, CodaListDocItemsParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + LIMIT_PARAM, + mapNamedReference, + NAMED_REFERENCE_PROPERTIES, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, + type RawCodaNamedReference, + SORT_BY_NAME_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListControlsTool: ToolConfig = { + id: 'coda_list_controls', + name: 'Coda List Controls', + description: + 'List the controls (sliders, selects, checkboxes, date pickers, buttons, etc.) in a Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + sortBy: SORT_BY_NAME_PARAM, + limit: LIMIT_PARAM, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'controls'), { + sortBy: params.sortBy, + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + items?: RawCodaNamedReference[] + nextPageToken?: string + } + return { + success: true, + output: { + controls: (data.items ?? []).map(mapNamedReference), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + controls: { + type: 'array', + description: 'Controls in the doc', + items: { type: 'object', properties: NAMED_REFERENCE_PROPERTIES }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_custom_domains.ts b/apps/sim/tools/coda/list_custom_domains.ts new file mode 100644 index 00000000000..8ddb09ed44e --- /dev/null +++ b/apps/sim/tools/coda/list_custom_domains.ts @@ -0,0 +1,86 @@ +import type { + CodaCustomDomain, + CodaDocParams, + CodaListCustomDomainsResponse, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + NEXT_PAGE_TOKEN_OUTPUT, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +type RawCustomDomain = Omit & { + lastVerifiedTimestamp?: string +} + +export const codaListCustomDomainsTool: ToolConfig = { + id: 'coda_list_custom_domains', + name: 'Coda List Custom Domains', + description: 'List the custom domains connected to a published Coda doc and their setup status', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'domains')), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + customDocDomains?: RawCustomDomain[] + nextPageToken?: string + } + return { + success: true, + output: { + customDomains: (data.customDocDomains ?? []).map((domain) => ({ + customDocDomain: domain.customDocDomain, + hasCertificate: domain.hasCertificate, + hasDnsDocId: domain.hasDnsDocId, + setupStatus: domain.setupStatus, + domainStatus: domain.domainStatus, + lastVerifiedTimestamp: domain.lastVerifiedTimestamp ?? null, + })), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + customDomains: { + type: 'array', + description: 'Custom domains for the published doc', + items: { + type: 'object', + properties: { + customDocDomain: { type: 'string', description: 'The custom domain' }, + hasCertificate: { type: 'boolean', description: 'Whether the domain has a certificate' }, + hasDnsDocId: { + type: 'boolean', + description: 'Whether the domain DNS points back to this doc', + }, + setupStatus: { type: 'string', description: 'Setup status (pending, succeeded, failed)' }, + domainStatus: { type: 'string', description: 'connected or notConnected' }, + lastVerifiedTimestamp: { + type: 'string', + description: 'When the DNS settings were last checked', + nullable: true, + }, + }, + }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_doc_analytics.ts b/apps/sim/tools/coda/list_doc_analytics.ts new file mode 100644 index 00000000000..12ca60f6dd9 --- /dev/null +++ b/apps/sim/tools/coda/list_doc_analytics.ts @@ -0,0 +1,275 @@ +import type { + CodaDocAnalyticsItem, + CodaListDocAnalyticsParams, + CodaListDocAnalyticsResponse, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + ICON_PROPERTIES, + joinListParam, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +const DOC_METRIC_KEYS = [ + 'views', + 'copies', + 'likes', + 'sessionsMobile', + 'sessionsDesktop', + 'sessionsOther', + 'totalSessions', + 'aiCreditsChat', + 'aiCreditsBlock', + 'aiCreditsColumn', + 'aiCreditsAssistant', + 'aiCreditsReviewer', + 'aiCredits', +] as const + +interface RawDocAnalyticsItem { + doc: { + id: string + title: string + href: string + browserLink: string + icon?: { name?: string; type?: string; browserLink?: string } + createdAt?: string + publishedAt?: string + } + metrics?: Array & { date?: string }> +} + +function toNumberOrNull(value: unknown): number | null { + return typeof value === 'number' ? value : null +} + +export const codaListDocAnalyticsTool: ToolConfig< + CodaListDocAnalyticsParams, + CodaListDocAnalyticsResponse +> = { + id: 'coda_list_doc_analytics', + name: 'Coda List Doc Analytics', + description: + 'Get per-day or cumulative analytics (views, copies, likes, sessions by device, AI credits) for Coda docs', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docIds: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Doc IDs to fetch analytics for, as an array or comma-separated list', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include docs in this workspace', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search term used to filter docs', + }, + isPublished: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only include published docs', + }, + sinceDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include activity on or after this date (YYYY-MM-DD)', + }, + untilDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include activity on or before this date (YYYY-MM-DD)', + }, + scale: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Aggregation: "daily" (default) or "cumulative"', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Sort field: date, docId, title, createdAt, publishedAt, likes, copies, views, sessionsDesktop, sessionsMobile, sessionsOther, totalSessions, or an aiCredits field', + }, + direction: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort direction: "ascending" or "descending"', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return (1-5000, default 1000)', + }, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl('/analytics/docs', { + docIds: joinListParam(params.docIds, 'docIds'), + workspaceId: optionalTrimmed(params.workspaceId), + query: optionalTrimmed(params.query), + isPublished: params.isPublished, + sinceDate: optionalTrimmed(params.sinceDate), + untilDate: optionalTrimmed(params.untilDate), + scale: params.scale, + orderBy: params.orderBy, + direction: params.direction, + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + items?: RawDocAnalyticsItem[] + nextPageToken?: string + } + const items: CodaDocAnalyticsItem[] = (data.items ?? []).map((item) => ({ + doc: { + id: item.doc.id, + title: item.doc.title, + href: item.doc.href, + browserLink: item.doc.browserLink, + icon: item.doc.icon + ? { + name: item.doc.icon.name ?? null, + type: item.doc.icon.type ?? null, + browserLink: item.doc.icon.browserLink ?? null, + } + : null, + createdAt: item.doc.createdAt ?? null, + publishedAt: item.doc.publishedAt ?? null, + }, + metrics: (item.metrics ?? []).map((metric) => { + const projected: Record = { date: metric.date ?? null } + for (const key of DOC_METRIC_KEYS) projected[key] = toNumberOrNull(metric[key]) + return projected + }), + })) + return { success: true, output: { items, nextPageToken: data.nextPageToken || null } } + }, + + outputs: { + items: { + type: 'array', + description: 'Analytics per doc', + items: { + type: 'object', + properties: { + doc: { + type: 'object', + description: 'Doc the metrics belong to', + properties: { + id: { type: 'string', description: 'Doc ID' }, + title: { type: 'string', description: 'Doc title' }, + href: { type: 'string', description: 'API link to the doc' }, + browserLink: { type: 'string', description: 'Browser link to the doc' }, + icon: { + type: 'object', + description: 'Doc icon', + nullable: true, + properties: ICON_PROPERTIES, + }, + createdAt: { type: 'string', description: 'Doc creation time', nullable: true }, + publishedAt: { type: 'string', description: 'Doc publish time', nullable: true }, + }, + }, + metrics: { + type: 'array', + description: 'Metrics per date', + items: { + type: 'object', + properties: { + date: { + type: 'string', + description: 'Date of the data (YYYY-MM-DD)', + nullable: true, + }, + views: { type: 'number', description: 'Doc views', nullable: true }, + copies: { type: 'number', description: 'Doc copies', nullable: true }, + likes: { type: 'number', description: 'Doc likes', nullable: true }, + sessionsMobile: { + type: 'number', + description: 'Unique mobile visitors', + nullable: true, + }, + sessionsDesktop: { + type: 'number', + description: 'Unique desktop visitors', + nullable: true, + }, + sessionsOther: { + type: 'number', + description: 'Unique visitors on other devices', + nullable: true, + }, + totalSessions: { + type: 'number', + description: 'Sessions across all devices', + nullable: true, + }, + aiCreditsChat: { + type: 'number', + description: 'AI credits used by chat', + nullable: true, + }, + aiCreditsBlock: { + type: 'number', + description: 'AI credits used by AI blocks', + nullable: true, + }, + aiCreditsColumn: { + type: 'number', + description: 'AI credits used by AI columns', + nullable: true, + }, + aiCreditsAssistant: { + type: 'number', + description: 'AI credits used by the assistant', + nullable: true, + }, + aiCreditsReviewer: { + type: 'number', + description: 'AI credits used by the reviewer', + nullable: true, + }, + aiCredits: { type: 'number', description: 'Total AI credits used', nullable: true }, + }, + }, + }, + }, + }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_docs.ts b/apps/sim/tools/coda/list_docs.ts new file mode 100644 index 00000000000..d6c98143381 --- /dev/null +++ b/apps/sim/tools/coda/list_docs.ts @@ -0,0 +1,120 @@ +import type { CodaListDocsParams, CodaListDocsResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + DOC_PROPERTIES, + LIMIT_PARAM, + mapDoc, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, + type RawCodaDoc, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListDocsTool: ToolConfig = { + id: 'coda_list_docs', + name: 'Coda List Docs', + description: + 'List Coda docs the user has opened, most recently used first, filtered by search, owner, publishing, stars, workspace, folder, or source doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search term used to filter docs', + }, + isOwner: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only return docs owned by the user', + }, + isPublished: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only return published docs', + }, + isStarred: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'true returns only starred docs; false returns only unstarred docs', + }, + inGallery: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only return docs visible in the gallery', + }, + sourceDoc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return docs copied from this doc ID', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return docs in this workspace (e.g., "ws-1Ab234")', + }, + folderId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return docs in this folder (e.g., "fl-1Ab234")', + }, + limit: LIMIT_PARAM, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl('/docs', { + query: optionalTrimmed(params.query), + isOwner: params.isOwner, + isPublished: params.isPublished, + isStarred: params.isStarred, + inGallery: params.inGallery, + sourceDoc: optionalTrimmed(params.sourceDoc), + workspaceId: optionalTrimmed(params.workspaceId), + folderId: optionalTrimmed(params.folderId), + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { items?: RawCodaDoc[]; nextPageToken?: string } + return { + success: true, + output: { + docs: (data.items ?? []).map(mapDoc), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + docs: { + type: 'array', + description: 'Docs matching the filters', + items: { type: 'object', properties: DOC_PROPERTIES }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_folder_children.ts b/apps/sim/tools/coda/list_folder_children.ts new file mode 100644 index 00000000000..484e67be709 --- /dev/null +++ b/apps/sim/tools/coda/list_folder_children.ts @@ -0,0 +1,86 @@ +import type { + CodaListFolderChildrenParams, + CodaListFolderChildrenResponse, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + codaPath, + FOLDER_CHILD_PROPERTIES, + FOLDER_ID_PARAM, + LIMIT_PARAM, + mapFolder, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, + type RawCodaFolder, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListFolderChildrenTool: ToolConfig< + CodaListFolderChildrenParams, + CodaListFolderChildrenResponse +> = { + id: 'coda_list_folder_children', + name: 'Coda List Subfolders', + description: + 'List the direct subfolders of a Coda folder. Subfolders you cannot access but manage the parent of are returned with only an ID and restricted visibility.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + folderId: FOLDER_ID_PARAM, + limit: LIMIT_PARAM, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaPath('folders', [params.folderId, 'folderId'], 'children'), { + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { items?: RawCodaFolder[]; nextPageToken?: string } + return { + success: true, + output: { + children: (data.items ?? []).map((folder) => { + const { icon: _icon, ...child } = mapFolder(folder) + return { ...child, visibility: folder.visibility ?? 'visible' } + }), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + children: { + type: 'array', + description: 'Direct subfolders', + items: { + type: 'object', + properties: { + ...FOLDER_CHILD_PROPERTIES, + visibility: { + type: 'string', + description: + 'visible, or restricted when only the ID is returned because you cannot access the subfolder', + }, + }, + }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_folders.ts b/apps/sim/tools/coda/list_folders.ts new file mode 100644 index 00000000000..9e42fa5ae17 --- /dev/null +++ b/apps/sim/tools/coda/list_folders.ts @@ -0,0 +1,77 @@ +import type { CodaListFoldersParams, CodaListFoldersResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + FOLDER_PROPERTIES, + LIMIT_PARAM, + mapFolder, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, + type RawCodaFolder, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListFoldersTool: ToolConfig = { + id: 'coda_list_folders', + name: 'Coda List Folders', + description: 'List the Coda folders the user can access, optionally within one workspace', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + workspaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return folders in this workspace (e.g., "ws-1Ab234")', + }, + isStarred: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'true returns only starred folders; false returns only unstarred folders', + }, + limit: LIMIT_PARAM, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl('/folders', { + workspaceId: optionalTrimmed(params.workspaceId), + isStarred: params.isStarred, + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { items?: RawCodaFolder[]; nextPageToken?: string } + return { + success: true, + output: { + folders: (data.items ?? []).map(mapFolder), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + folders: { + type: 'array', + description: 'Folders the user can access', + items: { type: 'object', properties: FOLDER_PROPERTIES }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_formulas.ts b/apps/sim/tools/coda/list_formulas.ts new file mode 100644 index 00000000000..b63a4c27d86 --- /dev/null +++ b/apps/sim/tools/coda/list_formulas.ts @@ -0,0 +1,72 @@ +import type { CodaListDocItemsParams, CodaListFormulasResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + LIMIT_PARAM, + mapNamedReference, + NAMED_REFERENCE_PROPERTIES, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, + type RawCodaNamedReference, + SORT_BY_NAME_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListFormulasTool: ToolConfig = { + id: 'coda_list_formulas', + name: 'Coda List Formulas', + description: 'List the named formulas in a Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + sortBy: SORT_BY_NAME_PARAM, + limit: LIMIT_PARAM, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'formulas'), { + sortBy: params.sortBy, + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + items?: RawCodaNamedReference[] + nextPageToken?: string + } + return { + success: true, + output: { + formulas: (data.items ?? []).map(mapNamedReference), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + formulas: { + type: 'array', + description: 'Named formulas in the doc', + items: { type: 'object', properties: NAMED_REFERENCE_PROPERTIES }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_page_analytics.ts b/apps/sim/tools/coda/list_page_analytics.ts new file mode 100644 index 00000000000..d517df97f19 --- /dev/null +++ b/apps/sim/tools/coda/list_page_analytics.ts @@ -0,0 +1,179 @@ +import type { + CodaListPageAnalyticsParams, + CodaListPageAnalyticsResponse, + CodaPageAnalyticsItem, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + codaPath, + DOC_ID_PARAM, + ICON_PROPERTIES, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +const PAGE_METRIC_KEYS = [ + 'views', + 'sessions', + 'users', + 'averageSecondsViewed', + 'medianSecondsViewed', + 'tabs', +] as const + +interface RawPageAnalyticsItem { + page: { id: string; name: string; icon?: { name?: string; type?: string; browserLink?: string } } + metrics?: Array & { date?: string }> +} + +export const codaListPageAnalyticsTool: ToolConfig< + CodaListPageAnalyticsParams, + CodaListPageAnalyticsResponse +> = { + id: 'coda_list_page_analytics', + name: 'Coda List Page Analytics', + description: + 'Get daily analytics (views, sessions, users, time viewed) for each page of a Coda doc. Only available for docs in Enterprise workspaces.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + sinceDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include activity on or after this date (YYYY-MM-DD)', + }, + untilDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include activity on or before this date (YYYY-MM-DD)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return (1-5000, default 1000)', + }, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaPath('analytics', 'docs', [params.docId, 'docId'], 'pages'), { + sinceDate: optionalTrimmed(params.sinceDate), + untilDate: optionalTrimmed(params.untilDate), + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + items?: RawPageAnalyticsItem[] + nextPageToken?: string + } + const items: CodaPageAnalyticsItem[] = (data.items ?? []).map((item) => ({ + page: { + id: item.page.id, + name: item.page.name, + icon: item.page.icon + ? { + name: item.page.icon.name ?? null, + type: item.page.icon.type ?? null, + browserLink: item.page.icon.browserLink ?? null, + } + : null, + }, + metrics: (item.metrics ?? []).map((metric) => { + const projected: Record = { date: metric.date ?? null } + for (const key of PAGE_METRIC_KEYS) { + projected[key] = typeof metric[key] === 'number' ? (metric[key] as number) : null + } + return projected + }), + })) + return { success: true, output: { items, nextPageToken: data.nextPageToken || null } } + }, + + outputs: { + items: { + type: 'array', + description: 'Analytics per page', + items: { + type: 'object', + properties: { + page: { + type: 'object', + description: 'Page the metrics belong to', + properties: { + id: { type: 'string', description: 'Page ID' }, + name: { type: 'string', description: 'Page name' }, + icon: { + type: 'object', + description: 'Page icon', + nullable: true, + properties: ICON_PROPERTIES, + }, + }, + }, + metrics: { + type: 'array', + description: 'Metrics per date', + items: { + type: 'object', + properties: { + date: { + type: 'string', + description: 'Date of the data (YYYY-MM-DD)', + nullable: true, + }, + views: { type: 'number', description: 'Page views that day', nullable: true }, + sessions: { + type: 'number', + description: 'Unique browsers that viewed the page', + nullable: true, + }, + users: { + type: 'number', + description: 'Unique Coda users that viewed the page', + nullable: true, + }, + averageSecondsViewed: { + type: 'number', + description: 'Average seconds the page was viewed', + nullable: true, + }, + medianSecondsViewed: { + type: 'number', + description: 'Median seconds the page was viewed', + nullable: true, + }, + tabs: { + type: 'number', + description: 'Unique tabs that opened the doc', + nullable: true, + }, + }, + }, + }, + }, + }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_pages.ts b/apps/sim/tools/coda/list_pages.ts new file mode 100644 index 00000000000..0e439b93645 --- /dev/null +++ b/apps/sim/tools/coda/list_pages.ts @@ -0,0 +1,66 @@ +import type { CodaListPagesParams, CodaListPagesResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + LIMIT_PARAM, + mapPage, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_PROPERTIES, + PAGE_TOKEN_PARAM, + type RawCodaPage, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListPagesTool: ToolConfig = { + id: 'coda_list_pages', + name: 'Coda List Pages', + description: 'List the pages in a Coda doc, including their hierarchy', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + limit: LIMIT_PARAM, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'pages'), { + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { items?: RawCodaPage[]; nextPageToken?: string } + return { + success: true, + output: { + pages: (data.items ?? []).map(mapPage), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + pages: { + type: 'array', + description: 'Pages in the doc', + items: { type: 'object', properties: PAGE_PROPERTIES }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_permissions.ts b/apps/sim/tools/coda/list_permissions.ts new file mode 100644 index 00000000000..8329573c7b3 --- /dev/null +++ b/apps/sim/tools/coda/list_permissions.ts @@ -0,0 +1,72 @@ +import type { CodaListPermissionsParams, CodaListPermissionsResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + LIMIT_PARAM, + mapPermission, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, + PERMISSION_PROPERTIES, + type RawCodaPermission, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListPermissionsTool: ToolConfig< + CodaListPermissionsParams, + CodaListPermissionsResponse +> = { + id: 'coda_list_permissions', + name: 'Coda List Permissions', + description: 'List who a Coda doc is shared with and their access levels', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + limit: LIMIT_PARAM, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'acl', 'permissions'), { + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + items?: RawCodaPermission[] + nextPageToken?: string + } + return { + success: true, + output: { + permissions: (data.items ?? []).map(mapPermission), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + permissions: { + type: 'array', + description: 'Permissions granted on the doc', + items: { type: 'object', properties: PERMISSION_PROPERTIES }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_rows.ts b/apps/sim/tools/coda/list_rows.ts new file mode 100644 index 00000000000..4d6f978b494 --- /dev/null +++ b/apps/sim/tools/coda/list_rows.ts @@ -0,0 +1,123 @@ +import type { CodaListRowsParams, CodaListRowsResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + LIMIT_PARAM, + mapRow, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, + type RawCodaRow, + ROW_PROPERTIES, + TABLE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListRowsTool: ToolConfig = { + id: 'coda_list_rows', + name: 'Coda List Rows', + description: + 'List rows in a Coda table or view, optionally filtered by a column value, sorted, or limited to rows changed since a sync token', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + tableId: TABLE_ID_PARAM, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter as :. Quote column names and string values, e.g., c-tuVwxYz:"Apple" or "Status":"Done"', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Sort order: "createdAt" (default), "updatedAt", or "natural" (view order; implies visibleOnly)', + }, + useColumnNames: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Key cell values by column name instead of column ID', + }, + valueFormat: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Cell value format: "simple" (default), "simpleWithArrays", or "rich"', + }, + visibleOnly: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only return visible rows and columns', + }, + syncToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'nextSyncToken from a previous call, to return only rows changed since then', + }, + limit: LIMIT_PARAM, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'tables', [params.tableId, 'tableId'], 'rows'), { + query: optionalTrimmed(params.query), + sortBy: params.sortBy, + useColumnNames: params.useColumnNames, + valueFormat: params.valueFormat, + visibleOnly: params.visibleOnly, + syncToken: optionalTrimmed(params.syncToken), + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + items?: RawCodaRow[] + nextPageToken?: string + nextSyncToken?: string + } + return { + success: true, + output: { + rows: (data.items ?? []).map(mapRow), + nextPageToken: data.nextPageToken || null, + nextSyncToken: data.nextSyncToken ?? null, + }, + } + }, + + outputs: { + rows: { + type: 'array', + description: 'Rows in the table', + items: { type: 'object', properties: ROW_PROPERTIES }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + nextSyncToken: { + type: 'string', + description: 'Token to pass as syncToken later to fetch only rows changed after this call', + nullable: true, + }, + }, +} diff --git a/apps/sim/tools/coda/list_tables.ts b/apps/sim/tools/coda/list_tables.ts new file mode 100644 index 00000000000..6597124bd97 --- /dev/null +++ b/apps/sim/tools/coda/list_tables.ts @@ -0,0 +1,81 @@ +import type { CodaListTablesParams, CodaListTablesResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + joinListParam, + LIMIT_PARAM, + mapTableReference, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, + type RawCodaTableReference, + SORT_BY_NAME_PARAM, + TABLE_REFERENCE_PROPERTIES, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListTablesTool: ToolConfig = { + id: 'coda_list_tables', + name: 'Coda List Tables', + description: 'List the tables and views in a Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + tableTypes: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Table types to include, as an array or comma-separated list of "table", "view", "database" (defaults to all)', + }, + sortBy: SORT_BY_NAME_PARAM, + limit: LIMIT_PARAM, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'tables'), { + tableTypes: joinListParam(params.tableTypes, 'tableTypes'), + sortBy: params.sortBy, + limit: params.limit, + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + items?: RawCodaTableReference[] + nextPageToken?: string + } + return { + success: true, + output: { + tables: (data.items ?? []).map(mapTableReference), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + tables: { + type: 'array', + description: 'Tables and views in the doc', + items: { type: 'object', properties: TABLE_REFERENCE_PROPERTIES }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_workspace_members.ts b/apps/sim/tools/coda/list_workspace_members.ts new file mode 100644 index 00000000000..8b7d8eb9468 --- /dev/null +++ b/apps/sim/tools/coda/list_workspace_members.ts @@ -0,0 +1,150 @@ +import type { + CodaListWorkspaceMembersParams, + CodaListWorkspaceMembersResponse, + CodaWorkspaceMember, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + codaPath, + joinListParam, + NEXT_PAGE_TOKEN_OUTPUT, + optionalTrimmed, + PAGE_TOKEN_PARAM, + WORKSPACE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +type RawWorkspaceMember = Partial & { + email: string + name: string + role: string + registeredAt: string +} + +export const codaListWorkspaceMembersTool: ToolConfig< + CodaListWorkspaceMembersParams, + CodaListWorkspaceMembersResponse +> = { + id: 'coda_list_workspace_members', + name: 'Coda List Workspace Members', + description: + 'List the members of a Coda workspace with their roles and doc activity, requesting user first. The workspace must belong to an organization.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + workspaceId: WORKSPACE_ID_PARAM, + includedRoles: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Only return members with these roles, as an array or comma-separated list of "Admin", "DocMaker", "Editor"', + }, + pageToken: PAGE_TOKEN_PARAM, + }, + + request: { + url: (params) => + buildCodaUrl(codaPath('workspaces', [params.workspaceId, 'workspaceId'], 'users'), { + includedRoles: joinListParam(params.includedRoles, 'includedRoles'), + pageToken: optionalTrimmed(params.pageToken), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + items?: RawWorkspaceMember[] + nextPageToken?: string + } + return { + success: true, + output: { + members: (data.items ?? []).map((member) => ({ + email: member.email, + name: member.name, + role: member.role, + pictureUrl: member.pictureUrl ?? null, + registeredAt: member.registeredAt, + roleChangedAt: member.roleChangedAt ?? null, + lastActiveAt: member.lastActiveAt ?? null, + ownedDocs: member.ownedDocs ?? null, + docsLastActiveAt: member.docsLastActiveAt ?? null, + docCollaboratorCount: member.docCollaboratorCount ?? null, + totalDocs: member.totalDocs ?? null, + totalDocsLastActiveAt: member.totalDocsLastActiveAt ?? null, + totalDocCollaboratorsLast90Days: member.totalDocCollaboratorsLast90Days ?? null, + })), + nextPageToken: data.nextPageToken || null, + }, + } + }, + + outputs: { + members: { + type: 'array', + description: 'Workspace members', + items: { + type: 'object', + properties: { + email: { type: 'string', description: 'Email address' }, + name: { type: 'string', description: 'Name' }, + role: { type: 'string', description: 'Workspace role (Admin, DocMaker, Editor)' }, + pictureUrl: { type: 'string', description: 'Avatar link', nullable: true }, + registeredAt: { type: 'string', description: 'When the user joined the workspace' }, + roleChangedAt: { + type: 'string', + description: 'When the role last changed', + nullable: true, + }, + lastActiveAt: { + type: 'string', + description: 'Date the user last acted in any workspace', + nullable: true, + }, + ownedDocs: { + type: 'number', + description: 'Docs the user owns in this workspace', + nullable: true, + }, + docsLastActiveAt: { + type: 'string', + description: 'Date anyone last accessed a doc the user owns', + nullable: true, + }, + docCollaboratorCount: { + type: 'number', + description: 'Collaborators on docs the user owns in the last 90 days', + nullable: true, + }, + totalDocs: { + type: 'number', + description: 'Docs the user owns, manages, or added pages to in the last 90 days', + nullable: true, + }, + totalDocsLastActiveAt: { + type: 'string', + description: 'Date anyone last accessed a doc the user owns or contributed to', + nullable: true, + }, + totalDocCollaboratorsLast90Days: { + type: 'number', + description: 'Unique viewers of docs the user owns, manages, or added pages to', + nullable: true, + }, + }, + }, + }, + nextPageToken: NEXT_PAGE_TOKEN_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/list_workspace_roles.ts b/apps/sim/tools/coda/list_workspace_roles.ts new file mode 100644 index 00000000000..e0e3a5ec204 --- /dev/null +++ b/apps/sim/tools/coda/list_workspace_roles.ts @@ -0,0 +1,76 @@ +import type { + CodaListWorkspaceRolesResponse, + CodaWorkspaceParams, + CodaWorkspaceRoleActivity, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + codaPath, + WORKSPACE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaListWorkspaceRolesTool: ToolConfig< + CodaWorkspaceParams, + CodaListWorkspaceRolesResponse +> = { + id: 'coda_list_workspace_roles', + name: 'Coda List Workspace Role Activity', + description: + 'Get monthly counts of active and inactive Admins, Doc Makers, and Editors in a workspace. The workspace must belong to an organization.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, workspaceId: WORKSPACE_ID_PARAM }, + + request: { + url: (params) => + buildCodaUrl(codaPath('workspaces', [params.workspaceId, 'workspaceId'], 'roles')), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { items?: CodaWorkspaceRoleActivity[] } + return { + success: true, + output: { + roleActivity: (data.items ?? []).map((item) => ({ + month: item.month, + activeAdminCount: item.activeAdminCount, + activeDocMakerCount: item.activeDocMakerCount, + activeEditorCount: item.activeEditorCount, + inactiveAdminCount: item.inactiveAdminCount, + inactiveDocMakerCount: item.inactiveDocMakerCount, + inactiveEditorCount: item.inactiveEditorCount, + })), + }, + } + }, + + outputs: { + roleActivity: { + type: 'array', + description: 'Role counts per month', + items: { + type: 'object', + properties: { + month: { type: 'string', description: 'Month of the data (YYYY-MM-DD)' }, + activeAdminCount: { type: 'number', description: 'Active Admins' }, + activeDocMakerCount: { type: 'number', description: 'Active Doc Makers' }, + activeEditorCount: { type: 'number', description: 'Active Editors' }, + inactiveAdminCount: { type: 'number', description: 'Inactive Admins' }, + inactiveDocMakerCount: { type: 'number', description: 'Inactive Doc Makers' }, + inactiveEditorCount: { type: 'number', description: 'Inactive Editors' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/coda/publish_doc.ts b/apps/sim/tools/coda/publish_doc.ts new file mode 100644 index 00000000000..07ac562099a --- /dev/null +++ b/apps/sim/tools/coda/publish_doc.ts @@ -0,0 +1,80 @@ +import type { CodaPublishDocParams, CodaRequestIdResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + optionalTrimmed, + parseStringList, + REQUEST_ID_OUTPUT, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaPublishDocTool: ToolConfig = { + id: 'coda_publish_doc', + name: 'Coda Publish Doc', + description: + 'Publish a Coda doc or update its publishing settings: URL slug, discoverability, categories, and interaction mode. The doc owner needs a Coda maker profile.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL slug for the published doc (e.g., "my-doc")', + }, + discoverable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the published doc is discoverable in the gallery', + }, + categoryNames: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Category names to apply, as an array or comma-separated list (see List Doc Categories)', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Interaction mode for viewers: "view", "play", or "edit"', + }, + }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'publish')), + method: 'PUT', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const categoryNames = parseStringList(params.categoryNames, 'categoryNames') + return { + ...(optionalTrimmed(params.slug) ? { slug: optionalTrimmed(params.slug) } : {}), + ...(typeof params.discoverable === 'boolean' ? { discoverable: params.discoverable } : {}), + ...(categoryNames.length > 0 ? { categoryNames } : {}), + ...(params.mode ? { mode: params.mode } : {}), + } + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string } + return { success: true, output: { requestId: data.requestId } } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/push_button.ts b/apps/sim/tools/coda/push_button.ts new file mode 100644 index 00000000000..5f1c69a1489 --- /dev/null +++ b/apps/sim/tools/coda/push_button.ts @@ -0,0 +1,70 @@ +import type { CodaPushButtonParams, CodaPushButtonResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + REQUEST_ID_OUTPUT, + ROW_ID_PARAM, + TABLE_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaPushButtonTool: ToolConfig = { + id: 'coda_push_button', + name: 'Coda Push Button', + description: + 'Push a button column on a row of a Coda table, running its action. The button can perform any action in the doc.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + tableId: TABLE_ID_PARAM, + rowId: ROW_ID_PARAM, + columnId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID or name of the button column (e.g., "c-tuVwxYz")', + }, + }, + + request: { + url: (params) => + buildCodaUrl( + codaDocPath( + params.docId, + 'tables', + [params.tableId, 'tableId'], + 'rows', + [params.rowId, 'rowId'], + 'buttons', + [params.columnId, 'columnId'] + ) + ), + method: 'POST', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string; rowId: string; columnId: string } + return { + success: true, + output: { requestId: data.requestId, rowId: data.rowId, columnId: data.columnId }, + } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + rowId: { type: 'string', description: 'ID of the row containing the button' }, + columnId: { type: 'string', description: 'ID of the button column' }, + }, +} diff --git a/apps/sim/tools/coda/resolve_browser_link.ts b/apps/sim/tools/coda/resolve_browser_link.ts new file mode 100644 index 00000000000..2622ec6f155 --- /dev/null +++ b/apps/sim/tools/coda/resolve_browser_link.ts @@ -0,0 +1,95 @@ +import type { + CodaResolveBrowserLinkParams, + CodaResolveBrowserLinkResponse, +} from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + requiredTrimmed, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaResolveBrowserLinkTool: ToolConfig< + CodaResolveBrowserLinkParams, + CodaResolveBrowserLinkResponse +> = { + id: 'coda_resolve_browser_link', + name: 'Coda Resolve Browser Link', + description: + 'Resolve a Coda browser URL (doc, page, table, row, etc.) into its resource type and ID for use in other Coda operations', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + url: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Coda browser link, e.g., https://coda.io/d/_dAbCDeFGH/Launch-Status_sumnO', + }, + degradeGracefully: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'If the linked object was deleted, resolve the nearest existing parent (up to the doc) instead of failing', + }, + }, + + request: { + url: (params) => + buildCodaUrl('/resolveBrowserLink', { + url: requiredTrimmed(params.url, 'url'), + degradeGracefully: params.degradeGracefully, + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { + browserLink?: string + resource: { type: string; id: string; name?: string; href: string } + } + return { + success: true, + output: { + browserLink: data.browserLink ?? null, + resource: { + type: data.resource.type, + id: data.resource.id, + name: data.resource.name ?? null, + href: data.resource.href, + }, + }, + } + }, + + outputs: { + browserLink: { + type: 'string', + description: 'Canonical browser link to the resource', + nullable: true, + }, + resource: { + type: 'object', + description: 'The resolved resource', + properties: { + type: { + type: 'string', + description: 'Resource type (doc, page, table, row, column, formula, control, etc.)', + }, + id: { type: 'string', description: 'Resource ID' }, + name: { type: 'string', description: 'Resource name', nullable: true }, + href: { type: 'string', description: 'API link to the resource' }, + }, + }, + }, +} diff --git a/apps/sim/tools/coda/search_principals.ts b/apps/sim/tools/coda/search_principals.ts new file mode 100644 index 00000000000..ec1153eb59e --- /dev/null +++ b/apps/sim/tools/coda/search_principals.ts @@ -0,0 +1,96 @@ +import type { CodaSearchPrincipalsParams, CodaSearchPrincipalsResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + optionalTrimmed, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +interface RawSearchPrincipals { + users?: Array<{ name: string; loginId: string; pictureLink?: string }> + groups?: Array<{ groupId: string; groupName: string }> +} + +export const codaSearchPrincipalsTool: ToolConfig< + CodaSearchPrincipalsParams, + CodaSearchPrincipalsResponse +> = { + id: 'coda_search_principals', + name: 'Coda Search Principals', + description: + 'Search for users and groups a Coda doc can be shared with (up to 20 of each). Returns nothing without a query.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name or email to search for', + }, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'acl', 'principals', 'search'), { + query: optionalTrimmed(params.query), + }), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawSearchPrincipals + return { + success: true, + output: { + users: (data.users ?? []).map((user) => ({ + name: user.name, + loginId: user.loginId, + pictureLink: user.pictureLink ?? null, + })), + groups: (data.groups ?? []).map((group) => ({ + groupId: group.groupId, + groupName: group.groupName, + })), + }, + } + }, + + outputs: { + users: { + type: 'array', + description: 'Matching users', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'User name' }, + loginId: { type: 'string', description: 'User email address' }, + pictureLink: { type: 'string', description: 'Avatar link', nullable: true }, + }, + }, + }, + groups: { + type: 'array', + description: 'Matching groups', + items: { + type: 'object', + properties: { + groupId: { type: 'string', description: 'Group ID' }, + groupName: { type: 'string', description: 'Group name' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/coda/trigger_automation.ts b/apps/sim/tools/coda/trigger_automation.ts new file mode 100644 index 00000000000..664d0713d66 --- /dev/null +++ b/apps/sim/tools/coda/trigger_automation.ts @@ -0,0 +1,69 @@ +import type { CodaRequestIdResponse, CodaTriggerAutomationParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + parseJsonInput, + REQUEST_ID_OUTPUT, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaTriggerAutomationTool: ToolConfig< + CodaTriggerAutomationParams, + CodaRequestIdResponse +> = { + id: 'coda_trigger_automation', + name: 'Coda Trigger Automation', + description: + 'Trigger a webhook-invoked automation in a Coda doc, optionally passing a JSON payload the automation can read', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + ruleId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the automation rule (e.g., "grid-auto-b3Jmey6jBS")', + }, + payload: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON object passed to the automation', + }, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'hooks', 'automation', [params.ruleId, 'ruleId'])), + method: 'POST', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const payload = parseJsonInput(params.payload, 'payload') + if (payload === undefined || payload === null) return {} + if (typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('payload must be a JSON object') + } + return payload as Record + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string } + return { success: true, output: { requestId: data.requestId } } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + }, +} diff --git a/apps/sim/tools/coda/types.ts b/apps/sim/tools/coda/types.ts new file mode 100644 index 00000000000..b32167c0b5a --- /dev/null +++ b/apps/sim/tools/coda/types.ts @@ -0,0 +1,780 @@ +import type { ToolResponse } from '@/tools/types' + +export interface CodaAuthParams { + accessToken: string +} + +export interface CodaPaginationParams { + limit?: number + pageToken?: string +} + +export interface CodaDocParams extends CodaAuthParams { + docId: string +} + +export interface CodaPageParams extends CodaDocParams { + pageId: string +} + +export interface CodaTableParams extends CodaDocParams { + tableId: string +} + +export interface CodaRowParams extends CodaTableParams { + rowId: string +} + +export interface CodaIcon { + name: string | null + type: string | null + browserLink: string | null +} + +export interface CodaPerson { + name: string | null + email: string | null +} + +export interface CodaPageRef { + id: string + name: string | null + href: string | null + browserLink: string | null +} + +export interface CodaTableRef { + id: string + name: string | null + tableType: string | null + href: string | null + browserLink: string | null +} + +export interface CodaWorkspaceRef { + id: string + name: string | null + organizationId: string | null + browserLink: string | null +} + +export interface CodaDoc { + id: string + name: string + href: string + browserLink: string + icon: CodaIcon | null + owner: string | null + ownerName: string | null + createdAt: string | null + updatedAt: string | null + workspace: CodaWorkspaceRef | null + folder: { id: string; name: string | null; browserLink: string | null } | null + sourceDoc: { id: string; href: string | null; browserLink: string | null } | null + docSize: { + totalRowCount: number | null + tableAndViewCount: number | null + baseTableCount: number | null + pageCount: number | null + overApiSizeLimit: boolean | null + } | null + published: { + description: string | null + browserLink: string | null + imageLink: string | null + discoverable: boolean | null + earnCredit: boolean | null + mode: string | null + categories: string[] + } | null +} + +export interface CodaPage { + id: string + name: string + subtitle: string | null + href: string + browserLink: string + contentType: string | null + isHidden: boolean | null + isEffectivelyHidden: boolean | null + icon: CodaIcon | null + image: { + browserLink: string | null + type: string | null + width: number | null + height: number | null + } | null + parent: CodaPageRef | null + children: CodaPageRef[] + authors: CodaPerson[] + createdAt: string | null + createdBy: CodaPerson | null + updatedAt: string | null + updatedBy: CodaPerson | null +} + +export interface CodaTableReference { + id: string + name: string + tableType: string | null + href: string + browserLink: string + parent: CodaPageRef | null +} + +export interface CodaTable extends CodaTableReference { + parentTable: CodaTableRef | null + displayColumnId: string | null + rowCount: number | null + sorts: Array<{ columnId: string | null; direction: string | null }> + layout: string | null + filter: { + valid: boolean | null + isVolatile: boolean | null + hasUserFormula: boolean | null + hasTodayFormula: boolean | null + hasNowFormula: boolean | null + } | null + createdAt: string | null + updatedAt: string | null +} + +export interface CodaColumn { + id: string + name: string + href: string + display: boolean | null + calculated: boolean | null + formula: string | null + defaultValue: string | null + format: Record | null + parentTable: CodaTableRef | null +} + +export interface CodaRow { + id: string + name: string + index: number | null + href: string + browserLink: string + createdAt: string | null + updatedAt: string | null + values: Record + parentTable: CodaTableRef | null +} + +export interface CodaNamedReference { + id: string + name: string + href: string + parent: CodaPageRef | null +} + +export interface CodaFormula extends CodaNamedReference { + value: unknown +} + +export interface CodaControl extends CodaNamedReference { + controlType: string | null + value: unknown +} + +export interface CodaFolder { + id: string + name: string | null + browserLink: string | null + description: string | null + icon: CodaIcon | null + iconColor: string | null + createdAt: string | null + canEdit: boolean | null + workspace: CodaWorkspaceRef | null +} + +export interface CodaPermission { + id: string + access: string + principal: { + type: string | null + email: string | null + groupId: string | null + groupName: string | null + domain: string | null + workspaceId: string | null + internalAccessType: string | null + } +} + +export type CodaListResponse = ToolResponse & { + output: Record & { nextPageToken: string | null } +} + +export interface CodaRequestIdResponse extends ToolResponse { + output: { requestId: string } +} + +export interface CodaWhoamiResponse extends ToolResponse { + output: { + name: string + loginId: string + pictureLink: string | null + scoped: boolean | null + tokenName: string | null + workspace: CodaWorkspaceRef | null + } +} + +export interface CodaListDocsParams extends CodaAuthParams, CodaPaginationParams { + query?: string + isOwner?: boolean + isPublished?: boolean + isStarred?: boolean + inGallery?: boolean + sourceDoc?: string + workspaceId?: string + folderId?: string +} + +export type CodaListDocsResponse = CodaListResponse<'docs', CodaDoc> + +export interface CodaDocResponse extends ToolResponse { + output: { doc: CodaDoc } +} + +export interface CodaPageContentParams { + pageType?: string + contentFormat?: string + content?: string + embedUrl?: string + renderMethod?: string + sourceDocId?: string + sourcePageId?: string + syncMode?: string + includeSubpages?: boolean +} + +export interface CodaCreateDocParams extends CodaAuthParams, CodaPageContentParams { + title?: string + sourceDoc?: string + timezone?: string + folderId?: string + pageName?: string + pageSubtitle?: string + iconName?: string + imageUrl?: string +} + +export interface CodaCreateDocResponse extends ToolResponse { + output: { doc: CodaDoc; requestId: string | null } +} + +export interface CodaUpdateDocParams extends CodaDocParams { + title?: string + iconName?: string +} + +export interface CodaDocIdResponse extends ToolResponse { + output: { docId: string } +} + +export interface CodaListCategoriesResponse extends ToolResponse { + output: { categories: string[] } +} + +export interface CodaPublishDocParams extends CodaDocParams { + slug?: string + discoverable?: boolean + categoryNames?: unknown + mode?: string +} + +export interface CodaSharingMetadataResponse extends ToolResponse { + output: { + canShare: boolean + canShareWithWorkspace: boolean + canShareWithOrg: boolean + canCopy: boolean + } +} + +export interface CodaAclSettingsParams extends CodaDocParams { + allowEditorsToChangePermissions?: boolean + allowCopying?: boolean + allowViewersToRequestEditing?: boolean +} + +export interface CodaAclSettingsResponse extends ToolResponse { + output: { + allowEditorsToChangePermissions: boolean + allowCopying: boolean + allowViewersToRequestEditing: boolean + } +} + +export interface CodaSearchPrincipalsParams extends CodaDocParams { + query?: string +} + +export interface CodaSearchPrincipalsResponse extends ToolResponse { + output: { + users: Array<{ name: string; loginId: string; pictureLink: string | null }> + groups: Array<{ groupId: string; groupName: string }> + } +} + +export interface CodaListPermissionsParams extends CodaDocParams, CodaPaginationParams {} + +export type CodaListPermissionsResponse = CodaListResponse<'permissions', CodaPermission> + +export type CodaPrincipalType = 'email' | 'group' | 'domain' | 'workspace' | 'anyone' + +export interface CodaAddPermissionParams extends CodaDocParams { + access: 'readonly' | 'write' | 'comment' + principalType: CodaPrincipalType + principal?: string + suppressEmail?: boolean +} + +export interface CodaAddPermissionResponse extends ToolResponse { + output: { docId: string; access: string; principalType: string } +} + +export interface CodaDeletePermissionParams extends CodaDocParams { + permissionId: string +} + +export interface CodaDeletePermissionResponse extends ToolResponse { + output: { docId: string; permissionId: string } +} + +export interface CodaListPagesParams extends CodaDocParams, CodaPaginationParams {} + +export type CodaListPagesResponse = CodaListResponse<'pages', CodaPage> + +export interface CodaPageResponse extends ToolResponse { + output: { page: CodaPage } +} + +export interface CodaCreatePageParams extends CodaDocParams, CodaPageContentParams { + name?: string + subtitle?: string + iconName?: string + imageUrl?: string + parentPageId?: string +} + +export interface CodaUpdatePageParams extends CodaPageParams { + name?: string + subtitle?: string + iconName?: string + imageUrl?: string + isHidden?: boolean + insertionMode?: string + elementId?: string + contentFormat?: string + content?: string +} + +export interface CodaPageMutationResponse extends ToolResponse { + output: { requestId: string; pageId: string } +} + +export interface CodaDeletePageContentParams extends CodaPageParams { + elementIds?: unknown + deleteAll?: boolean +} + +export interface CodaGetPageContentParams extends CodaPageParams, CodaPaginationParams {} + +export interface CodaPageContentItem { + id: string + type: string + style: string | null + format: string | null + content: string | null + lineLevel: number | null +} + +export type CodaGetPageContentResponse = CodaListResponse<'items', CodaPageContentItem> + +export interface CodaExportPageParams extends CodaPageParams { + outputFormat: string +} + +export interface CodaExportPageResponse extends ToolResponse { + output: { exportId: string; status: string; href: string } +} + +export interface CodaGetPageExportStatusParams extends CodaPageParams { + exportId: string +} + +export interface CodaPageExportStatusResponse extends ToolResponse { + output: { + exportId: string + status: string + href: string + downloadLink: string | null + exportError: string | null + } +} + +export interface CodaListTablesParams extends CodaDocParams, CodaPaginationParams { + sortBy?: string + tableTypes?: unknown +} + +export type CodaListTablesResponse = CodaListResponse<'tables', CodaTableReference> + +export interface CodaGetTableParams extends CodaTableParams { + useUpdatedTableLayouts?: boolean +} + +export interface CodaTableResponse extends ToolResponse { + output: { table: CodaTable } +} + +export interface CodaListColumnsParams extends CodaTableParams, CodaPaginationParams { + visibleOnly?: boolean +} + +export type CodaListColumnsResponse = CodaListResponse<'columns', CodaColumn> + +export interface CodaGetColumnParams extends CodaTableParams { + columnId: string +} + +export interface CodaColumnResponse extends ToolResponse { + output: { column: CodaColumn } +} + +export interface CodaListRowsParams extends CodaTableParams, CodaPaginationParams { + query?: string + sortBy?: string + useColumnNames?: boolean + valueFormat?: string + visibleOnly?: boolean + syncToken?: string +} + +export interface CodaListRowsResponse extends ToolResponse { + output: { rows: CodaRow[]; nextPageToken: string | null; nextSyncToken: string | null } +} + +export interface CodaGetRowParams extends CodaRowParams { + useColumnNames?: boolean + valueFormat?: string +} + +export interface CodaRowResponse extends ToolResponse { + output: { row: CodaRow } +} + +export interface CodaUpsertRowsParams extends CodaTableParams { + rows: unknown + keyColumns?: unknown + disableParsing?: boolean +} + +export interface CodaUpsertRowsResponse extends ToolResponse { + output: { requestId: string; addedRowIds: string[] } +} + +export interface CodaUpdateRowParams extends CodaRowParams { + cells: unknown + disableParsing?: boolean +} + +export interface CodaRowMutationResponse extends ToolResponse { + output: { requestId: string; rowId: string } +} + +export interface CodaDeleteRowsParams extends CodaTableParams { + rowIds: unknown +} + +export interface CodaDeleteRowsResponse extends ToolResponse { + output: { requestId: string; rowIds: string[] } +} + +export interface CodaPushButtonParams extends CodaRowParams { + columnId: string +} + +export interface CodaPushButtonResponse extends ToolResponse { + output: { requestId: string; rowId: string; columnId: string } +} + +export interface CodaListDocItemsParams extends CodaDocParams, CodaPaginationParams { + sortBy?: string +} + +export type CodaListFormulasResponse = CodaListResponse<'formulas', CodaNamedReference> + +export interface CodaGetFormulaParams extends CodaDocParams { + formulaId: string +} + +export interface CodaFormulaResponse extends ToolResponse { + output: { formula: CodaFormula } +} + +export type CodaListControlsResponse = CodaListResponse<'controls', CodaNamedReference> + +export interface CodaGetControlParams extends CodaDocParams { + controlId: string +} + +export interface CodaControlResponse extends ToolResponse { + output: { control: CodaControl } +} + +export interface CodaListFoldersParams extends CodaAuthParams, CodaPaginationParams { + workspaceId?: string + isStarred?: boolean +} + +export type CodaListFoldersResponse = CodaListResponse<'folders', CodaFolder> + +export interface CodaFolderParams extends CodaAuthParams { + folderId: string +} + +export interface CodaFolderResponse extends ToolResponse { + output: { folder: CodaFolder } +} + +export interface CodaCreateFolderParams extends CodaAuthParams { + name: string + workspaceId: string + description?: string +} + +export interface CodaUpdateFolderParams extends CodaFolderParams { + name?: string + description?: string +} + +export interface CodaDeleteFolderResponse extends ToolResponse { + output: { folderId: string } +} + +export interface CodaListFolderChildrenParams extends CodaFolderParams, CodaPaginationParams {} + +export type CodaFolderChild = Omit & { visibility: string } + +export type CodaListFolderChildrenResponse = CodaListResponse<'children', CodaFolderChild> + +export interface CodaWorkspaceParams extends CodaAuthParams { + workspaceId: string +} + +export interface CodaListWorkspaceMembersParams extends CodaWorkspaceParams { + includedRoles?: unknown + pageToken?: string +} + +export interface CodaWorkspaceMember { + email: string + name: string + role: string + pictureUrl: string | null + registeredAt: string + roleChangedAt: string | null + lastActiveAt: string | null + ownedDocs: number | null + docsLastActiveAt: string | null + docCollaboratorCount: number | null + totalDocs: number | null + totalDocsLastActiveAt: string | null + totalDocCollaboratorsLast90Days: number | null +} + +export type CodaListWorkspaceMembersResponse = CodaListResponse<'members', CodaWorkspaceMember> + +export interface CodaChangeUserRoleParams extends CodaWorkspaceParams { + email: string + newRole: string +} + +export interface CodaChangeUserRoleResponse extends ToolResponse { + output: { email: string; newRole: string; roleChangedAt: string } +} + +export interface CodaWorkspaceRoleActivity { + month: string + activeAdminCount: number + activeDocMakerCount: number + activeEditorCount: number + inactiveAdminCount: number + inactiveDocMakerCount: number + inactiveEditorCount: number +} + +export interface CodaListWorkspaceRolesResponse extends ToolResponse { + output: { roleActivity: CodaWorkspaceRoleActivity[] } +} + +export interface CodaListDocAnalyticsParams extends CodaAuthParams, CodaPaginationParams { + docIds?: unknown + workspaceId?: string + query?: string + isPublished?: boolean + sinceDate?: string + untilDate?: string + scale?: string + orderBy?: string + direction?: string +} + +export interface CodaDocAnalyticsItem { + doc: { + id: string + title: string + href: string + browserLink: string + icon: CodaIcon | null + createdAt: string | null + publishedAt: string | null + } + metrics: Array> +} + +export type CodaListDocAnalyticsResponse = CodaListResponse<'items', CodaDocAnalyticsItem> + +export interface CodaListPageAnalyticsParams extends CodaDocParams, CodaPaginationParams { + sinceDate?: string + untilDate?: string +} + +export interface CodaPageAnalyticsItem { + page: { id: string; name: string; icon: CodaIcon | null } + metrics: Array> +} + +export type CodaListPageAnalyticsResponse = CodaListResponse<'items', CodaPageAnalyticsItem> + +export interface CodaDocAnalyticsSummaryParams extends CodaAuthParams { + isPublished?: boolean + sinceDate?: string + untilDate?: string + workspaceId?: string +} + +export interface CodaDocAnalyticsSummaryResponse extends ToolResponse { + output: { totalSessions: number } +} + +export interface CodaAnalyticsLastUpdatedResponse extends ToolResponse { + output: { + docAnalyticsLastUpdated: string + packAnalyticsLastUpdated: string + packFormulaAnalyticsLastUpdated: string + } +} + +export interface CodaCustomDomain { + customDocDomain: string + hasCertificate: boolean + hasDnsDocId: boolean + setupStatus: string + domainStatus: string + lastVerifiedTimestamp: string | null +} + +export interface CodaListCustomDomainsResponse extends ToolResponse { + output: { customDomains: CodaCustomDomain[]; nextPageToken: string | null } +} + +export interface CodaCustomDomainParams extends CodaDocParams { + customDocDomain: string +} + +export interface CodaCustomDomainResponse extends ToolResponse { + output: { docId: string; customDocDomain: string } +} + +export interface CodaGetCustomDomainProviderParams extends CodaAuthParams { + customDocDomain: string +} + +export interface CodaGetCustomDomainProviderResponse extends ToolResponse { + output: { customDocDomain: string; provider: string } +} + +export interface CodaResolveBrowserLinkParams extends CodaAuthParams { + url: string + degradeGracefully?: boolean +} + +export interface CodaResolveBrowserLinkResponse extends ToolResponse { + output: { + browserLink: string | null + resource: { type: string; id: string; name: string | null; href: string } + } +} + +export interface CodaGetMutationStatusParams extends CodaAuthParams { + requestId: string +} + +export interface CodaGetMutationStatusResponse extends ToolResponse { + output: { completed: boolean; warning: string | null } +} + +export interface CodaTriggerAutomationParams extends CodaDocParams { + ruleId: string + payload?: unknown +} + +export type CodaResponse = + | CodaWhoamiResponse + | CodaListDocsResponse + | CodaDocResponse + | CodaCreateDocResponse + | CodaDocIdResponse + | CodaListCategoriesResponse + | CodaRequestIdResponse + | CodaSharingMetadataResponse + | CodaAclSettingsResponse + | CodaSearchPrincipalsResponse + | CodaListPermissionsResponse + | CodaAddPermissionResponse + | CodaDeletePermissionResponse + | CodaListPagesResponse + | CodaPageResponse + | CodaPageMutationResponse + | CodaGetPageContentResponse + | CodaExportPageResponse + | CodaPageExportStatusResponse + | CodaListTablesResponse + | CodaTableResponse + | CodaListColumnsResponse + | CodaColumnResponse + | CodaListRowsResponse + | CodaRowResponse + | CodaUpsertRowsResponse + | CodaRowMutationResponse + | CodaDeleteRowsResponse + | CodaPushButtonResponse + | CodaListFormulasResponse + | CodaFormulaResponse + | CodaListControlsResponse + | CodaControlResponse + | CodaListFoldersResponse + | CodaFolderResponse + | CodaDeleteFolderResponse + | CodaListFolderChildrenResponse + | CodaListWorkspaceMembersResponse + | CodaChangeUserRoleResponse + | CodaListWorkspaceRolesResponse + | CodaListDocAnalyticsResponse + | CodaListPageAnalyticsResponse + | CodaDocAnalyticsSummaryResponse + | CodaAnalyticsLastUpdatedResponse + | CodaListCustomDomainsResponse + | CodaCustomDomainResponse + | CodaGetCustomDomainProviderResponse + | CodaResolveBrowserLinkResponse + | CodaGetMutationStatusResponse diff --git a/apps/sim/tools/coda/unpublish_doc.ts b/apps/sim/tools/coda/unpublish_doc.ts new file mode 100644 index 00000000000..6527b3bc374 --- /dev/null +++ b/apps/sim/tools/coda/unpublish_doc.ts @@ -0,0 +1,39 @@ +import type { CodaDocIdResponse, CodaDocParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaUnpublishDocTool: ToolConfig = { + id: 'coda_unpublish_doc', + name: 'Coda Unpublish Doc', + description: 'Unpublish a Coda doc', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams, docId: DOC_ID_PARAM }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'publish')), + method: 'DELETE', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (_response, params) => ({ + success: true, + output: { docId: String(params?.docId ?? '').trim() }, + }), + + outputs: { + docId: { type: 'string', description: 'ID of the unpublished doc' }, + }, +} diff --git a/apps/sim/tools/coda/update_acl_settings.ts b/apps/sim/tools/coda/update_acl_settings.ts new file mode 100644 index 00000000000..17bb1a782e7 --- /dev/null +++ b/apps/sim/tools/coda/update_acl_settings.ts @@ -0,0 +1,84 @@ +import type { CodaAclSettingsParams, CodaAclSettingsResponse } from '@/tools/coda/types' +import { + ACL_SETTINGS_OUTPUTS, + buildCodaUrl, + CODA_FIELD_UPDATE_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +const SETTING_KEYS = [ + 'allowEditorsToChangePermissions', + 'allowCopying', + 'allowViewersToRequestEditing', +] as const + +export const codaUpdateAclSettingsTool: ToolConfig = + { + id: 'coda_update_acl_settings', + name: 'Coda Update Sharing Settings', + description: + 'Update who can change permissions, copy, or request edit access on a Coda doc; unset settings are left unchanged', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + allowEditorsToChangePermissions: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Allow editors to change doc permissions', + }, + allowCopying: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Allow viewers to copy the doc', + }, + allowViewersToRequestEditing: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Allow viewers to request edit access', + }, + }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'acl', 'settings')), + method: 'PATCH', + retry: CODA_FIELD_UPDATE_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const body: Record = {} + for (const key of SETTING_KEYS) { + if (typeof params[key] === 'boolean') body[key] = params[key] + } + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one sharing setting to update') + } + return body + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as CodaAclSettingsResponse['output'] + return { + success: true, + output: { + allowEditorsToChangePermissions: data.allowEditorsToChangePermissions, + allowCopying: data.allowCopying, + allowViewersToRequestEditing: data.allowViewersToRequestEditing, + }, + } + }, + + outputs: ACL_SETTINGS_OUTPUTS, + } diff --git a/apps/sim/tools/coda/update_doc.ts b/apps/sim/tools/coda/update_doc.ts new file mode 100644 index 00000000000..2f19ffdb6a1 --- /dev/null +++ b/apps/sim/tools/coda/update_doc.ts @@ -0,0 +1,62 @@ +import type { CodaDocIdResponse, CodaUpdateDocParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_FIELD_UPDATE_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + optionalTrimmed, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaUpdateDocTool: ToolConfig = { + id: 'coda_update_doc', + name: 'Coda Update Doc', + description: + 'Rename a Coda doc or change its icon. Renaming requires Doc Maker access in the workspace.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New title of the doc', + }, + iconName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name of the icon to use (e.g., "rocket")', + }, + }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId)), + method: 'PATCH', + retry: CODA_FIELD_UPDATE_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const title = optionalTrimmed(params.title) + const iconName = optionalTrimmed(params.iconName) + if (!title && !iconName) throw new Error('Provide a title or iconName to update') + return { ...(title ? { title } : {}), ...(iconName ? { iconName } : {}) } + }, + }, + + transformResponse: async (_response, params) => ({ + success: true, + output: { docId: String(params?.docId ?? '').trim() }, + }), + + outputs: { + docId: { type: 'string', description: 'ID of the updated doc' }, + }, +} diff --git a/apps/sim/tools/coda/update_folder.ts b/apps/sim/tools/coda/update_folder.ts new file mode 100644 index 00000000000..f2f7b0df8d0 --- /dev/null +++ b/apps/sim/tools/coda/update_folder.ts @@ -0,0 +1,70 @@ +import type { CodaFolderResponse, CodaUpdateFolderParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_FIELD_UPDATE_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + codaPath, + FOLDER_ID_PARAM, + FOLDER_PROPERTIES, + mapFolder, + optionalTrimmed, + type RawCodaFolder, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaUpdateFolderTool: ToolConfig = { + id: 'coda_update_folder', + name: 'Coda Update Folder', + description: + 'Rename a Coda folder or change its description. Coda can return the folder as it was before the change; read it again to confirm.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + folderId: FOLDER_ID_PARAM, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name of the folder', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New description of the folder', + }, + }, + + request: { + url: (params) => buildCodaUrl(codaPath('folders', [params.folderId, 'folderId'])), + method: 'PATCH', + retry: CODA_FIELD_UPDATE_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const name = optionalTrimmed(params.name) + const description = params.description ? params.description : undefined + if (!name && description === undefined) { + throw new Error('Provide a name or description to update') + } + return { + ...(name ? { name } : {}), + ...(description !== undefined ? { description } : {}), + } + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawCodaFolder + return { success: true, output: { folder: mapFolder(data) } } + }, + + outputs: { + folder: { type: 'object', description: 'The updated folder', properties: FOLDER_PROPERTIES }, + }, +} diff --git a/apps/sim/tools/coda/update_page.ts b/apps/sim/tools/coda/update_page.ts new file mode 100644 index 00000000000..19644c869d6 --- /dev/null +++ b/apps/sim/tools/coda/update_page.ts @@ -0,0 +1,128 @@ +import type { CodaPageMutationResponse, CodaUpdatePageParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + optionalTrimmed, + PAGE_ID_PARAM, + REQUEST_ID_OUTPUT, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaUpdatePageTool: ToolConfig = { + id: 'coda_update_page', + name: 'Coda Update Page', + description: + 'Update a Coda page: rename it, change its subtitle, icon, cover, or visibility, and append, prepend, or replace content with Markdown or HTML. Applied asynchronously.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + pageId: PAGE_ID_PARAM, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name of the page', + }, + subtitle: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New subtitle of the page (an empty value leaves the subtitle unchanged)', + }, + iconName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name of the page icon (e.g., "rocket")', + }, + imageUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL of a cover image for the page', + }, + isHidden: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Whether the page is hidden (requires a paid Coda plan; ignored for pages that cannot be hidden)', + }, + insertionMode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'How to apply content: "append", "prepend", or "replace". Required when content is provided.', + }, + elementId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Page element to insert relative to or replace (e.g., "cl-lzqh0Q0poT"); omit to apply to the whole page', + }, + contentFormat: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Content format: "markdown" (default) or "html"', + }, + content: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Content to add to the page in the chosen format', + }, + }, + + request: { + url: (params) => buildCodaUrl(codaDocPath(params.docId, 'pages', [params.pageId, 'pageId'])), + method: 'PUT', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const body: Record = {} + if (optionalTrimmed(params.name)) body.name = optionalTrimmed(params.name) + if (params.subtitle) body.subtitle = params.subtitle + if (optionalTrimmed(params.iconName)) body.iconName = optionalTrimmed(params.iconName) + if (optionalTrimmed(params.imageUrl)) body.imageUrl = optionalTrimmed(params.imageUrl) + if (typeof params.isHidden === 'boolean') body.isHidden = params.isHidden + if (params.content) { + if (!params.insertionMode) { + throw new Error('insertionMode is required when updating page content') + } + const elementId = optionalTrimmed(params.elementId) + body.contentUpdate = { + insertionMode: params.insertionMode, + ...(elementId ? { elementId } : {}), + canvasContent: { format: params.contentFormat || 'markdown', content: params.content }, + } + } + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one page property or content to update') + } + return body + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string; id: string } + return { success: true, output: { requestId: data.requestId, pageId: data.id } } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + pageId: { type: 'string', description: 'ID of the updated page' }, + }, +} diff --git a/apps/sim/tools/coda/update_row.ts b/apps/sim/tools/coda/update_row.ts new file mode 100644 index 00000000000..c044fcd75bd --- /dev/null +++ b/apps/sim/tools/coda/update_row.ts @@ -0,0 +1,75 @@ +import type { CodaRowMutationResponse, CodaUpdateRowParams } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + parseJsonInput, + REQUEST_ID_OUTPUT, + ROW_ID_PARAM, + TABLE_ID_PARAM, + toCodaCells, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaUpdateRowTool: ToolConfig = { + id: 'coda_update_row', + name: 'Coda Update Row', + description: 'Update cell values in a row of a Coda table. Applied asynchronously.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + tableId: TABLE_ID_PARAM, + rowId: ROW_ID_PARAM, + cells: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Object mapping column IDs (or names) to new values, e.g., {"c-tuVwxYz": "Done"}, or Coda cells [{"column": "c-tuVwxYz", "value": "Done"}]', + }, + disableParsing: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Store values exactly as given without parsing them', + }, + }, + + request: { + url: (params) => + buildCodaUrl( + codaDocPath(params.docId, 'tables', [params.tableId, 'tableId'], 'rows', [ + params.rowId, + 'rowId', + ]), + { disableParsing: params.disableParsing } + ), + method: 'PUT', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const cells = toCodaCells(parseJsonInput(params.cells, 'cells'), 'cells') + if (cells.length === 0) throw new Error('cells must contain at least one column value') + return { row: { cells } } + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string; id: string } + return { success: true, output: { requestId: data.requestId, rowId: data.id } } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + rowId: { type: 'string', description: 'ID of the updated row' }, + }, +} diff --git a/apps/sim/tools/coda/upsert_rows.ts b/apps/sim/tools/coda/upsert_rows.ts new file mode 100644 index 00000000000..aa3e3be7b3f --- /dev/null +++ b/apps/sim/tools/coda/upsert_rows.ts @@ -0,0 +1,90 @@ +import type { CodaUpsertRowsParams, CodaUpsertRowsResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaDocPath, + codaHeaders, + codaOAuth, + DOC_ID_PARAM, + parseJsonInput, + parseStringList, + REQUEST_ID_OUTPUT, + TABLE_ID_PARAM, + toCodaCells, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +export const codaUpsertRowsTool: ToolConfig = { + id: 'coda_upsert_rows', + name: 'Coda Insert or Upsert Rows', + description: + 'Insert rows into a Coda base table, or update matching rows when key columns are given. Only works on base tables, not views. Applied asynchronously.', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { + ...codaAuthParams, + docId: DOC_ID_PARAM, + tableId: TABLE_ID_PARAM, + rows: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of rows. Each row maps column IDs (or names) to values, e.g., [{"c-tuVwxYz": "Apple", "c-bCdeFgh": 12}], or uses Coda cells [{"cells": [{"column": "c-tuVwxYz", "value": "Apple"}]}]', + }, + keyColumns: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Column IDs (or names) to match existing rows on, as an array or comma-separated list. Matching rows are updated instead of inserted.', + }, + disableParsing: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Store values exactly as given without parsing them', + }, + }, + + request: { + url: (params) => + buildCodaUrl(codaDocPath(params.docId, 'tables', [params.tableId, 'tableId'], 'rows'), { + disableParsing: params.disableParsing, + }), + method: 'POST', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken, true), + body: (params) => { + const parsed = parseJsonInput(params.rows, 'rows') + const rows = Array.isArray(parsed) ? parsed : parsed === undefined ? [] : [parsed] + if (rows.length === 0) throw new Error('rows must contain at least one row') + const keyColumns = parseStringList(params.keyColumns, 'keyColumns') + return { + rows: rows.map((row, index) => ({ cells: toCodaCells(row, `rows[${index}]`) })), + ...(keyColumns.length > 0 ? { keyColumns } : {}), + } + }, + }, + + transformResponse: async (response) => { + const data = (await response.json()) as { requestId: string; addedRowIds?: string[] } + return { + success: true, + output: { requestId: data.requestId, addedRowIds: data.addedRowIds ?? [] }, + } + }, + + outputs: { + requestId: REQUEST_ID_OUTPUT, + addedRowIds: { + type: 'array', + description: 'IDs of rows that will be added (only returned when no key columns are set)', + items: { type: 'string', description: 'Row ID' }, + }, + }, +} diff --git a/apps/sim/tools/coda/utils.ts b/apps/sim/tools/coda/utils.ts new file mode 100644 index 00000000000..bf44386e11a --- /dev/null +++ b/apps/sim/tools/coda/utils.ts @@ -0,0 +1,1106 @@ +import { omit } from '@sim/utils/object' +import type { + CodaColumn, + CodaControl, + CodaDoc, + CodaFolder, + CodaFormula, + CodaNamedReference, + CodaPage, + CodaPermission, + CodaRow, + CodaTable, + CodaTableReference, +} from '@/tools/coda/types' +import type { OutputProperty, ToolConfig, ToolRetryConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +export const CODA_API_BASE = 'https://coda.io/apis/v1' + +type QueryValue = string | number | boolean | null | undefined + +/** + * Builds a Coda API URL from already-guarded path segments and optional query params. + * Empty query values are omitted so unset optional filters are never sent. A page token + * already encodes the original query, and Coda rejects any other parameter sent with it + * (for example `limit` on pages, or `useColumnNames` on rows), so only the token is sent. + */ +export function buildCodaUrl(path: string, query?: Record): string { + const url = new URL(`${CODA_API_BASE}${path}`) + const pageToken = query?.pageToken + const effectiveQuery = + typeof pageToken === 'string' && pageToken.trim() !== '' ? { pageToken } : (query ?? {}) + for (const [key, value] of Object.entries(effectiveQuery)) { + if (value === undefined || value === null) continue + if (typeof value === 'string' && value.trim() === '') continue + url.searchParams.set(key, String(value)) + } + return url.toString() +} + +/** + * Joins literal path segments with traversal-guarded, percent-encoded identifiers. + * A string is a literal segment; a `[value, paramName]` tuple is a caller-supplied id. + */ +export function codaPath(...segments: Array): string { + return segments + .map((segment) => + typeof segment === 'string' ? `/${segment}` : `/${safeUrlPathSegment(segment[0], segment[1])}` + ) + .join('') +} + +/** Guards and encodes a doc-scoped path: `/docs/{docId}` plus any extra segments. */ +export function codaDocPath(docId: string, ...segments: Array): string { + return codaPath('docs', [docId, 'docId'], ...segments) +} + +/** Headers for every Coda API request; shared with the credential validator. */ +export function codaHeaders(accessToken: string, hasBody = false): Record { + return { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + ...(hasBody ? { 'Content-Type': 'application/json' } : {}), + } +} + +/** Parses a JSON value that may arrive as a serialized string from a block input. */ +export function parseJsonInput(value: unknown, paramName: string): unknown { + if (typeof value !== 'string') return value + const trimmed = value.trim() + if (!trimmed) return undefined + try { + return JSON.parse(trimmed) + } catch { + throw new Error(`${paramName} must be valid JSON`) + } +} + +/** Normalizes a list given as an array, JSON array string, or comma-separated string. */ +export function parseStringList(value: unknown, paramName: string): string[] { + if (value === undefined || value === null || value === '') return [] + if (typeof value === 'string' && !value.trim().startsWith('[')) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + } + const parsed = parseJsonInput(value, paramName) + if (!Array.isArray(parsed)) { + throw new Error(`${paramName} must be an array or a comma-separated list`) + } + return parsed.map((item) => String(item).trim()).filter(Boolean) +} + +/** Joins a list param into Coda's comma-delimited query format, or undefined when empty. */ +export function joinListParam(value: unknown, paramName: string): string | undefined { + const items = parseStringList(value, paramName) + return items.length > 0 ? items.join(',') : undefined +} + +/** Trims an optional string param, returning undefined when blank. */ +export function optionalTrimmed(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined + const trimmed = String(value).trim() + return trimmed || undefined +} + +/** + * Trims a required string param, throwing when it is blank. The executor's required check + * accepts whitespace, which would otherwise drop the value from the query string. + */ +export function requiredTrimmed(value: unknown, paramName: string): string { + const trimmed = optionalTrimmed(value) + if (!trimmed) throw new Error(`${paramName} is required`) + return trimmed +} + +interface CellEdit { + column: string + value: unknown +} + +function isCellArray(value: unknown): value is CellEdit[] { + return ( + Array.isArray(value) && + value.every( + (cell) => + cell !== null && + typeof cell === 'object' && + typeof (cell as { column?: unknown }).column === 'string' && + 'value' in cell + ) + ) +} + +/** + * Converts a row given either as Coda cells (`[{ column, value }]`), a `{ cells: [...] }` + * object, or a plain `{ columnIdOrName: value }` map into Coda's cell-edit array. An object + * is only read as the `cells` wrapper when that is its sole key and holds cell edits, so a + * column named `cells` still maps normally. + */ +export function toCodaCells(row: unknown, paramName: string): CellEdit[] { + if (isCellArray(row)) return row + if (row === null || typeof row !== 'object' || Array.isArray(row)) { + throw new Error(`${paramName} must be an object mapping columns to values`) + } + const entries = Object.entries(row) + if (entries.length === 1 && entries[0][0] === 'cells' && isCellArray(entries[0][1])) { + return entries[0][1] + } + return entries.map(([column, value]) => ({ column, value })) +} + +/** Builds Coda's `PageCreateContent` union from flat tool params, or undefined when absent. */ +export function buildPageCreateContent(params: { + pageType?: string + contentFormat?: string + content?: string + embedUrl?: string + renderMethod?: string + sourceDocId?: string + sourcePageId?: string + syncMode?: string + includeSubpages?: boolean +}): Record | undefined { + const pageType = params.pageType || 'canvas' + if (pageType === 'canvas') { + if (!params.content) return undefined + return { + type: 'canvas', + canvasContent: { format: params.contentFormat || 'markdown', content: params.content }, + } + } + if (pageType === 'embed') { + const url = optionalTrimmed(params.embedUrl) + if (!url) throw new Error('embedUrl is required when pageType is "embed"') + return { + type: 'embed', + url, + ...(params.renderMethod ? { renderMethod: params.renderMethod } : {}), + } + } + if (pageType === 'syncPage') { + const sourceDocId = optionalTrimmed(params.sourceDocId) + if (!sourceDocId) throw new Error('sourceDocId is required when pageType is "syncPage"') + if ((params.syncMode || 'page') === 'document') { + return { type: 'syncPage', mode: 'document', sourceDocId } + } + const sourcePageId = optionalTrimmed(params.sourcePageId) + if (!sourcePageId) throw new Error('sourcePageId is required for a single-page sync page') + return { + type: 'syncPage', + mode: 'page', + sourceDocId, + sourcePageId, + includeSubpages: params.includeSubpages === true, + } + } + throw new Error('pageType must be one of: canvas, embed, syncPage') +} + +export const codaAuthParams = { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Coda API token resolved from the selected credential', + }, +} satisfies ToolConfig['params'] + +export const codaOAuth = { required: true, provider: 'coda' } as const + +/** + * Coda rate-limits per user and asks API clients to back off and retry on HTTP 429. Only + * idempotent methods retry, so a timed-out insert or page creation is never duplicated. + */ +export const CODA_RETRY = { + enabled: true, + maxRetries: 3, + initialDelayMs: 1_000, + maxDelayMs: 30_000, + retryIdempotentOnly: true, +} as const satisfies ToolRetryConfig + +/** + * Coda's PATCH endpoints set the supplied fields to fixed values, so repeating one is safe even + * though the executor does not treat PATCH as idempotent. + */ +export const CODA_FIELD_UPDATE_RETRY = { + ...CODA_RETRY, + retryIdempotentOnly: false, +} as const satisfies ToolRetryConfig + +interface RawReference { + id?: string + name?: string + href?: string + browserLink?: string + tableType?: string +} + +interface RawPerson { + name?: string + email?: string +} + +interface RawIcon { + name?: string + type?: string + browserLink?: string +} + +function mapIcon(icon: RawIcon | undefined) { + if (!icon) return null + return { + name: icon.name ?? null, + type: icon.type ?? null, + browserLink: icon.browserLink ?? null, + } +} + +function mapPerson(person: RawPerson | undefined) { + if (!person) return null + return { name: person.name ?? null, email: person.email ?? null } +} + +function mapPageRef(ref: RawReference | undefined) { + if (!ref?.id) return null + return { + id: ref.id, + name: ref.name ?? null, + href: ref.href ?? null, + browserLink: ref.browserLink ?? null, + } +} + +function mapTableRef(ref: RawReference | undefined) { + if (!ref?.id) return null + return { + id: ref.id, + name: ref.name ?? null, + tableType: ref.tableType ?? null, + href: ref.href ?? null, + browserLink: ref.browserLink ?? null, + } +} + +export interface RawCodaWorkspaceReference { + id?: string + name?: string + organizationId?: string + browserLink?: string +} + +export function mapWorkspaceRef(workspace: RawCodaWorkspaceReference | undefined) { + if (!workspace?.id) return null + return { + id: workspace.id, + name: workspace.name ?? null, + organizationId: workspace.organizationId ?? null, + browserLink: workspace.browserLink ?? null, + } +} + +export interface RawCodaDoc { + id: string + name: string + href: string + browserLink: string + icon?: RawIcon + owner?: string + ownerName?: string + createdAt?: string + updatedAt?: string + workspace?: RawCodaWorkspaceReference + folder?: RawReference + sourceDoc?: RawReference + docSize?: { + totalRowCount?: number + tableAndViewCount?: number + baseTableCount?: number + pageCount?: number + overApiSizeLimit?: boolean + } + published?: { + description?: string + browserLink?: string + imageLink?: string + discoverable?: boolean + earnCredit?: boolean + mode?: string + categories?: Array<{ name?: string }> + } +} + +export function mapDoc(doc: RawCodaDoc): CodaDoc { + return { + id: doc.id, + name: doc.name, + href: doc.href, + browserLink: doc.browserLink, + icon: mapIcon(doc.icon), + owner: doc.owner ?? null, + ownerName: doc.ownerName ?? null, + createdAt: doc.createdAt ?? null, + updatedAt: doc.updatedAt ?? null, + workspace: mapWorkspaceRef(doc.workspace), + folder: doc.folder?.id + ? { + id: doc.folder.id, + name: doc.folder.name ?? null, + browserLink: doc.folder.browserLink ?? null, + } + : null, + sourceDoc: doc.sourceDoc?.id + ? { + id: doc.sourceDoc.id, + href: doc.sourceDoc.href ?? null, + browserLink: doc.sourceDoc.browserLink ?? null, + } + : null, + docSize: doc.docSize + ? { + totalRowCount: doc.docSize.totalRowCount ?? null, + tableAndViewCount: doc.docSize.tableAndViewCount ?? null, + baseTableCount: doc.docSize.baseTableCount ?? null, + pageCount: doc.docSize.pageCount ?? null, + overApiSizeLimit: doc.docSize.overApiSizeLimit ?? null, + } + : null, + published: doc.published + ? { + description: doc.published.description ?? null, + browserLink: doc.published.browserLink ?? null, + imageLink: doc.published.imageLink ?? null, + discoverable: doc.published.discoverable ?? null, + earnCredit: doc.published.earnCredit ?? null, + mode: doc.published.mode ?? null, + categories: (doc.published.categories ?? []) + .map((category) => category.name) + .filter((name): name is string => typeof name === 'string'), + } + : null, + } +} + +export interface RawCodaPage { + id: string + name: string + subtitle?: string + href: string + browserLink: string + contentType?: string + isHidden?: boolean + isEffectivelyHidden?: boolean + icon?: RawIcon + image?: { browserLink?: string; type?: string; width?: number; height?: number } + parent?: RawReference + children?: RawReference[] + authors?: RawPerson[] + createdAt?: string + createdBy?: RawPerson + updatedAt?: string + updatedBy?: RawPerson +} + +export function mapPage(page: RawCodaPage): CodaPage { + return { + id: page.id, + name: page.name, + subtitle: page.subtitle ?? null, + href: page.href, + browserLink: page.browserLink, + contentType: page.contentType ?? null, + isHidden: page.isHidden ?? null, + isEffectivelyHidden: page.isEffectivelyHidden ?? null, + icon: mapIcon(page.icon), + image: page.image + ? { + browserLink: page.image.browserLink ?? null, + type: page.image.type ?? null, + width: page.image.width ?? null, + height: page.image.height ?? null, + } + : null, + parent: mapPageRef(page.parent), + children: (page.children ?? []).flatMap((child) => { + const mapped = mapPageRef(child) + return mapped ? [mapped] : [] + }), + authors: (page.authors ?? []).flatMap((author) => { + const mapped = mapPerson(author) + return mapped ? [mapped] : [] + }), + createdAt: page.createdAt ?? null, + createdBy: mapPerson(page.createdBy), + updatedAt: page.updatedAt ?? null, + updatedBy: mapPerson(page.updatedBy), + } +} + +export interface RawCodaTableReference { + id: string + name: string + tableType?: string + href: string + browserLink: string + parent?: RawReference +} + +export function mapTableReference(table: RawCodaTableReference): CodaTableReference { + return { + id: table.id, + name: table.name, + tableType: table.tableType ?? null, + href: table.href, + browserLink: table.browserLink, + parent: mapPageRef(table.parent), + } +} + +export interface RawCodaTable extends RawCodaTableReference { + parentTable?: RawReference + displayColumn?: RawReference + rowCount?: number + sorts?: Array<{ column?: RawReference; direction?: string }> + layout?: string + filter?: { + valid?: boolean + isVolatile?: boolean + hasUserFormula?: boolean + hasTodayFormula?: boolean + hasNowFormula?: boolean + } + createdAt?: string + updatedAt?: string +} + +export function mapTable(table: RawCodaTable): CodaTable { + return { + ...mapTableReference(table), + parentTable: mapTableRef(table.parentTable), + displayColumnId: table.displayColumn?.id ?? null, + rowCount: table.rowCount ?? null, + sorts: (table.sorts ?? []).map((sort) => ({ + columnId: sort.column?.id ?? null, + direction: sort.direction ?? null, + })), + layout: table.layout ?? null, + filter: table.filter + ? { + valid: table.filter.valid ?? null, + isVolatile: table.filter.isVolatile ?? null, + hasUserFormula: table.filter.hasUserFormula ?? null, + hasTodayFormula: table.filter.hasTodayFormula ?? null, + hasNowFormula: table.filter.hasNowFormula ?? null, + } + : null, + createdAt: table.createdAt ?? null, + updatedAt: table.updatedAt ?? null, + } +} + +export interface RawCodaColumn { + id: string + name: string + href: string + display?: boolean + calculated?: boolean + formula?: string + defaultValue?: string + format?: Record & { type?: string; isArray?: boolean } + parent?: RawReference +} + +export function mapColumn(column: RawCodaColumn): CodaColumn { + return { + id: column.id, + name: column.name, + href: column.href, + display: column.display ?? null, + calculated: column.calculated ?? null, + formula: column.formula ?? null, + defaultValue: column.defaultValue ?? null, + format: column.format ?? null, + parentTable: mapTableRef(column.parent), + } +} + +export interface RawCodaRow { + id: string + name: string + index?: number + href: string + browserLink: string + createdAt?: string + updatedAt?: string + values?: Record + parent?: RawReference +} + +export function mapRow(row: RawCodaRow): CodaRow { + return { + id: row.id, + name: row.name, + index: row.index ?? null, + href: row.href, + browserLink: row.browserLink, + createdAt: row.createdAt ?? null, + updatedAt: row.updatedAt ?? null, + values: row.values ?? {}, + parentTable: mapTableRef(row.parent), + } +} + +export interface RawCodaNamedReference { + id: string + name: string + href: string + parent?: RawReference +} + +export function mapNamedReference(item: RawCodaNamedReference): CodaNamedReference { + return { + id: item.id, + name: item.name, + href: item.href, + parent: mapPageRef(item.parent), + } +} + +export function mapFormula(item: RawCodaNamedReference & { value?: unknown }): CodaFormula { + return { ...mapNamedReference(item), value: item.value ?? null } +} + +export function mapControl( + item: RawCodaNamedReference & { controlType?: string; value?: unknown } +): CodaControl { + return { + ...mapNamedReference(item), + controlType: item.controlType ?? null, + value: item.value ?? null, + } +} + +export interface RawCodaFolder { + id: string + name?: string + browserLink?: string + description?: string + icon?: RawIcon + iconColor?: string + createdAt?: string + canEdit?: boolean + workspace?: RawCodaWorkspaceReference + visibility?: string +} + +export function mapFolder(folder: RawCodaFolder): CodaFolder { + return { + id: folder.id, + name: folder.name ?? null, + browserLink: folder.browserLink ?? null, + description: folder.description ?? null, + icon: mapIcon(folder.icon), + iconColor: folder.iconColor ?? null, + createdAt: folder.createdAt ?? null, + canEdit: folder.canEdit ?? null, + workspace: mapWorkspaceRef(folder.workspace), + } +} + +export interface RawCodaPermission { + id: string + access: string + principal?: { + type?: string + email?: string + groupId?: string + groupName?: string + domain?: string + workspaceId?: string + internalAccessType?: string + } +} + +export function mapPermission(permission: RawCodaPermission): CodaPermission { + const principal = permission.principal + return { + id: permission.id, + access: permission.access, + principal: { + type: principal?.type ?? null, + email: principal?.email ?? null, + groupId: principal?.groupId ?? null, + groupName: principal?.groupName ?? null, + domain: principal?.domain ?? null, + workspaceId: principal?.workspaceId ?? null, + internalAccessType: principal?.internalAccessType ?? null, + }, + } +} + +export const NEXT_PAGE_TOKEN_OUTPUT = { + type: 'string', + description: 'Token to pass as pageToken to fetch the next page of results', + nullable: true, +} as const satisfies OutputProperty + +export const REQUEST_ID_OUTPUT = { + type: 'string', + description: + 'Coda request ID for the queued change; pass to Get Mutation Status to confirm it was applied', +} as const satisfies OutputProperty + +export const ICON_PROPERTIES = { + name: { type: 'string', description: 'Icon name', nullable: true }, + type: { type: 'string', description: 'Icon MIME type', nullable: true }, + browserLink: { type: 'string', description: 'Link to the icon image', nullable: true }, +} as const satisfies Record + +const PERSON_PROPERTIES = { + name: { type: 'string', description: 'Full name', nullable: true }, + email: { type: 'string', description: 'Email address', nullable: true }, +} as const satisfies Record + +const PAGE_REF_PROPERTIES = { + id: { type: 'string', description: 'Page ID' }, + name: { type: 'string', description: 'Page name', nullable: true }, + href: { type: 'string', description: 'API link to the page', nullable: true }, + browserLink: { type: 'string', description: 'Browser link to the page', nullable: true }, +} as const satisfies Record + +const TABLE_REF_PROPERTIES = { + id: { type: 'string', description: 'Table ID' }, + name: { type: 'string', description: 'Table name', nullable: true }, + tableType: { type: 'string', description: 'Table type (table or view)', nullable: true }, + href: { type: 'string', description: 'API link to the table', nullable: true }, + browserLink: { type: 'string', description: 'Browser link to the table', nullable: true }, +} as const satisfies Record + +export const WORKSPACE_REF_PROPERTIES = { + id: { type: 'string', description: 'Workspace ID' }, + name: { type: 'string', description: 'Workspace name', nullable: true }, + organizationId: { + type: 'string', + description: 'Organization bound to the workspace', + nullable: true, + }, + browserLink: { type: 'string', description: 'Browser link to the workspace', nullable: true }, +} as const satisfies Record + +export const DOC_PROPERTIES = { + id: { type: 'string', description: 'Doc ID' }, + name: { type: 'string', description: 'Doc name' }, + href: { type: 'string', description: 'API link to the doc' }, + browserLink: { type: 'string', description: 'Browser link to the doc' }, + icon: { type: 'object', description: 'Doc icon', nullable: true, properties: ICON_PROPERTIES }, + owner: { type: 'string', description: 'Email address of the doc owner', nullable: true }, + ownerName: { type: 'string', description: 'Name of the doc owner', nullable: true }, + createdAt: { type: 'string', description: 'Creation timestamp', nullable: true }, + updatedAt: { type: 'string', description: 'Last modified timestamp', nullable: true }, + workspace: { + type: 'object', + description: 'Workspace containing the doc', + nullable: true, + properties: WORKSPACE_REF_PROPERTIES, + }, + folder: { + type: 'object', + description: 'Folder containing the doc', + nullable: true, + properties: { + id: { type: 'string', description: 'Folder ID' }, + name: { type: 'string', description: 'Folder name', nullable: true }, + browserLink: { type: 'string', description: 'Browser link to the folder', nullable: true }, + }, + }, + sourceDoc: { + type: 'object', + description: 'Doc this doc was copied from', + nullable: true, + properties: { + id: { type: 'string', description: 'Source doc ID' }, + href: { type: 'string', description: 'API link to the source doc', nullable: true }, + browserLink: { + type: 'string', + description: 'Browser link to the source doc', + nullable: true, + }, + }, + }, + docSize: { + type: 'object', + description: 'Size of the doc', + nullable: true, + properties: { + totalRowCount: { type: 'number', description: 'Rows across all tables', nullable: true }, + tableAndViewCount: { type: 'number', description: 'Tables and views', nullable: true }, + baseTableCount: { type: 'number', description: 'Base tables', nullable: true }, + pageCount: { type: 'number', description: 'Pages', nullable: true }, + overApiSizeLimit: { + type: 'boolean', + description: 'Whether the doc is over the API size limit', + nullable: true, + }, + }, + }, + published: { + type: 'object', + description: 'Publishing settings, when the doc is published', + nullable: true, + properties: { + description: { type: 'string', description: 'Published description', nullable: true }, + browserLink: { type: 'string', description: 'Published doc link', nullable: true }, + imageLink: { type: 'string', description: 'Cover image link', nullable: true }, + discoverable: { + type: 'boolean', + description: 'Whether the doc is discoverable', + nullable: true, + }, + earnCredit: { + type: 'boolean', + description: 'Whether viewers must sign in so the owner earns credit', + nullable: true, + }, + mode: { type: 'string', description: 'Interaction mode (view, play, edit)', nullable: true }, + categories: { + type: 'array', + description: 'Category names', + items: { type: 'string', description: 'Category name' }, + }, + }, + }, +} as const satisfies Record + +export const PAGE_PROPERTIES = { + id: { type: 'string', description: 'Page ID' }, + name: { type: 'string', description: 'Page name' }, + subtitle: { type: 'string', description: 'Page subtitle', nullable: true }, + href: { type: 'string', description: 'API link to the page' }, + browserLink: { type: 'string', description: 'Browser link to the page' }, + contentType: { + type: 'string', + description: 'Page type (canvas, embed, or syncPage)', + nullable: true, + }, + isHidden: { type: 'boolean', description: 'Whether the page is hidden', nullable: true }, + isEffectivelyHidden: { + type: 'boolean', + description: 'Whether the page or any parent is hidden', + nullable: true, + }, + icon: { type: 'object', description: 'Page icon', nullable: true, properties: ICON_PROPERTIES }, + image: { + type: 'object', + description: 'Cover image', + nullable: true, + properties: { + browserLink: { type: 'string', description: 'Image link', nullable: true }, + type: { type: 'string', description: 'Image MIME type', nullable: true }, + width: { type: 'number', description: 'Width in pixels', nullable: true }, + height: { type: 'number', description: 'Height in pixels', nullable: true }, + }, + }, + parent: { + type: 'object', + description: 'Parent page', + nullable: true, + properties: PAGE_REF_PROPERTIES, + }, + children: { + type: 'array', + description: 'Direct subpages', + items: { type: 'object', properties: PAGE_REF_PROPERTIES }, + }, + authors: { + type: 'array', + description: 'Page authors', + items: { type: 'object', properties: PERSON_PROPERTIES }, + }, + createdAt: { type: 'string', description: 'Creation timestamp', nullable: true }, + createdBy: { + type: 'object', + description: 'Page creator', + nullable: true, + properties: PERSON_PROPERTIES, + }, + updatedAt: { type: 'string', description: 'Last content update timestamp', nullable: true }, + updatedBy: { + type: 'object', + description: 'Last editor of the page', + nullable: true, + properties: PERSON_PROPERTIES, + }, +} as const satisfies Record + +export const TABLE_REFERENCE_PROPERTIES = { + id: { type: 'string', description: 'Table ID' }, + name: { type: 'string', description: 'Table name' }, + tableType: { + type: 'string', + description: 'Table type (table, view, or database)', + nullable: true, + }, + href: { type: 'string', description: 'API link to the table' }, + browserLink: { type: 'string', description: 'Browser link to the table' }, + parent: { + type: 'object', + description: 'Page containing the table', + nullable: true, + properties: PAGE_REF_PROPERTIES, + }, +} as const satisfies Record + +export const TABLE_PROPERTIES = { + ...TABLE_REFERENCE_PROPERTIES, + parentTable: { + type: 'object', + description: 'Base table, when this is a view', + nullable: true, + properties: TABLE_REF_PROPERTIES, + }, + displayColumnId: { type: 'string', description: 'Display column ID', nullable: true }, + rowCount: { type: 'number', description: 'Total number of rows', nullable: true }, + sorts: { + type: 'array', + description: 'Sorts applied to the table', + items: { + type: 'object', + properties: { + columnId: { type: 'string', description: 'Sorted column ID', nullable: true }, + direction: { type: 'string', description: 'ascending or descending', nullable: true }, + }, + }, + }, + layout: { + type: 'string', + description: 'Layout (default, card, calendar, detail, form, ganttChart, etc.)', + nullable: true, + }, + filter: { + type: 'object', + description: 'Details about the table filter formula, if any', + nullable: true, + properties: { + valid: { + type: 'boolean', + description: 'Whether the filter formula is valid', + nullable: true, + }, + isVolatile: { + type: 'boolean', + description: 'Whether results can differ by context or user', + nullable: true, + }, + hasUserFormula: { type: 'boolean', description: 'Uses User()', nullable: true }, + hasTodayFormula: { type: 'boolean', description: 'Uses Today()', nullable: true }, + hasNowFormula: { type: 'boolean', description: 'Uses Now()', nullable: true }, + }, + }, + createdAt: { type: 'string', description: 'Creation timestamp', nullable: true }, + updatedAt: { type: 'string', description: 'Last modified timestamp', nullable: true }, +} as const satisfies Record + +export const COLUMN_PROPERTIES = { + id: { type: 'string', description: 'Column ID' }, + name: { type: 'string', description: 'Column name' }, + href: { type: 'string', description: 'API link to the column' }, + display: { type: 'boolean', description: 'Whether this is the display column', nullable: true }, + calculated: { + type: 'boolean', + description: 'Whether the column has a formula', + nullable: true, + }, + formula: { type: 'string', description: 'Column formula', nullable: true }, + defaultValue: { type: 'string', description: 'Default value formula', nullable: true }, + format: { + type: 'json', + description: + 'Column format: always type (text, number, date, select, lookup, button, etc.) and isArray, plus type-specific settings such as precision, currencyCode, dateFormat, options, or the referenced table', + nullable: true, + }, + parentTable: { + type: 'object', + description: 'Table containing the column (returned by Get Column)', + nullable: true, + properties: TABLE_REF_PROPERTIES, + }, +} as const satisfies Record + +export const ROW_PROPERTIES = { + id: { type: 'string', description: 'Row ID' }, + name: { type: 'string', description: 'Row display name' }, + index: { type: 'number', description: 'Index of the row in the table', nullable: true }, + href: { type: 'string', description: 'API link to the row' }, + browserLink: { type: 'string', description: 'Browser link to the row' }, + createdAt: { type: 'string', description: 'Creation timestamp', nullable: true }, + updatedAt: { type: 'string', description: 'Last modified timestamp', nullable: true }, + values: { + type: 'json', + description: 'Cell values keyed by column ID (or column name when useColumnNames is set)', + }, + parentTable: { + type: 'object', + description: 'Table containing the row (returned by Get Row)', + nullable: true, + properties: TABLE_REF_PROPERTIES, + }, +} as const satisfies Record + +export const NAMED_REFERENCE_PROPERTIES = { + id: { type: 'string', description: 'ID' }, + name: { type: 'string', description: 'Name' }, + href: { type: 'string', description: 'API link' }, + parent: { + type: 'object', + description: 'Page containing the item', + nullable: true, + properties: PAGE_REF_PROPERTIES, + }, +} as const satisfies Record + +export const FOLDER_PROPERTIES = { + id: { type: 'string', description: 'Folder ID' }, + name: { type: 'string', description: 'Folder name', nullable: true }, + browserLink: { type: 'string', description: 'Browser link to the folder', nullable: true }, + description: { type: 'string', description: 'Folder description', nullable: true }, + icon: { + type: 'object', + description: 'Folder icon', + nullable: true, + properties: ICON_PROPERTIES, + }, + iconColor: { type: 'string', description: 'Folder icon color', nullable: true }, + createdAt: { type: 'string', description: 'Creation timestamp', nullable: true }, + canEdit: { + type: 'boolean', + description: 'Whether the folder settings can be edited', + nullable: true, + }, + workspace: { + type: 'object', + description: 'Workspace containing the folder', + nullable: true, + properties: WORKSPACE_REF_PROPERTIES, + }, +} as const satisfies Record + +/** Subfolders you cannot access carry only `id` and `visibility`, so their other fields are null. */ +export const FOLDER_CHILD_PROPERTIES = omit(FOLDER_PROPERTIES, ['icon']) + +export const PERMISSION_PROPERTIES = { + id: { type: 'string', description: 'Permission ID' }, + access: { type: 'string', description: 'Access level (readonly, write, comment, none)' }, + principal: { + type: 'object', + description: 'Who the permission is granted to', + properties: { + type: { + type: 'string', + description: 'Principal type (email, group, domain, workspace, anyone, internalAccess)', + nullable: true, + }, + email: { type: 'string', description: 'Email of an email principal', nullable: true }, + groupId: { type: 'string', description: 'Group ID of a group principal', nullable: true }, + groupName: { type: 'string', description: 'Name of a group principal', nullable: true }, + domain: { type: 'string', description: 'Domain of a domain principal', nullable: true }, + workspaceId: { + type: 'string', + description: 'Workspace ID of a workspace principal', + nullable: true, + }, + internalAccessType: { + type: 'string', + description: 'Internal access type (e.g., support)', + nullable: true, + }, + }, + }, +} as const satisfies Record + +export const DOC_ID_PARAM = { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the doc (e.g., "AbCDeFGH")', +} as const + +export const PAGE_ID_PARAM = { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'ID or name of the page (IDs are recommended, e.g., "canvas-IjkLmnO"; names containing "/" are not supported)', +} as const + +export const TABLE_ID_PARAM = { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'ID or name of the table or view (IDs are recommended, e.g., "grid-pqRst-U"; names containing "/" are not supported)', +} as const + +export const ROW_ID_PARAM = { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'ID or name of the row (IDs are recommended, e.g., "i-tuVwxYz"; names containing "/" are not supported)', +} as const + +export const WORKSPACE_ID_PARAM = { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (e.g., "ws-1Ab234")', +} as const + +export const FOLDER_ID_PARAM = { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the folder (e.g., "fl-1Ab234")', +} as const + +export const LIMIT_PARAM = { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return per page', +} as const + +export const PAGE_TOKEN_PARAM = { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous response to fetch the next page', +} as const + +export const SORT_BY_NAME_PARAM = { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order; "name" sorts alphabetically', +} as const + +export const CUSTOM_DOMAIN_PARAM = { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The custom domain (e.g., "docs.example.com")', +} as const + +export const ACL_SETTINGS_OUTPUTS = { + allowEditorsToChangePermissions: { + type: 'boolean', + description: 'Whether editors can change doc permissions (otherwise only the owner can)', + }, + allowCopying: { type: 'boolean', description: 'Whether viewers can copy the doc' }, + allowViewersToRequestEditing: { + type: 'boolean', + description: 'Whether viewers can request edit access', + }, +} as const satisfies Record diff --git a/apps/sim/tools/coda/whoami.ts b/apps/sim/tools/coda/whoami.ts new file mode 100644 index 00000000000..bce21f4432c --- /dev/null +++ b/apps/sim/tools/coda/whoami.ts @@ -0,0 +1,73 @@ +import type { CodaAuthParams, CodaWhoamiResponse } from '@/tools/coda/types' +import { + buildCodaUrl, + CODA_RETRY, + codaAuthParams, + codaHeaders, + codaOAuth, + mapWorkspaceRef, + type RawCodaWorkspaceReference, + WORKSPACE_REF_PROPERTIES, +} from '@/tools/coda/utils' +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { ToolConfig } from '@/tools/types' + +interface RawWhoami { + name: string + loginId: string + pictureLink?: string + scoped?: boolean + tokenName?: string + workspace?: RawCodaWorkspaceReference +} + +export const codaWhoamiTool: ToolConfig = { + id: 'coda_whoami', + name: 'Coda Get Current User', + description: 'Get the user and default workspace behind the connected Coda API token', + version: '1.0.0', + oauth: codaOAuth, + errorExtractor: ErrorExtractorId.CODA_ERRORS, + + params: { ...codaAuthParams }, + + request: { + url: () => buildCodaUrl('/whoami'), + method: 'GET', + retry: CODA_RETRY, + headers: (params) => codaHeaders(params.accessToken), + }, + + transformResponse: async (response) => { + const data = (await response.json()) as RawWhoami + return { + success: true, + output: { + name: data.name, + loginId: data.loginId, + pictureLink: data.pictureLink ?? null, + scoped: data.scoped ?? null, + tokenName: data.tokenName ?? null, + workspace: mapWorkspaceRef(data.workspace), + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Name of the user' }, + loginId: { type: 'string', description: 'Email address of the user' }, + pictureLink: { type: 'string', description: 'Link to the user avatar', nullable: true }, + scoped: { + type: 'boolean', + description: 'Whether the token is restricted to specific docs or tables', + nullable: true, + }, + tokenName: { type: 'string', description: 'Name of the API token', nullable: true }, + workspace: { + type: 'object', + description: 'Default workspace of the user', + nullable: true, + properties: WORKSPACE_REF_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/error-extractors.ts b/apps/sim/tools/error-extractors.ts index a033d6acf67..1177c38c642 100644 --- a/apps/sim/tools/error-extractors.ts +++ b/apps/sim/tools/error-extractors.ts @@ -50,6 +50,41 @@ interface ErrorExtractorConfig { redactData?: (errorInfo?: ErrorInfo) => unknown } +const CODA_MAX_VALIDATION_MESSAGES = 5 + +/** + * Flattens Coda's validation detail (`validationErrors` or nested schema `issues`) into + * `path: message` strings. Only Coda's own path and message text is used, never the + * submitted values. + */ +function collectCodaValidationMessages(detail: unknown): string[] { + const messages = new Set() + const visit = (issue: unknown) => { + if (messages.size >= CODA_MAX_VALIDATION_MESSAGES || !issue || typeof issue !== 'object') return + const record = issue as { path?: unknown; message?: unknown; errors?: unknown } + if (Array.isArray(record.errors) && record.errors.length > 0) { + for (const branch of record.errors) { + if (Array.isArray(branch)) branch.forEach(visit) + else visit(branch) + } + return + } + if (typeof record.message !== 'string' || !record.message) return + const path = Array.isArray(record.path) + ? record.path.filter((part) => typeof part === 'string' || typeof part === 'number').join('.') + : typeof record.path === 'string' + ? record.path + : '' + messages.add(path ? `${path}: ${record.message}` : record.message) + } + if (detail && typeof detail === 'object') { + const { validationErrors, issues } = detail as { validationErrors?: unknown; issues?: unknown } + if (Array.isArray(validationErrors)) validationErrors.forEach(visit) + if (Array.isArray(issues)) issues.forEach(visit) + } + return [...messages] +} + const PITCHBOOK_UNAUTHORIZED_MESSAGE = 'PitchBook rejected the API key. Check that the key is active and has API access.' @@ -234,6 +269,23 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [ examples: ['Notion', 'Discord', 'GitHub', 'Twilio', 'Slack'], extract: (errorInfo) => errorInfo?.data?.message, }, + { + id: 'coda-errors', + description: + 'Coda (Superhuman Docs) API errors: the `message` field, or the field-level validation issues under `codaDetail` when the message is only the generic HTTP status text', + examples: ['Coda'], + extract: (errorInfo) => { + const data = errorInfo?.data + if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined + const message = typeof data.message === 'string' ? data.message.trim() : '' + const generic = !message || message === data.statusMessage + if (!generic) return message + const details = collectCodaValidationMessages(data.codaDetail) + const status = message || (typeof data.statusMessage === 'string' ? data.statusMessage : '') + if (details.length > 0) return `${status || 'Invalid request'}: ${details.join('; ')}` + return status || undefined + }, + }, { id: 'harmonic-errors', description: @@ -618,6 +670,7 @@ export const ErrorExtractorId = { TELEGRAM_DESCRIPTION: 'telegram-description', STANDARD_MESSAGE: 'standard-message', HARMONIC_ERRORS: 'harmonic-errors', + CODA_ERRORS: 'coda-errors', SOAP_FAULT: 'soap-fault', OAUTH_ERROR_DESCRIPTION: 'oauth-error-description', NESTED_ERROR_OBJECT: 'nested-error-object', diff --git a/apps/sim/tools/generated/tool-ids.ts b/apps/sim/tools/generated/tool-ids.ts index 67aa132eb1a..4925d58cea9 100644 --- a/apps/sim/tools/generated/tool-ids.ts +++ b/apps/sim/tools/generated/tool-ids.ts @@ -3,7 +3,7 @@ /** Every registered tool id, including versioned variants. */ const toolIds: string[] = JSON.parse( - '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","affinity_batch_update_entity_fields","affinity_batch_update_list_entry_fields","affinity_create_list","affinity_create_list_field_dropdown_option","affinity_create_merge","affinity_create_note","affinity_create_reminder","affinity_delete_list_field_dropdown_option","affinity_delete_note","affinity_get_company","affinity_get_current_user","affinity_get_entity_field_value","affinity_get_list","affinity_get_list_entry","affinity_get_list_entry_field","affinity_get_list_field_dropdown_option","affinity_get_merge","affinity_get_merge_task","affinity_get_note","affinity_get_opportunity","affinity_get_person","affinity_get_saved_view","affinity_get_transcript","affinity_get_user","affinity_list_calls","affinity_list_chat_messages","affinity_list_companies","affinity_list_coworker_connections","affinity_list_emails","affinity_list_entity_field_values","affinity_list_entity_list_entries","affinity_list_entity_lists","affinity_list_entity_notes","affinity_list_entity_relationships","affinity_list_field_dropdown_options","affinity_list_field_metadata","affinity_list_field_value_changes","affinity_list_investor_executive_connections","affinity_list_list_entries","affinity_list_list_entry_field_value_changes","affinity_list_list_entry_fields","affinity_list_list_field_dropdown_options","affinity_list_list_fields","affinity_list_lists","affinity_list_meetings","affinity_list_merge_tasks","affinity_list_merges","affinity_list_note_attached_companies","affinity_list_note_attached_opportunities","affinity_list_note_attached_persons","affinity_list_note_replies","affinity_list_notes","affinity_list_opportunities","affinity_list_persons","affinity_list_reminders","affinity_list_saved_view_entries","affinity_list_saved_views","affinity_list_transcript_fragments","affinity_list_transcripts","affinity_list_users","affinity_search_companies","affinity_search_files","affinity_search_list_entries","affinity_search_notes","affinity_search_persons","affinity_semantic_search","affinity_update_entity_field_value","affinity_update_list_entry_field","affinity_update_list_field_dropdown_option","affinity_update_note","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","airweave_search","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_get_opening","ashby_list_application_feedback","ashby_list_application_history","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interview_plans","ashby_list_interview_stages","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_search_jobs","ashby_search_openings","ashby_search_users","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_transfer_application","ashby_update_candidate","ashby_upload_candidate_file","ashby_upload_resume","athena_batch_get_named_query","athena_batch_get_prepared_statement","athena_batch_get_query_execution","athena_create_named_query","athena_create_prepared_statement","athena_delete_named_query","athena_delete_prepared_statement","athena_get_data_catalog","athena_get_database","athena_get_named_query","athena_get_prepared_statement","athena_get_query_execution","athena_get_query_results","athena_get_query_runtime_statistics","athena_get_table_metadata","athena_get_work_group","athena_list_data_catalogs","athena_list_databases","athena_list_named_queries","athena_list_prepared_statements","athena_list_query_executions","athena_list_table_metadata","athena_list_work_groups","athena_start_query","athena_stop_query","athena_update_named_query","athena_update_prepared_statement","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","bitbucket_approve_pull_request","bitbucket_create_branch","bitbucket_create_pull_request","bitbucket_create_pull_request_comment","bitbucket_decline_pull_request","bitbucket_delete_branch","bitbucket_get_commit","bitbucket_get_file","bitbucket_get_file_metadata","bitbucket_get_pipeline","bitbucket_get_pipeline_step_log","bitbucket_get_pull_request","bitbucket_get_pull_request_diff","bitbucket_get_pull_request_diffstat","bitbucket_get_pull_request_merge_task_status","bitbucket_get_repository","bitbucket_list_branches","bitbucket_list_commits","bitbucket_list_directory","bitbucket_list_pipeline_steps","bitbucket_list_pipelines","bitbucket_list_pull_request_comments","bitbucket_list_pull_request_commit_statuses","bitbucket_list_pull_requests","bitbucket_list_repositories","bitbucket_list_workspaces","bitbucket_merge_pull_request","bitbucket_request_pull_request_changes","bitbucket_stop_pipeline","bitbucket_trigger_pipeline","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_download_file_v2","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_channels","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","cbinsights_chat","cbinsights_get_commercial_maturity_history","cbinsights_get_exit_probability_history","cbinsights_get_mosaic_history","cbinsights_get_org_business_relationships","cbinsights_get_org_funding_window","cbinsights_get_org_fundings","cbinsights_get_org_investments","cbinsights_get_org_management_and_board","cbinsights_get_org_outlook","cbinsights_get_org_portfolio_exits","cbinsights_get_org_revenue","cbinsights_get_scouting_report","cbinsights_get_strategy_map","cbinsights_list_business_relationships","cbinsights_list_funding_window","cbinsights_list_fundings","cbinsights_list_investments","cbinsights_list_management_and_board","cbinsights_list_outlook","cbinsights_list_portfolio_exits","cbinsights_list_revenue","cbinsights_lookup_organizations","cbinsights_rag","cbinsights_search_firmographics","circleback_add_tag_to_meetings","circleback_create_tag","circleback_delete_action_item","circleback_delete_meeting","circleback_delete_tag","circleback_get_company","circleback_get_meeting","circleback_get_person","circleback_get_transcript","circleback_list_action_items","circleback_list_calendar_events","circleback_list_companies","circleback_list_meetings","circleback_list_people","circleback_list_tags","circleback_remove_tag_from_meetings","circleback_search_meetings","circleback_update_action_item","circleback_update_meeting","circleback_update_tag","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_ruleset_rule","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_r2_bucket","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_revoke_access_service_token","cloudflare_update_access_application","cloudflare_update_access_policy","cloudflare_update_dns_record","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset_rule","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudtrail_cancel_query","cloudtrail_describe_query","cloudtrail_describe_trails","cloudtrail_get_event_data_store","cloudtrail_get_event_selectors","cloudtrail_get_insight_selectors","cloudtrail_get_query_results","cloudtrail_get_trail","cloudtrail_get_trail_status","cloudtrail_list_event_data_stores","cloudtrail_list_tags","cloudtrail_list_trails","cloudtrail_lookup_events","cloudtrail_start_query","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","crunchbase_autocomplete","crunchbase_get_acquisition","crunchbase_get_entity","crunchbase_get_entity_card","crunchbase_get_fields_metadata","crunchbase_get_funding_round","crunchbase_get_organization","crunchbase_get_person","crunchbase_list_deleted_entities","crunchbase_search_acquisitions","crunchbase_search_entities","crunchbase_search_funding_rounds","crunchbase_search_organizations","crunchbase_search_people","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_run","databricks_execute_sql","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_run_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_download_v2","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_get_qr_code_v2","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_ollama","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_create_folder","file_decompress","file_delete_folder","file_edit","file_fetch","file_get","file_get_content","file_list","file_manage_sharing","file_move","file_parser","file_parser_v2","file_parser_v3","file_read","file_restore_folder","file_search","file_update_folder","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","flint_create_task","flint_generate_pages","flint_get_task","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_create_webhook_endpoint","granola_delete_webhook_endpoint","granola_get_note","granola_get_transcript","granola_list_audit_events","granola_list_folders","granola_list_notes","granola_list_webhook_endpoints","granola_update_webhook_endpoint","greenhouse_get_application","greenhouse_get_candidate","greenhouse_get_job","greenhouse_get_user","greenhouse_list_applications","greenhouse_list_candidates","greenhouse_list_departments","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_offices","greenhouse_list_users","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","harmonic_batch_get_people","harmonic_clear_people_saved_search_net_new_results","harmonic_enrich_person","harmonic_get_company_employees","harmonic_get_email_enrichment_job","harmonic_get_email_enrichment_usage","harmonic_get_enrichment_status","harmonic_get_people_saved_search_net_new_results","harmonic_get_people_saved_search_results","harmonic_get_person","harmonic_list_people_saved_searches","harmonic_search_people_scout","harmonic_submit_email_enrichment_job","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_policy","iam_get_role","iam_get_user","iam_list_access_keys","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","iam_update_access_key","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_describe_group","identity_center_describe_user","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_assignments_for_account","identity_center_list_group_memberships","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jotform_add_label_resources","jotform_clone_form","jotform_create_form","jotform_create_label","jotform_create_question","jotform_create_questions","jotform_create_report","jotform_create_submission","jotform_create_submissions","jotform_create_webhook","jotform_delete_form","jotform_delete_label","jotform_delete_question","jotform_delete_report","jotform_delete_submission","jotform_delete_webhook","jotform_get_form","jotform_get_form_properties","jotform_get_history","jotform_get_label","jotform_get_question","jotform_get_report","jotform_get_settings","jotform_get_submission","jotform_get_usage","jotform_get_user","jotform_list_form_files","jotform_list_form_reports","jotform_list_form_submissions","jotform_list_forms","jotform_list_label_resources","jotform_list_labels","jotform_list_questions","jotform_list_reports","jotform_list_submissions","jotform_list_subusers","jotform_list_webhooks","jotform_remove_label_resources","jotform_update_form_properties","jotform_update_label","jotform_update_question","jotform_update_settings","jotform_update_submission","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_get_content_v2","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","lambda_add_permission","lambda_create_alias","lambda_create_event_source_mapping","lambda_create_function","lambda_create_function_url_config","lambda_delete_alias","lambda_delete_event_source_mapping","lambda_delete_function","lambda_delete_function_concurrency","lambda_delete_function_event_invoke_config","lambda_delete_function_url_config","lambda_delete_provisioned_concurrency_config","lambda_get_account_settings","lambda_get_alias","lambda_get_event_source_mapping","lambda_get_function","lambda_get_function_concurrency","lambda_get_function_configuration","lambda_get_function_event_invoke_config","lambda_get_function_recursion_config","lambda_get_function_url_config","lambda_get_layer_version","lambda_get_policy","lambda_get_provisioned_concurrency_config","lambda_get_runtime_management_config","lambda_invoke","lambda_list_aliases","lambda_list_event_source_mappings","lambda_list_function_event_invoke_configs","lambda_list_function_url_configs","lambda_list_functions","lambda_list_layer_versions","lambda_list_layers","lambda_list_provisioned_concurrency_configs","lambda_list_tags","lambda_list_versions_by_function","lambda_publish_version","lambda_put_function_concurrency","lambda_put_function_event_invoke_config","lambda_put_function_recursion_config","lambda_put_provisioned_concurrency_config","lambda_put_runtime_management_config","lambda_remove_permission","lambda_tag_resource","lambda_untag_resource","lambda_update_alias","lambda_update_event_source_mapping","lambda_update_function_code","lambda_update_function_configuration","lambda_update_function_url_config","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","manageengine_sdp_add_change_note","manageengine_sdp_add_problem_note","manageengine_sdp_add_request_note","manageengine_sdp_create_asset","manageengine_sdp_create_change","manageengine_sdp_create_problem","manageengine_sdp_create_request","manageengine_sdp_create_solution","manageengine_sdp_delete_asset","manageengine_sdp_delete_change","manageengine_sdp_delete_problem","manageengine_sdp_delete_request","manageengine_sdp_delete_solution","manageengine_sdp_get_asset","manageengine_sdp_get_change","manageengine_sdp_get_problem","manageengine_sdp_get_request","manageengine_sdp_get_solution","manageengine_sdp_list_assets","manageengine_sdp_list_change_notes","manageengine_sdp_list_changes","manageengine_sdp_list_problem_notes","manageengine_sdp_list_problems","manageengine_sdp_list_request_notes","manageengine_sdp_list_requests","manageengine_sdp_list_solutions","manageengine_sdp_update_asset","manageengine_sdp_update_change","manageengine_sdp_update_problem","manageengine_sdp_update_request","manageengine_sdp_update_solution","mcp_list_operations","mcp_run_operation","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_download_file_v2","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_dynamics_365_close_case","microsoft_dynamics_365_close_opportunity","microsoft_dynamics_365_create_record","microsoft_dynamics_365_get_record","microsoft_dynamics_365_list_records","microsoft_dynamics_365_qualify_lead","microsoft_dynamics_365_search_records","microsoft_dynamics_365_update_record","microsoft_excel_clear_range","microsoft_excel_create_table","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","microsoft_word_append","microsoft_word_create","microsoft_word_create_from_template","microsoft_word_export_pdf","microsoft_word_list","microsoft_word_read","microsoft_word_replace_text","microsoft_word_update","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","modal_call_function","modal_chat_completion","modal_list_models","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","pitchbook_company_active_investors","pitchbook_company_bio","pitchbook_company_deal_service_providers","pitchbook_company_deals","pitchbook_company_financials","pitchbook_company_general_service_providers","pitchbook_company_industries","pitchbook_company_investors","pitchbook_company_most_recent_debt_financing","pitchbook_company_most_recent_financials","pitchbook_company_most_recent_financing","pitchbook_company_search","pitchbook_company_similar_companies","pitchbook_company_social_analytics","pitchbook_company_updates","pitchbook_company_vc_exit_predictions","pitchbook_contracts_history","pitchbook_cost_of_calls","pitchbook_credit_history","pitchbook_credit_news","pitchbook_credit_news_bulk","pitchbook_credit_news_most_recent","pitchbook_credit_news_search","pitchbook_deal_bio","pitchbook_deal_cap_table_history","pitchbook_deal_debt_lenders","pitchbook_deal_detailed","pitchbook_deal_investors","pitchbook_deal_multiples","pitchbook_deal_search","pitchbook_deal_service_providers","pitchbook_deal_stock_info","pitchbook_deal_tranche_info","pitchbook_deal_updates","pitchbook_deal_valuation","pitchbook_entity_affiliates","pitchbook_entity_locations","pitchbook_entity_news","pitchbook_entity_people","pitchbook_entity_updates","pitchbook_fund_active_investments","pitchbook_fund_benchmark","pitchbook_fund_bio","pitchbook_fund_cash_flows","pitchbook_fund_commitments","pitchbook_fund_investment_preferences","pitchbook_fund_investments","pitchbook_fund_performance","pitchbook_fund_search","pitchbook_fund_team","pitchbook_fund_updates","pitchbook_investor_active_investments","pitchbook_investor_bio","pitchbook_investor_board_seats","pitchbook_investor_deal_service_providers","pitchbook_investor_funds","pitchbook_investor_general_service_providers","pitchbook_investor_investments","pitchbook_investor_last_closed_fund","pitchbook_investor_preferences","pitchbook_investor_search","pitchbook_investor_updates","pitchbook_limited_partner_actual_allocations","pitchbook_limited_partner_bio","pitchbook_limited_partner_commitment_aggregates","pitchbook_limited_partner_commitment_preferences","pitchbook_limited_partner_commitments_detailed","pitchbook_limited_partner_search","pitchbook_limited_partner_service_providers","pitchbook_limited_partner_target_allocations","pitchbook_limited_partner_updates","pitchbook_lookup_table_structure","pitchbook_lookup_tables","pitchbook_patent_detailed","pitchbook_patent_search","pitchbook_people_search","pitchbook_person_bio","pitchbook_person_contact","pitchbook_person_education_work","pitchbook_sandbox_entities","pitchbook_search","pitchbook_service_provider_bio","pitchbook_service_provider_search","pitchbook_service_provider_updates","pitchbook_serviced_companies","pitchbook_serviced_deals","pitchbook_serviced_funds","pitchbook_serviced_investors","pitchbook_serviced_limited_partners","pitchbook_shared_search","pitchbook_usage_report","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quickbooks_add_attachment","quickbooks_create_bill","quickbooks_create_bill_payment","quickbooks_create_credit_memo","quickbooks_create_customer","quickbooks_create_customer_payment","quickbooks_create_deposit","quickbooks_create_employee","quickbooks_create_estimate","quickbooks_create_invoice","quickbooks_create_item","quickbooks_create_journal_entry","quickbooks_create_purchase","quickbooks_create_purchase_order","quickbooks_create_refund_receipt","quickbooks_create_sales_receipt","quickbooks_create_vendor","quickbooks_create_vendor_credit","quickbooks_download_attachment","quickbooks_download_transaction_pdf","quickbooks_email_transaction","quickbooks_get_company_info","quickbooks_read_accounting_transactions","quickbooks_read_attachments","quickbooks_read_master_data","quickbooks_read_purchasing_transactions","quickbooks_read_sales_transactions","quickbooks_run_financial_report","quickbooks_update_bill","quickbooks_update_bill_payment","quickbooks_update_credit_memo","quickbooks_update_customer","quickbooks_update_customer_payment","quickbooks_update_deposit","quickbooks_update_employee","quickbooks_update_estimate","quickbooks_update_invoice","quickbooks_update_item","quickbooks_update_journal_entry","quickbooks_update_purchase","quickbooks_update_purchase_order","quickbooks_update_refund_receipt","quickbooks_update_sales_receipt","quickbooks_update_vendor","quickbooks_update_vendor_credit","quickbooks_void_bill_payment","quickbooks_void_customer_payment","quickbooks_void_invoice","quickbooks_void_sales_receipt","quiver_image_to_svg","quiver_image_to_svg_v2","quiver_list_models","quiver_text_to_svg","quiver_text_to_svg_v2","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","sailpoint_approve_access_request","sailpoint_cancel_access_request","sailpoint_decide_certification_review_items","sailpoint_get_access_profile","sailpoint_get_access_profile_entitlements","sailpoint_get_access_request_config","sailpoint_get_access_request_status","sailpoint_get_account","sailpoint_get_account_activity","sailpoint_get_account_entitlements","sailpoint_get_account_selections","sailpoint_get_campaign","sailpoint_get_certification","sailpoint_get_entitlement","sailpoint_get_entitlement_request_config","sailpoint_get_identity","sailpoint_get_role","sailpoint_get_role_entitlements","sailpoint_get_source","sailpoint_get_task_status","sailpoint_list_access_profiles","sailpoint_list_account_activities","sailpoint_list_accounts","sailpoint_list_campaigns","sailpoint_list_certification_review_items","sailpoint_list_certifications","sailpoint_list_entitlements","sailpoint_list_identities","sailpoint_list_identity_entitlements","sailpoint_list_pending_access_request_approvals","sailpoint_list_roles","sailpoint_list_sources","sailpoint_load_accounts","sailpoint_load_entitlements","sailpoint_reject_access_request","sailpoint_request_access","sailpoint_search","sailpoint_search_aggregate","sailpoint_search_count","sailpoint_sign_off_certification","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","semrush_backlinks","semrush_backlinks_anchors","semrush_backlinks_competitors","semrush_backlinks_geo_distribution","semrush_backlinks_indexed_pages","semrush_backlinks_overview","semrush_backlinks_tld_distribution","semrush_batch_keyword_overview","semrush_broad_match_keywords","semrush_domain_ad_copies","semrush_domain_ad_history","semrush_domain_organic_competitors","semrush_domain_organic_keywords","semrush_domain_overview","semrush_domain_overview_all","semrush_domain_overview_history","semrush_domain_paid_competitors","semrush_domain_paid_keywords","semrush_domain_pla_copies","semrush_domain_pla_keywords","semrush_domain_vs_domain","semrush_keyword_ad_history","semrush_keyword_difficulty","semrush_keyword_overview","semrush_keyword_overview_all","semrush_keyword_questions","semrush_organic_results","semrush_paid_results","semrush_referring_domains","semrush_referring_ips","semrush_related_keywords","semrush_subdomain_ad_copies","semrush_subdomain_organic_keywords","semrush_subdomain_overview","semrush_subdomain_overview_all","semrush_subdomain_overview_history","semrush_subdomain_paid_keywords","semrush_top_domains","semrush_url_organic_keywords","semrush_url_overview","semrush_url_overview_all","semrush_url_overview_history","semrush_url_paid_keywords","semrush_winners_and_losers","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_download_attachment_v2","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_download_v2","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_inventory_level","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_order","shopify_update_product","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_create_channel_canvas","slack_create_conversation","slack_delete_canvas","slack_delete_message","slack_delete_scheduled_message","slack_download","slack_edit_canvas","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_message","slack_get_permalink","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_invite_to_conversation","slack_list_canvases","slack_list_channels","slack_list_members","slack_list_scheduled_messages","slack_list_users","slack_lookup_canvas_sections","slack_message","slack_message_reader","slack_open_view","slack_publish_view","slack_push_view","slack_remove_reaction","slack_rename_agent_session_v2","slack_rename_conversation","slack_schedule_message","slack_set_agent_session_status_v2","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_suggested_prompts_v2","slack_set_title","slack_update_message","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_cancel_message_move_task","sqs_change_message_visibility","sqs_change_message_visibility_batch","sqs_create_queue","sqs_delete_message","sqs_delete_message_batch","sqs_delete_queue","sqs_get_queue_attributes","sqs_get_queue_url","sqs_list_dead_letter_source_queues","sqs_list_message_move_tasks","sqs_list_queue_tags","sqs_list_queues","sqs_purge_queue","sqs_receive_message","sqs_send","sqs_send_message_batch","sqs_set_queue_attributes","sqs_start_message_move_task","sqs_tag_queue","sqs_untag_queue","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_download_file_v2","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","ssm_cancel_command","ssm_delete_parameter","ssm_describe_automation_executions","ssm_describe_instance_information","ssm_describe_instance_patch_states","ssm_describe_instance_patches","ssm_describe_parameters","ssm_get_automation_execution","ssm_get_command_invocation","ssm_get_document","ssm_get_parameter","ssm_get_parameters","ssm_get_parameters_by_path","ssm_list_command_invocations","ssm_list_commands","ssm_list_compliance_items","ssm_list_compliance_summaries","ssm_list_documents","ssm_put_parameter","ssm_send_command","ssm_start_automation_execution","ssm_stop_automation_execution","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_routes","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_set_acl","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_suspend_user","tailscale_update_device_key","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","tinyfish_cancel_run","tinyfish_fetch","tinyfish_get_run","tinyfish_list_profiles","tinyfish_list_runs","tinyfish_list_vault_items","tinyfish_run","tinyfish_run_async","tinyfish_search","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_get_attachment","zoho_desk_get_contact","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_comments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' + '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","affinity_batch_update_entity_fields","affinity_batch_update_list_entry_fields","affinity_create_list","affinity_create_list_field_dropdown_option","affinity_create_merge","affinity_create_note","affinity_create_reminder","affinity_delete_list_field_dropdown_option","affinity_delete_note","affinity_get_company","affinity_get_current_user","affinity_get_entity_field_value","affinity_get_list","affinity_get_list_entry","affinity_get_list_entry_field","affinity_get_list_field_dropdown_option","affinity_get_merge","affinity_get_merge_task","affinity_get_note","affinity_get_opportunity","affinity_get_person","affinity_get_saved_view","affinity_get_transcript","affinity_get_user","affinity_list_calls","affinity_list_chat_messages","affinity_list_companies","affinity_list_coworker_connections","affinity_list_emails","affinity_list_entity_field_values","affinity_list_entity_list_entries","affinity_list_entity_lists","affinity_list_entity_notes","affinity_list_entity_relationships","affinity_list_field_dropdown_options","affinity_list_field_metadata","affinity_list_field_value_changes","affinity_list_investor_executive_connections","affinity_list_list_entries","affinity_list_list_entry_field_value_changes","affinity_list_list_entry_fields","affinity_list_list_field_dropdown_options","affinity_list_list_fields","affinity_list_lists","affinity_list_meetings","affinity_list_merge_tasks","affinity_list_merges","affinity_list_note_attached_companies","affinity_list_note_attached_opportunities","affinity_list_note_attached_persons","affinity_list_note_replies","affinity_list_notes","affinity_list_opportunities","affinity_list_persons","affinity_list_reminders","affinity_list_saved_view_entries","affinity_list_saved_views","affinity_list_transcript_fragments","affinity_list_transcripts","affinity_list_users","affinity_search_companies","affinity_search_files","affinity_search_list_entries","affinity_search_notes","affinity_search_persons","affinity_semantic_search","affinity_update_entity_field_value","affinity_update_list_entry_field","affinity_update_list_field_dropdown_option","affinity_update_note","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","airweave_search","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_get_opening","ashby_list_application_feedback","ashby_list_application_history","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interview_plans","ashby_list_interview_stages","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_search_jobs","ashby_search_openings","ashby_search_users","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_transfer_application","ashby_update_candidate","ashby_upload_candidate_file","ashby_upload_resume","athena_batch_get_named_query","athena_batch_get_prepared_statement","athena_batch_get_query_execution","athena_create_named_query","athena_create_prepared_statement","athena_delete_named_query","athena_delete_prepared_statement","athena_get_data_catalog","athena_get_database","athena_get_named_query","athena_get_prepared_statement","athena_get_query_execution","athena_get_query_results","athena_get_query_runtime_statistics","athena_get_table_metadata","athena_get_work_group","athena_list_data_catalogs","athena_list_databases","athena_list_named_queries","athena_list_prepared_statements","athena_list_query_executions","athena_list_table_metadata","athena_list_work_groups","athena_start_query","athena_stop_query","athena_update_named_query","athena_update_prepared_statement","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","bitbucket_approve_pull_request","bitbucket_create_branch","bitbucket_create_pull_request","bitbucket_create_pull_request_comment","bitbucket_decline_pull_request","bitbucket_delete_branch","bitbucket_get_commit","bitbucket_get_file","bitbucket_get_file_metadata","bitbucket_get_pipeline","bitbucket_get_pipeline_step_log","bitbucket_get_pull_request","bitbucket_get_pull_request_diff","bitbucket_get_pull_request_diffstat","bitbucket_get_pull_request_merge_task_status","bitbucket_get_repository","bitbucket_list_branches","bitbucket_list_commits","bitbucket_list_directory","bitbucket_list_pipeline_steps","bitbucket_list_pipelines","bitbucket_list_pull_request_comments","bitbucket_list_pull_request_commit_statuses","bitbucket_list_pull_requests","bitbucket_list_repositories","bitbucket_list_workspaces","bitbucket_merge_pull_request","bitbucket_request_pull_request_changes","bitbucket_stop_pipeline","bitbucket_trigger_pipeline","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_download_file_v2","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_channels","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","cbinsights_chat","cbinsights_get_commercial_maturity_history","cbinsights_get_exit_probability_history","cbinsights_get_mosaic_history","cbinsights_get_org_business_relationships","cbinsights_get_org_funding_window","cbinsights_get_org_fundings","cbinsights_get_org_investments","cbinsights_get_org_management_and_board","cbinsights_get_org_outlook","cbinsights_get_org_portfolio_exits","cbinsights_get_org_revenue","cbinsights_get_scouting_report","cbinsights_get_strategy_map","cbinsights_list_business_relationships","cbinsights_list_funding_window","cbinsights_list_fundings","cbinsights_list_investments","cbinsights_list_management_and_board","cbinsights_list_outlook","cbinsights_list_portfolio_exits","cbinsights_list_revenue","cbinsights_lookup_organizations","cbinsights_rag","cbinsights_search_firmographics","circleback_add_tag_to_meetings","circleback_create_tag","circleback_delete_action_item","circleback_delete_meeting","circleback_delete_tag","circleback_get_company","circleback_get_meeting","circleback_get_person","circleback_get_transcript","circleback_list_action_items","circleback_list_calendar_events","circleback_list_companies","circleback_list_meetings","circleback_list_people","circleback_list_tags","circleback_remove_tag_from_meetings","circleback_search_meetings","circleback_update_action_item","circleback_update_meeting","circleback_update_tag","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_ruleset_rule","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_r2_bucket","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_revoke_access_service_token","cloudflare_update_access_application","cloudflare_update_access_policy","cloudflare_update_dns_record","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset_rule","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudtrail_cancel_query","cloudtrail_describe_query","cloudtrail_describe_trails","cloudtrail_get_event_data_store","cloudtrail_get_event_selectors","cloudtrail_get_insight_selectors","cloudtrail_get_query_results","cloudtrail_get_trail","cloudtrail_get_trail_status","cloudtrail_list_event_data_stores","cloudtrail_list_tags","cloudtrail_list_trails","cloudtrail_lookup_events","cloudtrail_start_query","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","coda_add_custom_domain","coda_add_permission","coda_change_user_role","coda_create_doc","coda_create_folder","coda_create_page","coda_delete_custom_domain","coda_delete_doc","coda_delete_folder","coda_delete_page","coda_delete_page_content","coda_delete_permission","coda_delete_row","coda_delete_rows","coda_export_page","coda_get_acl_settings","coda_get_analytics_last_updated","coda_get_column","coda_get_control","coda_get_custom_domain_provider","coda_get_doc","coda_get_doc_analytics_summary","coda_get_folder","coda_get_formula","coda_get_mutation_status","coda_get_page","coda_get_page_content","coda_get_page_export_status","coda_get_row","coda_get_sharing_metadata","coda_get_table","coda_list_categories","coda_list_columns","coda_list_controls","coda_list_custom_domains","coda_list_doc_analytics","coda_list_docs","coda_list_folder_children","coda_list_folders","coda_list_formulas","coda_list_page_analytics","coda_list_pages","coda_list_permissions","coda_list_rows","coda_list_tables","coda_list_workspace_members","coda_list_workspace_roles","coda_publish_doc","coda_push_button","coda_resolve_browser_link","coda_search_principals","coda_trigger_automation","coda_unpublish_doc","coda_update_acl_settings","coda_update_doc","coda_update_folder","coda_update_page","coda_update_row","coda_upsert_rows","coda_whoami","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","crunchbase_autocomplete","crunchbase_get_acquisition","crunchbase_get_entity","crunchbase_get_entity_card","crunchbase_get_fields_metadata","crunchbase_get_funding_round","crunchbase_get_organization","crunchbase_get_person","crunchbase_list_deleted_entities","crunchbase_search_acquisitions","crunchbase_search_entities","crunchbase_search_funding_rounds","crunchbase_search_organizations","crunchbase_search_people","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_run","databricks_execute_sql","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_run_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_download_v2","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_get_qr_code_v2","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_ollama","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_create_folder","file_decompress","file_delete_folder","file_edit","file_fetch","file_get","file_get_content","file_list","file_manage_sharing","file_move","file_parser","file_parser_v2","file_parser_v3","file_read","file_restore_folder","file_search","file_update_folder","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","flint_create_task","flint_generate_pages","flint_get_task","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_create_webhook_endpoint","granola_delete_webhook_endpoint","granola_get_note","granola_get_transcript","granola_list_audit_events","granola_list_folders","granola_list_notes","granola_list_webhook_endpoints","granola_update_webhook_endpoint","greenhouse_get_application","greenhouse_get_candidate","greenhouse_get_job","greenhouse_get_user","greenhouse_list_applications","greenhouse_list_candidates","greenhouse_list_departments","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_offices","greenhouse_list_users","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","harmonic_batch_get_people","harmonic_clear_people_saved_search_net_new_results","harmonic_enrich_person","harmonic_get_company_employees","harmonic_get_email_enrichment_job","harmonic_get_email_enrichment_usage","harmonic_get_enrichment_status","harmonic_get_people_saved_search_net_new_results","harmonic_get_people_saved_search_results","harmonic_get_person","harmonic_list_people_saved_searches","harmonic_search_people_scout","harmonic_submit_email_enrichment_job","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_policy","iam_get_role","iam_get_user","iam_list_access_keys","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","iam_update_access_key","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_describe_group","identity_center_describe_user","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_assignments_for_account","identity_center_list_group_memberships","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jotform_add_label_resources","jotform_clone_form","jotform_create_form","jotform_create_label","jotform_create_question","jotform_create_questions","jotform_create_report","jotform_create_submission","jotform_create_submissions","jotform_create_webhook","jotform_delete_form","jotform_delete_label","jotform_delete_question","jotform_delete_report","jotform_delete_submission","jotform_delete_webhook","jotform_get_form","jotform_get_form_properties","jotform_get_history","jotform_get_label","jotform_get_question","jotform_get_report","jotform_get_settings","jotform_get_submission","jotform_get_usage","jotform_get_user","jotform_list_form_files","jotform_list_form_reports","jotform_list_form_submissions","jotform_list_forms","jotform_list_label_resources","jotform_list_labels","jotform_list_questions","jotform_list_reports","jotform_list_submissions","jotform_list_subusers","jotform_list_webhooks","jotform_remove_label_resources","jotform_update_form_properties","jotform_update_label","jotform_update_question","jotform_update_settings","jotform_update_submission","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_get_content_v2","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","lambda_add_permission","lambda_create_alias","lambda_create_event_source_mapping","lambda_create_function","lambda_create_function_url_config","lambda_delete_alias","lambda_delete_event_source_mapping","lambda_delete_function","lambda_delete_function_concurrency","lambda_delete_function_event_invoke_config","lambda_delete_function_url_config","lambda_delete_provisioned_concurrency_config","lambda_get_account_settings","lambda_get_alias","lambda_get_event_source_mapping","lambda_get_function","lambda_get_function_concurrency","lambda_get_function_configuration","lambda_get_function_event_invoke_config","lambda_get_function_recursion_config","lambda_get_function_url_config","lambda_get_layer_version","lambda_get_policy","lambda_get_provisioned_concurrency_config","lambda_get_runtime_management_config","lambda_invoke","lambda_list_aliases","lambda_list_event_source_mappings","lambda_list_function_event_invoke_configs","lambda_list_function_url_configs","lambda_list_functions","lambda_list_layer_versions","lambda_list_layers","lambda_list_provisioned_concurrency_configs","lambda_list_tags","lambda_list_versions_by_function","lambda_publish_version","lambda_put_function_concurrency","lambda_put_function_event_invoke_config","lambda_put_function_recursion_config","lambda_put_provisioned_concurrency_config","lambda_put_runtime_management_config","lambda_remove_permission","lambda_tag_resource","lambda_untag_resource","lambda_update_alias","lambda_update_event_source_mapping","lambda_update_function_code","lambda_update_function_configuration","lambda_update_function_url_config","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","manageengine_sdp_add_change_note","manageengine_sdp_add_problem_note","manageengine_sdp_add_request_note","manageengine_sdp_create_asset","manageengine_sdp_create_change","manageengine_sdp_create_problem","manageengine_sdp_create_request","manageengine_sdp_create_solution","manageengine_sdp_delete_asset","manageengine_sdp_delete_change","manageengine_sdp_delete_problem","manageengine_sdp_delete_request","manageengine_sdp_delete_solution","manageengine_sdp_get_asset","manageengine_sdp_get_change","manageengine_sdp_get_problem","manageengine_sdp_get_request","manageengine_sdp_get_solution","manageengine_sdp_list_assets","manageengine_sdp_list_change_notes","manageengine_sdp_list_changes","manageengine_sdp_list_problem_notes","manageengine_sdp_list_problems","manageengine_sdp_list_request_notes","manageengine_sdp_list_requests","manageengine_sdp_list_solutions","manageengine_sdp_update_asset","manageengine_sdp_update_change","manageengine_sdp_update_problem","manageengine_sdp_update_request","manageengine_sdp_update_solution","mcp_list_operations","mcp_run_operation","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_download_file_v2","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_dynamics_365_close_case","microsoft_dynamics_365_close_opportunity","microsoft_dynamics_365_create_record","microsoft_dynamics_365_get_record","microsoft_dynamics_365_list_records","microsoft_dynamics_365_qualify_lead","microsoft_dynamics_365_search_records","microsoft_dynamics_365_update_record","microsoft_excel_clear_range","microsoft_excel_create_table","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","microsoft_word_append","microsoft_word_create","microsoft_word_create_from_template","microsoft_word_export_pdf","microsoft_word_list","microsoft_word_read","microsoft_word_replace_text","microsoft_word_update","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","modal_call_function","modal_chat_completion","modal_list_models","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","pitchbook_company_active_investors","pitchbook_company_bio","pitchbook_company_deal_service_providers","pitchbook_company_deals","pitchbook_company_financials","pitchbook_company_general_service_providers","pitchbook_company_industries","pitchbook_company_investors","pitchbook_company_most_recent_debt_financing","pitchbook_company_most_recent_financials","pitchbook_company_most_recent_financing","pitchbook_company_search","pitchbook_company_similar_companies","pitchbook_company_social_analytics","pitchbook_company_updates","pitchbook_company_vc_exit_predictions","pitchbook_contracts_history","pitchbook_cost_of_calls","pitchbook_credit_history","pitchbook_credit_news","pitchbook_credit_news_bulk","pitchbook_credit_news_most_recent","pitchbook_credit_news_search","pitchbook_deal_bio","pitchbook_deal_cap_table_history","pitchbook_deal_debt_lenders","pitchbook_deal_detailed","pitchbook_deal_investors","pitchbook_deal_multiples","pitchbook_deal_search","pitchbook_deal_service_providers","pitchbook_deal_stock_info","pitchbook_deal_tranche_info","pitchbook_deal_updates","pitchbook_deal_valuation","pitchbook_entity_affiliates","pitchbook_entity_locations","pitchbook_entity_news","pitchbook_entity_people","pitchbook_entity_updates","pitchbook_fund_active_investments","pitchbook_fund_benchmark","pitchbook_fund_bio","pitchbook_fund_cash_flows","pitchbook_fund_commitments","pitchbook_fund_investment_preferences","pitchbook_fund_investments","pitchbook_fund_performance","pitchbook_fund_search","pitchbook_fund_team","pitchbook_fund_updates","pitchbook_investor_active_investments","pitchbook_investor_bio","pitchbook_investor_board_seats","pitchbook_investor_deal_service_providers","pitchbook_investor_funds","pitchbook_investor_general_service_providers","pitchbook_investor_investments","pitchbook_investor_last_closed_fund","pitchbook_investor_preferences","pitchbook_investor_search","pitchbook_investor_updates","pitchbook_limited_partner_actual_allocations","pitchbook_limited_partner_bio","pitchbook_limited_partner_commitment_aggregates","pitchbook_limited_partner_commitment_preferences","pitchbook_limited_partner_commitments_detailed","pitchbook_limited_partner_search","pitchbook_limited_partner_service_providers","pitchbook_limited_partner_target_allocations","pitchbook_limited_partner_updates","pitchbook_lookup_table_structure","pitchbook_lookup_tables","pitchbook_patent_detailed","pitchbook_patent_search","pitchbook_people_search","pitchbook_person_bio","pitchbook_person_contact","pitchbook_person_education_work","pitchbook_sandbox_entities","pitchbook_search","pitchbook_service_provider_bio","pitchbook_service_provider_search","pitchbook_service_provider_updates","pitchbook_serviced_companies","pitchbook_serviced_deals","pitchbook_serviced_funds","pitchbook_serviced_investors","pitchbook_serviced_limited_partners","pitchbook_shared_search","pitchbook_usage_report","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quickbooks_add_attachment","quickbooks_create_bill","quickbooks_create_bill_payment","quickbooks_create_credit_memo","quickbooks_create_customer","quickbooks_create_customer_payment","quickbooks_create_deposit","quickbooks_create_employee","quickbooks_create_estimate","quickbooks_create_invoice","quickbooks_create_item","quickbooks_create_journal_entry","quickbooks_create_purchase","quickbooks_create_purchase_order","quickbooks_create_refund_receipt","quickbooks_create_sales_receipt","quickbooks_create_vendor","quickbooks_create_vendor_credit","quickbooks_download_attachment","quickbooks_download_transaction_pdf","quickbooks_email_transaction","quickbooks_get_company_info","quickbooks_read_accounting_transactions","quickbooks_read_attachments","quickbooks_read_master_data","quickbooks_read_purchasing_transactions","quickbooks_read_sales_transactions","quickbooks_run_financial_report","quickbooks_update_bill","quickbooks_update_bill_payment","quickbooks_update_credit_memo","quickbooks_update_customer","quickbooks_update_customer_payment","quickbooks_update_deposit","quickbooks_update_employee","quickbooks_update_estimate","quickbooks_update_invoice","quickbooks_update_item","quickbooks_update_journal_entry","quickbooks_update_purchase","quickbooks_update_purchase_order","quickbooks_update_refund_receipt","quickbooks_update_sales_receipt","quickbooks_update_vendor","quickbooks_update_vendor_credit","quickbooks_void_bill_payment","quickbooks_void_customer_payment","quickbooks_void_invoice","quickbooks_void_sales_receipt","quiver_image_to_svg","quiver_image_to_svg_v2","quiver_list_models","quiver_text_to_svg","quiver_text_to_svg_v2","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","sailpoint_approve_access_request","sailpoint_cancel_access_request","sailpoint_decide_certification_review_items","sailpoint_get_access_profile","sailpoint_get_access_profile_entitlements","sailpoint_get_access_request_config","sailpoint_get_access_request_status","sailpoint_get_account","sailpoint_get_account_activity","sailpoint_get_account_entitlements","sailpoint_get_account_selections","sailpoint_get_campaign","sailpoint_get_certification","sailpoint_get_entitlement","sailpoint_get_entitlement_request_config","sailpoint_get_identity","sailpoint_get_role","sailpoint_get_role_entitlements","sailpoint_get_source","sailpoint_get_task_status","sailpoint_list_access_profiles","sailpoint_list_account_activities","sailpoint_list_accounts","sailpoint_list_campaigns","sailpoint_list_certification_review_items","sailpoint_list_certifications","sailpoint_list_entitlements","sailpoint_list_identities","sailpoint_list_identity_entitlements","sailpoint_list_pending_access_request_approvals","sailpoint_list_roles","sailpoint_list_sources","sailpoint_load_accounts","sailpoint_load_entitlements","sailpoint_reject_access_request","sailpoint_request_access","sailpoint_search","sailpoint_search_aggregate","sailpoint_search_count","sailpoint_sign_off_certification","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","semrush_backlinks","semrush_backlinks_anchors","semrush_backlinks_competitors","semrush_backlinks_geo_distribution","semrush_backlinks_indexed_pages","semrush_backlinks_overview","semrush_backlinks_tld_distribution","semrush_batch_keyword_overview","semrush_broad_match_keywords","semrush_domain_ad_copies","semrush_domain_ad_history","semrush_domain_organic_competitors","semrush_domain_organic_keywords","semrush_domain_overview","semrush_domain_overview_all","semrush_domain_overview_history","semrush_domain_paid_competitors","semrush_domain_paid_keywords","semrush_domain_pla_copies","semrush_domain_pla_keywords","semrush_domain_vs_domain","semrush_keyword_ad_history","semrush_keyword_difficulty","semrush_keyword_overview","semrush_keyword_overview_all","semrush_keyword_questions","semrush_organic_results","semrush_paid_results","semrush_referring_domains","semrush_referring_ips","semrush_related_keywords","semrush_subdomain_ad_copies","semrush_subdomain_organic_keywords","semrush_subdomain_overview","semrush_subdomain_overview_all","semrush_subdomain_overview_history","semrush_subdomain_paid_keywords","semrush_top_domains","semrush_url_organic_keywords","semrush_url_overview","semrush_url_overview_all","semrush_url_overview_history","semrush_url_paid_keywords","semrush_winners_and_losers","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_download_attachment_v2","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_download_v2","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_inventory_level","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_order","shopify_update_product","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_create_channel_canvas","slack_create_conversation","slack_delete_canvas","slack_delete_message","slack_delete_scheduled_message","slack_download","slack_edit_canvas","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_message","slack_get_permalink","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_invite_to_conversation","slack_list_canvases","slack_list_channels","slack_list_members","slack_list_scheduled_messages","slack_list_users","slack_lookup_canvas_sections","slack_message","slack_message_reader","slack_open_view","slack_publish_view","slack_push_view","slack_remove_reaction","slack_rename_agent_session_v2","slack_rename_conversation","slack_schedule_message","slack_set_agent_session_status_v2","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_suggested_prompts_v2","slack_set_title","slack_update_message","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_cancel_message_move_task","sqs_change_message_visibility","sqs_change_message_visibility_batch","sqs_create_queue","sqs_delete_message","sqs_delete_message_batch","sqs_delete_queue","sqs_get_queue_attributes","sqs_get_queue_url","sqs_list_dead_letter_source_queues","sqs_list_message_move_tasks","sqs_list_queue_tags","sqs_list_queues","sqs_purge_queue","sqs_receive_message","sqs_send","sqs_send_message_batch","sqs_set_queue_attributes","sqs_start_message_move_task","sqs_tag_queue","sqs_untag_queue","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_download_file_v2","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","ssm_cancel_command","ssm_delete_parameter","ssm_describe_automation_executions","ssm_describe_instance_information","ssm_describe_instance_patch_states","ssm_describe_instance_patches","ssm_describe_parameters","ssm_get_automation_execution","ssm_get_command_invocation","ssm_get_document","ssm_get_parameter","ssm_get_parameters","ssm_get_parameters_by_path","ssm_list_command_invocations","ssm_list_commands","ssm_list_compliance_items","ssm_list_compliance_summaries","ssm_list_documents","ssm_put_parameter","ssm_send_command","ssm_start_automation_execution","ssm_stop_automation_execution","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_routes","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_set_acl","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_suspend_user","tailscale_update_device_key","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","tinyfish_cancel_run","tinyfish_fetch","tinyfish_get_run","tinyfish_list_profiles","tinyfish_list_runs","tinyfish_list_vault_items","tinyfish_run","tinyfish_run_async","tinyfish_search","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_get_attachment","zoho_desk_get_contact","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_comments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' ) export default toolIds diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index dacfa485f2a..204ce6efcfe 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"affinity_batch_update_entity_fields":{"id":"affinity_batch_update_entity_fields","name":"Affinity Batch Update Entity Fields","description":"Write up to 100 non-list field values on one company or person in a single request.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the fields on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"hostedApiKey":"none"},"affinity_batch_update_list_entry_fields":{"id":"affinity_batch_update_list_entry_fields","name":"Affinity Batch Update List Entry Fields","description":"Write up to 100 field values on one list row in a single request. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"hostedApiKey":"none"},"affinity_create_list":{"id":"affinity_create_list","name":"Affinity Create List","description":"Create a list. Its type fixes which entities it can hold, and the API key holder becomes its creator and owner.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the new list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Entity kind the list holds: company, opportunity, or person"},"isPublic":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether everyone in the organization can see the list"}},"hostedApiKey":"none"},"affinity_create_list_field_dropdown_option":{"id":"affinity_create_list_field_dropdown_option","name":"Affinity Create List Field Dropdown Option","description":"Add a selectable option to a dropdown field on a list. A ranked or status option also needs a rank and a color.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Kind of option to create, matching the field. dropdown takes only a label; ranked-dropdown also requires rank and color; status-dropdown additionally requires a status category. Sending a field the kind does not accept is rejected"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The option label"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"hostedApiKey":"none"},"affinity_create_merge":{"id":"affinity_create_merge","name":"Affinity Create Merge","description":"Fold a duplicate company or person into the record you are keeping. The merge runs asynchronously — poll the returned task to see it finish. Requires the \\"Manage duplicates\\" permission and an admin role.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to merge: companies or persons"},"primaryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to keep"},"duplicateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the duplicate record to fold in"}},"hostedApiKey":"none"},"affinity_create_note":{"id":"affinity_create_note","name":"Affinity Create Note","description":"Write a note — attached to companies, persons, and opportunities, anchored to a meeting, call, or chat message, or posted as a reply to an existing note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Note shape: entities to attach it to records, interaction to anchor it to a meeting, call, or chat message, or user-reply to reply to a note"},"html":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Companies to attach the note to, e.g. [1, 2]. Not used on a reply"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Persons to attach the note to, e.g. [1, 2]. Not used on a reply"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Opportunities to attach the note to, e.g. [1, 2]. Not used on a reply"},"interactionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The interaction to anchor the note to. Required for an interaction note"},"interactionType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Kind of the anchoring interaction: meeting, call, or chat-message. Required for an interaction note"},"parentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The note being replied to. Required for a user-reply note"},"creatorId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Attribute the note to another internal person. Defaults to the API key holder"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdate the note to this ISO 8601 timestamp"}},"hostedApiKey":"none"},"affinity_create_reminder":{"id":"affinity_create_reminder","name":"Affinity Create Reminder","description":"Create a reminder on one company, person, or opportunity. A recurring reminder resets whenever the chosen signal happens instead of firing once.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"one-time to fire once, or recurring to reset on a signal"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What the reminder is about: company, person, or opportunity"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"dueDate":{"type":"string","required":false,"visibility":"user-or-llm","description":"When the reminder is due, as an ISO 8601 timestamp. Required for a one-time reminder; on a recurring one Affinity computes it from the period when omitted"},"content":{"type":"string","required":false,"visibility":"user-or-llm","description":"What the reminder says"},"ownerId":{"type":"string","required":true,"visibility":"user-or-llm","description":"User the reminder is assigned to. Must be an internal user. The API key holder is recorded as the creator, which is a separate field"},"resetTrigger":{"type":"string","required":false,"visibility":"user-or-llm","description":"What restarts a recurring reminder: interaction, email, or event. Required when the type is recurring"},"periodDays":{"type":"number","required":false,"visibility":"user-or-llm","description":"Days between firings of a recurring reminder. Required when the type is recurring"}},"hostedApiKey":"none"},"affinity_delete_list_field_dropdown_option":{"id":"affinity_delete_list_field_dropdown_option","name":"Affinity Delete List Field Dropdown Option","description":"Permanently delete a dropdown option on a list field. Every list entry currently set to it is cleared, and those values cannot be recovered.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to delete"}},"hostedApiKey":"none"},"affinity_delete_note":{"id":"affinity_delete_note","name":"Affinity Delete Note","description":"Delete a note you created. Deleting a root note also deletes its replies; deleting a reply removes only that reply.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to delete"}},"hostedApiKey":"none"},"affinity_get_company":{"id":"affinity_get_company","name":"Affinity Get Company","description":"Look up one company by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"companyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The company ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_current_user":{"id":"affinity_get_current_user","name":"Affinity Get Current User","description":"Verify an Affinity API key and return the tenant, the user behind the key, and the scopes the grant carries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"}},"hostedApiKey":"none"},"affinity_get_entity_field_value":{"id":"affinity_get_entity_field_value","name":"Affinity Get Entity Field Value","description":"Read one non-list field value from a company or person.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read the field from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"hostedApiKey":"none"},"affinity_get_list":{"id":"affinity_get_list","name":"Affinity Get List","description":"Read one list — its name, type, owner, and privacy setting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"}},"hostedApiKey":"none"},"affinity_get_list_entry":{"id":"affinity_get_list_entry","name":"Affinity Get List Entry","description":"Read one row of a list with its entity. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_list_entry_field":{"id":"affinity_get_list_entry_field","name":"Affinity Get List Entry Field","description":"Read one field value on a list row.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"hostedApiKey":"none"},"affinity_get_list_field_dropdown_option":{"id":"affinity_get_list_field_dropdown_option","name":"Affinity Get List Field Dropdown Option","description":"Read one dropdown option on a list field.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID"}},"hostedApiKey":"none"},"affinity_get_merge":{"id":"affinity_get_merge","name":"Affinity Get Merge","description":"Read the status of one company or person merge, including why it failed if it did.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge to read: companies or persons"},"mergeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge ID"}},"hostedApiKey":"none"},"affinity_get_merge_task":{"id":"affinity_get_merge_task","name":"Affinity Get Merge Task","description":"Read one merge task and how its merges are progressing. Poll this after starting a merge.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge task to read: companies or persons"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge task ID"}},"hostedApiKey":"none"},"affinity_get_note":{"id":"affinity_get_note","name":"Affinity Get Note","description":"Read one note with its body, author, mentions, and attached records.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"}},"hostedApiKey":"none"},"affinity_get_opportunity":{"id":"affinity_get_opportunity","name":"Affinity Get Opportunity","description":"Read one opportunity and the list it belongs to. Its field data lives on the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"opportunityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The opportunity ID"}},"hostedApiKey":"none"},"affinity_get_person":{"id":"affinity_get_person","name":"Affinity Get Person","description":"Look up one person by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"personId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The person ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_saved_view":{"id":"affinity_get_saved_view","name":"Affinity Get Saved View","description":"Read one saved view — its name, kind, and creation date.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"}},"hostedApiKey":"none"},"affinity_get_transcript":{"id":"affinity_get_transcript","name":"Affinity Get Transcript","description":"Read one transcript with its first 100 fragments. Page the fragments endpoint for a longer meeting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"}},"hostedApiKey":"none"},"affinity_get_user":{"id":"affinity_get_user","name":"Affinity Get User","description":"Read one internal user. A user and their person record share the same numeric ID, so a person ID works here.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"userId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The user ID, which is also their person ID"}},"hostedApiKey":"none"},"affinity_list_calls":{"id":"affinity_list_calls","name":"Affinity List Calls","description":"Page through logged calls and their participants. Only calls the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_chat_messages":{"id":"affinity_list_chat_messages","name":"Affinity List Chat Messages","description":"Page through logged chat messages and their participants. Only messages the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_companies":{"id":"affinity_list_companies","name":"Affinity List Companies","description":"Page through companies. Companies come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these company IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_coworker_connections":{"id":"affinity_list_coworker_connections","name":"Affinity List Coworker Connections","description":"Find warm paths into a company through shared work history: who in your Affinity data once worked alongside the people you want to reach. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_emails":{"id":"affinity_list_emails","name":"Affinity List Emails","description":"Page through email metadata — subject, participants, and timestamps. Affinity never exposes email bodies through the API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_field_values":{"id":"affinity_list_entity_field_values","name":"Affinity List Entity Field Values","description":"Page through a company\'s or person\'s non-list field values. List fields are not returned here — read those through the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read field values from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_list_entity_list_entries":{"id":"affinity_list_entity_list_entries","name":"Affinity List Entity List Entries","description":"Page through a company\'s or person\'s rows across every list, each carrying that list\'s field values and when the entity was added.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the rows of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_lists":{"id":"affinity_list_entity_lists","name":"Affinity List Entity Lists","description":"List every list a company or person appears on that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the lists of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_notes":{"id":"affinity_list_entity_notes","name":"Affinity List Entity Notes","description":"List the notes relevant to one company, person, or opportunity — directly attached notes plus notes reaching it through its people and meetings.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity the notes hang off: companies, persons, or opportunities"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_entity_relationships":{"id":"affinity_list_entity_relationships","name":"Affinity List Entity Relationships","description":"List who knows a company or person, scored 0.0 to 1.0 by how much the two actually interact. Strongest first by default.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up relationships for: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on interactionScore only, e.g. \\"interactionScore>=0.5\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"interactionScore\\"] for weakest first, [\\"-interactionScore\\"] for strongest first (the default)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_field_dropdown_options":{"id":"affinity_list_field_dropdown_options","name":"Affinity List Field Dropdown Options","description":"List the selectable options on a dropdown or ranked-dropdown company or person field. Writing such a field needs the option ID, not its text.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which field family the field belongs to: companies or persons"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown or ranked-dropdown field ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_field_metadata":{"id":"affinity_list_field_metadata","name":"Affinity List Field Metadata","description":"List the non-list company or person fields, with the value type, filter operators, and sort support of each. Start here to find the Field IDs the read and write tools take.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which fields to describe: companies or persons"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Status\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_field_value_changes":{"id":"affinity_list_field_value_changes","name":"Affinity List Field Value Changes","description":"Page through field value changes across the whole workspace. Built for delta sync: follow nextCursor to the end of a run, then resume from the last cursor next time.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, listEntry.id, changer.id, changedAt, or actionType. Resume a sync with e.g. \\"changedAt>2026-06-01T12:00:00Z\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"changedAt\\"] for oldest first (the default), [\\"-changedAt\\"] for newest first"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_investor_executive_connections":{"id":"affinity_list_investor_executive_connections","name":"Affinity List Investor Executive Connections","description":"Find warm paths into a company through investment history: which investors in your Affinity data backed a company the people you want to reach once led. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_list_entries":{"id":"affinity_list_list_entries","name":"Affinity List List Entries","description":"Page through the rows of a list. Rows come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_entry_field_value_changes":{"id":"affinity_list_list_entry_field_value_changes","name":"Affinity List List Entry Field Value Changes","description":"Page through the history of one list row — who changed which field, when, and to what.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, changer.id, changedAt, or actionType, e.g. \\"field.id=field-1234\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_entry_fields":{"id":"affinity_list_list_entry_fields","name":"Affinity List List Entry Fields","description":"Page through every field value on one list row, including the list-specific columns. All fields are returned unless narrowed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, list, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_list_list_field_dropdown_options":{"id":"affinity_list_list_field_dropdown_options","name":"Affinity List List Field Dropdown Options","description":"List the selectable options on a dropdown, ranked-dropdown, or status-dropdown field of a list.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_fields":{"id":"affinity_list_list_fields","name":"Affinity List List Fields","description":"List the fields available on one list, including its list-specific columns. Use these Field IDs when reading or writing list entries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Stage\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_lists":{"id":"affinity_list_lists","name":"Affinity List Lists","description":"Page through the lists in the organization that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive substring match on the list name"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_meetings":{"id":"affinity_list_meetings","name":"Affinity List Meetings","description":"Page through past and upcoming meetings with their organizer and attendees.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_merge_tasks":{"id":"affinity_list_merge_tasks","name":"Affinity List Merge Tasks","description":"Page through merge tasks, each summarizing how many of its merges are in progress, succeeded, or failed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge tasks to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on status only, e.g. \\"status=in-progress\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_merges":{"id":"affinity_list_merges","name":"Affinity List Merges","description":"Page through the company or person merges the organization has run, with the status and the records involved in each.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merges to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over status or taskId, e.g. \\"status=failed\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_note_attached_companies":{"id":"affinity_list_note_attached_companies","name":"Affinity List Note Attached Companies","description":"List the companies directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_attached_opportunities":{"id":"affinity_list_note_attached_opportunities","name":"Affinity List Note Attached Opportunities","description":"List the opportunities directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_attached_persons":{"id":"affinity_list_note_attached_persons","name":"Affinity List Note Attached Persons","description":"List the persons directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_replies":{"id":"affinity_list_note_replies","name":"Affinity List Note Replies","description":"Page through the replies on one note, including AI Notetaker replies.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID whose replies to read"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_notes":{"id":"affinity_list_notes","name":"Affinity List Notes","description":"Page through every note the caller can see. Replies are excluded.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_opportunities":{"id":"affinity_list_opportunities","name":"Affinity List Opportunities","description":"Page through opportunities. Field data lives on the list entry, not here — read it through the list or saved view the opportunity belongs to.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these opportunity IDs, e.g. [1, 2, 3]"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_persons":{"id":"affinity_list_persons","name":"Affinity List Persons","description":"Page through persons. Persons come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these person IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_reminders":{"id":"affinity_list_reminders","name":"Affinity List Reminders","description":"Page through the reminders the caller can see. Filter by status to surface what is overdue.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_saved_view_entries":{"id":"affinity_list_saved_view_entries","name":"Affinity List Saved View Entries","description":"Page through the rows of a saved view. The view\'s own filters and columns decide which rows and which field data come back.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_saved_views":{"id":"affinity_list_saved_views","name":"Affinity List Saved Views","description":"List the saved views on a list that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_transcript_fragments":{"id":"affinity_list_transcript_fragments","name":"Affinity List Transcript Fragments","description":"Page through everything said in a meeting, segment by segment with the speaker.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_transcripts":{"id":"affinity_list_transcripts","name":"Affinity List Transcripts","description":"Page through meeting transcript metadata. Read one transcript to get what was actually said.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_users":{"id":"affinity_list_users","name":"Affinity List Users","description":"Page through the internal users in the organization. Email addresses and roles are returned only to callers with the \\"Manage Users\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive match across first name, last name, and primary email"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over id or status, e.g. \\"status=active\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_search_companies":{"id":"affinity_search_companies","name":"Affinity Search Companies","description":"Search companies by filters, sorts, and a free-text term. Requires the \\"Export All Organizations directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_search_files":{"id":"affinity_search_files","name":"Affinity Search Files","description":"Search files by keyword, ordered by relevance. Narrow to specific files or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these file IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s files. Cannot be combined with file IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of files to return, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_search_list_entries":{"id":"affinity_search_list_entries","name":"Affinity Search List Entries","description":"Search the rows of one list by filters, sorts, and a free-text term. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID to search"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_search_notes":{"id":"affinity_search_notes","name":"Affinity Search Notes","description":"Search notes by keyword, ordered by relevance. Narrow to specific notes or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these note IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s notes. Cannot be combined with note IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of notes to return, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_search_persons":{"id":"affinity_search_persons","name":"Affinity Search Persons","description":"Search persons by filters, sorts, and a free-text term. Requires the \\"Export All People directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_semantic_search":{"id":"affinity_semantic_search","name":"Affinity Semantic Search","description":"Find companies from a description in plain language — industry, technology, stage, or business model. Currently searches companies only.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to look for, in plain language, e.g. \\"climate tech companies in our pipeline\\". Up to 500 characters"},"listIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to companies on these lists, e.g. [1, 2]"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of companies to return, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_update_entity_field_value":{"id":"affinity_update_entity_field_value","name":"Affinity Update Entity Field Value","description":"Write one non-list field value on a company or person. The value type must match how the field is defined.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the field on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"hostedApiKey":"none"},"affinity_update_list_entry_field":{"id":"affinity_update_list_entry_field","name":"Affinity Update List Entry Field","description":"Write one field value on a list row. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"hostedApiKey":"none"},"affinity_update_list_field_dropdown_option":{"id":"affinity_update_list_field_dropdown_option","name":"Affinity Update List Field Dropdown Option","description":"Change a dropdown option on a list field. Every field is optional — supply only what should change, and only fields the option\'s kind actually has.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to update"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement option label. Supply at least one field to change"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"hostedApiKey":"none"},"affinity_update_note":{"id":"affinity_update_note","name":"Affinity Update Note","description":"Rewrite a note\'s body or replace which records it is attached to. Each list of IDs replaces that association wholesale, an empty list clears it, and omitting one leaves it untouched. A note\'s type cannot be changed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to update"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached companies, e.g. [1, 2]. Send [] to detach every company; omit to leave them unchanged"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached persons, e.g. [1, 2]. Send [] to detach every person; omit to leave them unchanged"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached opportunities, e.g. [1, 2]. Send [] to detach every opportunity; omit to leave them unchanged"}},"hostedApiKey":"none"},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"hostedApiKey":"none"},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}},"hostedApiKey":"none"},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}},"hostedApiKey":"none"},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}},"hostedApiKey":"none"},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}},"hostedApiKey":"none"},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"hostedApiKey":"none"},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}},"hostedApiKey":"none"},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}},"hostedApiKey":"none"},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}},"hostedApiKey":"none"},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}},"hostedApiKey":"none"},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"hostedApiKey":"none"},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"hostedApiKey":"none"},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"hostedApiKey":"none"},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}},"hostedApiKey":"none"},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}},"hostedApiKey":"none"},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}},"hostedApiKey":"none"},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"hostedApiKey":"none"},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"hostedApiKey":"none"},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}},"hostedApiKey":"none"},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}},"hostedApiKey":"none"},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}},"hostedApiKey":"none"},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}},"hostedApiKey":"none"},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}},"hostedApiKey":"none"},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}},"hostedApiKey":"none"},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"hostedApiKey":"none"},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}},"hostedApiKey":"none"},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}},"hostedApiKey":"none"},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"hostedApiKey":"none"},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}},"hostedApiKey":"none"},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"hostedApiKey":"none"},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"hostedApiKey":"none"},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}},"hostedApiKey":"none"},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}},"hostedApiKey":"none"},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}},"hostedApiKey":"none"},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}},"hostedApiKey":"none"},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"hostedApiKey":"none"},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"hostedApiKey":"none"},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"hostedApiKey":"none"},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}},"hostedApiKey":"none"},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}},"hostedApiKey":"none"},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}},"hostedApiKey":"none"},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}},"hostedApiKey":"none"},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}},"hostedApiKey":"none"},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}},"hostedApiKey":"none"},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}},"hostedApiKey":"none"},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}},"hostedApiKey":"none"},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}},"hostedApiKey":"none"},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}},"hostedApiKey":"none"},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}},"hostedApiKey":"none"},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}},"hostedApiKey":"none"},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}},"hostedApiKey":"none"},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}},"hostedApiKey":"none"},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}},"hostedApiKey":"none"},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}},"hostedApiKey":"none"},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}},"hostedApiKey":"none"},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}},"hostedApiKey":"none"},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}},"hostedApiKey":"none"},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}},"hostedApiKey":"none"},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}},"hostedApiKey":"none"},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}},"hostedApiKey":"none"},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}},"hostedApiKey":"none"},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airweave_search":{"id":"airweave_search","name":"Airweave Search","description":"Search your synced data collections using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Airweave API Key for authentication"},"collectionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The readable ID of the collection to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query text"},"limit":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 100)"},"retrievalStrategy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retrieval strategy: hybrid (default), neural, or keyword"},"expandQuery":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate query variations to improve recall"},"rerank":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reorder results for improved relevance using LLM"},"generateAnswer":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate a natural-language answer to the query"}},"hostedApiKey":"none"},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}},"hostedApiKey":"none"},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}},"hostedApiKey":"none"},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"hostedApiKey":"none"},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}},"hostedApiKey":"none"},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}},"hostedApiKey":"none"},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}},"hostedApiKey":"none"},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}},"hostedApiKey":"none"},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}},"hostedApiKey":"none"},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}},"hostedApiKey":"none"},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}},"hostedApiKey":"none"},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}},"hostedApiKey":"none"},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}},"hostedApiKey":"none"},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}},"hostedApiKey":"none"},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}},"hostedApiKey":"none"},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"hostedApiKey":"none"},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}},"hostedApiKey":"none"},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}},"hostedApiKey":"none"},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}},"hostedApiKey":"none"},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}},"hostedApiKey":"none"},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Run an APIFY actor asynchronously with polling for long-running tasks","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ], \\"maxPages\\": 10 }"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"hostedApiKey":"none"},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ], \\"maxPages\\": 10 }"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"hostedApiKey":"none"},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ] }"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}},"hostedApiKey":"none"},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}},"hostedApiKey":"none"},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}},"hostedApiKey":"none"},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}},"hostedApiKey":"none"},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}},"hostedApiKey":"none"},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}},"hostedApiKey":"none"},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}},"hostedApiKey":"none"},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}},"hostedApiKey":"none"},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}},"hostedApiKey":"none"},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}},"hostedApiKey":"none"},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}},"hostedApiKey":"none"},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"hostedApiKey":"none"},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"hostedApiKey":"none"},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}},"hostedApiKey":"none"},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}},"hostedApiKey":"none"},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}},"hostedApiKey":"none"},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}},"hostedApiKey":"none"},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}},"hostedApiKey":"none"},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}},"hostedApiKey":"none"},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}},"hostedApiKey":"none"},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}},"hostedApiKey":"none"},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}},"hostedApiKey":"none"},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}},"hostedApiKey":"none"},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}},"hostedApiKey":"none"},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}},"hostedApiKey":"none"},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}},"hostedApiKey":"none"},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}},"hostedApiKey":"none"},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}},"hostedApiKey":"none"},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}},"hostedApiKey":"none"},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}},"hostedApiKey":"none"},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}},"hostedApiKey":"none"},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}},"hostedApiKey":"none"},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}},"hostedApiKey":"none"},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}},"hostedApiKey":"none"},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}},"hostedApiKey":"none"},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}},"hostedApiKey":"none"},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}},"hostedApiKey":"none"},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}},"hostedApiKey":"none"},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Strips personally identifiable information from a candidate in Ashby. This does not delete the candidate - the record and its applications remain, with the PII removed. Ashby exposes no candidate deletion endpoint; true deletion is UI-only, restricted by role, and limited to a 10-day window. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}},"hostedApiKey":"none"},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}},"hostedApiKey":"none"},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"},"archiveEmail":{"type":"json","required":false,"visibility":"user-or-llm","description":"Archive email configuration with communicationTemplateId and optional sendAt ISO 8601 timestamp. Pass null or omit to send no archive email."}},"hostedApiKey":"none"},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in, or FirstPreInterviewScreen (defaults to the first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"},"applicationHistory":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional documented application history entries to create with the application"}},"hostedApiKey":"none"},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"},"location":{"type":"json","required":false,"visibility":"user-or-llm","description":"Candidate location object with optional city, region, and country"}},"hostedApiKey":"none"},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,