diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8d14cd98508..f99d9bbcba9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1.3.14-alpine +FROM oven/bun:1.4.1-alpine # Install necessary packages for development RUN apk add --no-cache \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b611026e2a..75d45ac5e46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,7 +240,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Cache Bun dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 @@ -960,7 +960,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index aadc3bf6f3e..eb5d1316f00 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -43,7 +43,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Install dependencies run: bun install --frozen-lockfile @@ -76,7 +76,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Install dependencies run: bun install --frozen-lockfile @@ -112,7 +112,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f7b6e2c2dd9..a986b909107 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -128,7 +128,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/docs-embeddings.yml b/.github/workflows/docs-embeddings.yml index 969f47f4b9f..ec73959170d 100644 --- a/.github/workflows/docs-embeddings.yml +++ b/.github/workflows/docs-embeddings.yml @@ -22,7 +22,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml index 6116c9ba1cb..ac6298ed8d6 100644 --- a/.github/workflows/helm.yml +++ b/.github/workflows/helm.yml @@ -43,7 +43,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 # Docker Compose and Kubernetes must run the same background jobs on the # same schedules; this fails the build if the two drift apart. The script diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index b92f99d6eac..0235a0b8342 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -34,7 +34,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Cache Bun dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 9513018c125..44d50c92704 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Setup Node.js for npm publishing uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index 95c74918a20..1739e37b19f 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/.github/workflows/publish-sim-setup.yml b/.github/workflows/publish-sim-setup.yml index 0594dee14b6..19b667c17b6 100644 --- a/.github/workflows/publish-sim-setup.yml +++ b/.github/workflows/publish-sim-setup.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml index c643c6ea5d8..e023621d6b4 100644 --- a/.github/workflows/publish-ts-sdk.yml +++ b/.github/workflows/publish-ts-sdk.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Setup Node.js for npm publishing uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/.github/workflows/stickydisk-gc.yml b/.github/workflows/stickydisk-gc.yml new file mode 100644 index 00000000000..5ac5e54d800 --- /dev/null +++ b/.github/workflows/stickydisk-gc.yml @@ -0,0 +1,183 @@ +name: Sticky Disk GC + +# node_modules sticky disks are keyed on hashFiles('bun.lock') by design — see the +# "Mount node_modules" comment in test-build.yml. A sticky disk is a mutable volume +# and `bun install --frozen-lockfile` adds what the lockfile needs without pruning +# what it dropped, so branches on different lockfiles must not share one. That +# design is correct and is preserved here. +# +# Its cost is a fresh ~13.7 GB disk per lockfile hash per event class, created at +# ~6.3/day. Blacksmith evicts a sticky disk after 7 days of inactivity, so this is +# not a leak — it is a retention window far too generous for a key that churns +# this fast. Steady state is (new disks/day x GB/disk x retention days), which at +# 7 days predicts 606 GB against 601 GB actually observed. +# +# Age-based on purpose. The key contains NO PR identifier, so every open PR whose +# checkout has the same bun.lock mounts the SAME disk — there are far more open +# PRs than distinct lockfile hashes, so sharing is the common case, not an edge +# case. Deleting on PR close would destroy a disk many other open PRs are using. +# Never add a pull_request or pull_request_target trigger here. + +on: + schedule: + - cron: '17 9 * * *' + workflow_dispatch: + inputs: + retention_days: + description: Delete node_modules disks unused for more than this many days. + required: false + default: '3' + dry_run: + description: List what would be deleted without deleting it. + type: boolean + required: false + default: false + +# Nothing in this job reads the repository. +permissions: {} + +concurrency: + group: stickydisk-gc + cancel-in-progress: false + +jobs: + gc: + name: Reclaim idle node_modules sticky disks + # GitHub-hosted on purpose, not a Blacksmith runner: the CLI is a pure API + # client, and collection has to keep working during a Blacksmith outage or a + # CI_PROVIDER break-glass switch — exactly when disks sit idle and still bill. + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + # Pinned binary + checksum rather than `curl https://get.blacksmith.sh | sh`: + # this job holds an org-wide token, so it must not execute unpinned remote + # shell. The vendor publishes a .sha256 sidecar next to each binary; check a + # new version against it and bump both values together. + BLACKSMITH_CLI_VERSION: v0.4.58 + BLACKSMITH_CLI_SHA256: 0b54a4398e9b35344d8fb32891703d8a393343f5001914d7482f93d068c76822 + # The CLI self-updates in the background on every invocation, which would + # silently defeat the pin above. + BLACKSMITH_DISABLE_AUTO_UPDATE: '1' + BLACKSMITH_ORG: simstudioai + TARGET_REPO: ${{ github.repository }} + RETENTION_DAYS: ${{ inputs.retention_days || '3' }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + + steps: + - name: Install Blacksmith CLI + run: | + set -euo pipefail + url="https://clireleases.blacksmith.sh/cli/${BLACKSMITH_CLI_VERSION}/linux/amd64/blacksmith" + curl -fsSL "$url" -o /usr/local/bin/blacksmith + echo "${BLACKSMITH_CLI_SHA256} /usr/local/bin/blacksmith" | sha256sum -c - + chmod +x /usr/local/bin/blacksmith + + - name: Authenticate + env: + BLACKSMITH_CLI_TOKEN: ${{ secrets.BLACKSMITH_CLI_TOKEN }} + run: | + set -euo pipefail + if [ -z "${BLACKSMITH_CLI_TOKEN:-}" ]; then + echo "::error::BLACKSMITH_CLI_TOKEN is not set. Mint one with 'blacksmith org-token create' and add it as a repository secret." + exit 1 + fi + printf '%s' "$BLACKSMITH_CLI_TOKEN" \ + | blacksmith auth login --api-token - --non-interactive --organization "$BLACKSMITH_ORG" + + - name: Delete node_modules disks idle beyond the retention window + run: | + set -euo pipefail + + case "$RETENTION_DAYS" in + ''|*[!0-9]*) + echo "::error::retention_days must be a whole number of days, got '${RETENTION_DAYS}'" + exit 1 + ;; + esac + if [ "$RETENTION_DAYS" -lt 1 ]; then + echo "::error::retention_days must be at least 1; 0 would delete disks a running job is using" + exit 1 + fi + + blacksmith stickydisk list \ + --repo "$TARGET_REPO" \ + --search '-node-modules-' \ + --per-page 100 \ + --format json > disks.json + + # Reduce the listing to the deletable set ONCE, so the staleness test and + # the bulk-delete guard below both count the same things. Deriving the + # guard's denominator from the raw entries instead would double-count + # architecture variants (which are deliberately grouped into one key) and + # would also count entries the regex rejected, so the guard could never + # fire. + # + # Two independent filters, because the blast radius of a wrong key is a + # cache every CI job depends on: + # 1. --search narrows server-side to the node_modules family. + # 2. The regex re-proves each key's full shape locally. + # The event segment is [a-z_]+ rather than an enumerated push|pull_request + # because the key interpolates ${{ github.event_name }} and a + # workflow_dispatch disk already exists that an enumerated list would have + # skipped forever. `-fork` is a separate optional segment rather than part + # of that class: test-build.yml appends it after the event name, so a fork + # key reads `pull_request-fork` and a character class cannot span the + # hyphen. Without it, fork disks would never be collected. + # + # Grouped by key because `delete` without --arch removes every + # architecture variant, so a key may only go when its NEWEST variant is + # stale. + jq --arg repo "$TARGET_REPO" ' + .entries + | map(select(.type == "stickydisk")) + | map(select(.key | test("^" + ($repo | gsub("/"; "\\/")) + "-node-modules-[a-z_]+(-fork)?-[0-9a-f]{64}$"))) + | group_by(.key) + | map({ + key: .[0].key, + gb: (map(.size_bytes) | add / 1000000000 * 100 | round / 100), + last_used: (map(.last_used_at | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) | max) + }) + ' disks.json > eligible.json + + jq -r --argjson days "$RETENTION_DAYS" ' + (now - ($days * 86400)) as $cutoff + | map(select(.last_used < $cutoff)) + | .[] | "\(.key)\t\(.gb)" + ' eligible.json > stale.tsv + + total=$(jq 'length' eligible.json) + count=$(wc -l < stale.tsv | tr -d ' ') + reclaimed=$(awk -F'\t' '{s+=$2} END {printf "%.1f", s+0}' stale.tsv) + { + echo "### Sticky disk GC" + echo "" + echo "Retention **${RETENTION_DAYS}d** · dry run **${DRY_RUN}** · **${count}** of ${total} node_modules disks idle (**${reclaimed} GB**)" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + # A run that would delete everything means the listing or the clock is + # wrong, not that every disk went idle at once. Refuse rather than wipe + # the caches every CI job depends on. + if [ "$count" -gt 0 ] && [ "$count" -eq "$total" ]; then + echo "::error::Refusing to delete all ${total} node_modules disks — that indicates a listing or clock fault, not genuine idleness." + exit 1 + fi + + failed=0 + while IFS=$'\t' read -r key gb; do + [ -n "$key" ] || continue + if [ "$DRY_RUN" = "true" ]; then + echo "- would delete \`${key}\` (${gb} GB)" >> "$GITHUB_STEP_SUMMARY" + continue + fi + if blacksmith stickydisk delete --repo "$TARGET_REPO" --key "$key" --yes; then + echo "- deleted \`${key}\` (${gb} GB)" >> "$GITHUB_STEP_SUMMARY" + else + echo "- FAILED \`${key}\`" >> "$GITHUB_STEP_SUMMARY" + failed=1 + fi + done < stale.tsv + + # Fail loudly rather than continue-on-error: a revoked token or a changed + # CLI JSON shape would otherwise silently revert us to 7-day billing. + exit "$failed" diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index b4d87d8425e..c5028b877c1 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -30,7 +30,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 @@ -230,7 +230,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 @@ -303,7 +303,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.1 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/README.md b/README.md index 7b2a05134aa..78c103799e2 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ npx sim-setup Open [http://localhost:3000](http://localhost:3000) +### Desktop: [macOS](https://sim.ai/api/desktop/update/download) + +Download Sim for macOS + +macOS 12+, Apple Silicon and Intel. See the [desktop docs](https://docs.sim.ai/desktop) to point it at a self-hosted deployment. +

The Sim platform — chat on the left, the visual workflow builder on the right

@@ -46,7 +52,7 @@ Open [http://localhost:3000](http://localhost:3000) ## One workspace, every surface -

Chat and workflows are just the start — tables, files, and knowledge all live in the same workspace.

+

Chat and workflows are just the start — tables, files, and knowledge all live in the same workspace, in the browser or on your desktop.

@@ -64,7 +70,10 @@ Open [http://localhost:3000](http://localhost:3000) Knowledge bases in Sim — synced docs your agents can search

Knowledge — your agents' memory

- +
+ Sim Desktop — Chat on the left, the built-in browser on the right +

Desktop — Sim on your Mac, browser and terminal included

+
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 49f5aa98909..c90de11d4bd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,7 @@ "type": "module", "main": "dist/main.cjs", "engines": { - "bun": ">=1.3.14", + "bun": ">=1.4.1", "node": ">=20.0.0" }, "scripts": { diff --git a/apps/desktop/src/main/browser-agent/cdp.test.ts b/apps/desktop/src/main/browser-agent/cdp.test.ts index f2cb1f6c0d7..c65c987f361 100644 --- a/apps/desktop/src/main/browser-agent/cdp.test.ts +++ b/apps/desktop/src/main/browser-agent/cdp.test.ts @@ -501,18 +501,25 @@ describe('browser-agent screenshot capture', () => { return Promise.resolve(undefined) }) const resized = { + getSize: vi.fn(() => ({ width: 1024, height: 512 })), toJPEG: vi.fn(() => Buffer.from('resized')), } + const cropped = { + getSize: vi.fn(() => ({ width: 400, height: 200 })), + 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({ 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 } + return { contents, resized, cropped } } function screenshotParams(contents: WebContents): Record { @@ -531,6 +538,23 @@ describe('browser-agent screenshot capture', () => { expect(screenshotParams(contents)).not.toHaveProperty('clip') }) + it('crops the decoded image in memory without sending a CDP clip', async () => { + const { contents, cropped } = 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(image.crop).toHaveBeenCalledWith({ x: 200, y: 100, width: 400, height: 200 }) + expect(cropped.resize).not.toHaveBeenCalled() + expect(shot).toEqual({ + dataUrl: `data:image/jpeg;base64,${Buffer.from('cropped').toString('base64')}`, + scale: 2, + viewport: { width: 2048, height: 1024 }, + imageSize: { width: 400, height: 200 }, + }) + }) + /** * 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 @@ -597,6 +621,24 @@ describe('browser-agent screenshot capture', () => { expect(shot.imageSize).toEqual({ width: 1024, height: 512 }) }) + it('refuses element cropping without verified CSS viewport metrics', async () => { + const { contents } = captureFixture({ width: 1024, height: 512 }) + vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { + if (method === 'Page.getLayoutMetrics') { + return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } }) + } + return Promise.resolve(undefined) + }) + + await expect( + captureScreenshot(contents, { x: 10, y: 10, width: 100, height: 50 }) + ).rejects.toThrow(/CSS viewport/) + expect(contents.debugger.sendCommand).not.toHaveBeenCalledWith( + 'Page.captureScreenshot', + expect.anything() + ) + }) + it('accepts stable finite scroll offsets around the capture', async () => { const { contents } = captureFixture({ width: 1024, height: 512 }) vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { diff --git a/apps/desktop/src/main/browser-agent/cdp.ts b/apps/desktop/src/main/browser-agent/cdp.ts index 75cf1adbc80..e966850aaf2 100644 --- a/apps/desktop/src/main/browser-agent/cdp.ts +++ b/apps/desktop/src/main/browser-agent/cdp.ts @@ -404,6 +404,13 @@ export interface ScreenshotCapture { imageSize: ScreenshotSize | null } +export interface ScreenshotClip { + x: number + y: number + width: number + height: number +} + function screenshotViewportMetrics( metrics: { cssLayoutViewport?: CdpViewport @@ -461,12 +468,13 @@ function sameScreenshotViewport( * snapshot capture refuses to scale a visible surface for the same reason. * * Bounding resolution therefore happens here instead, on the returned image. - * The output keeps the dimensions the clipped capture produced, so `scale` - * still maps image pixels back to CSS pixels for the coordinate tools - * (cssX = imageX / scale) — including on a 2x display, where an unclipped - * capture arrives at device resolution and this is what brings it back down. + * Optional element crops also happen in memory. Convert output coordinates + * with cssX = (clip?.x ?? 0) + imageX / scale, and the equivalent Y formula. */ -export async function captureScreenshot(contents: WebContents): Promise { +export async function captureScreenshot( + contents: WebContents, + clip?: ScreenshotClip +): Promise { const metrics = await send<{ cssLayoutViewport?: CdpViewport layoutViewport?: CdpViewport @@ -478,6 +486,9 @@ export async function captureScreenshot(contents: WebContents): Promise 0 && cssHeight > 0 ? { width: cssWidth, height: cssHeight } : null + if (clip && !cssViewport) { + throw new Error('A CSS viewport is required for element screenshot cropping') + } const scale = width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1 @@ -499,8 +510,49 @@ export async function captureScreenshot(contents: WebContents): Promise { expect(second.result).toMatchObject({ tabs: [] }) }) + it('lists safe download metadata without opening a page', async () => { + const result = await driver.executeTool('chat-test', 'browser_list_downloads', {}) + + expect(result).toEqual({ + ok: true, + result: { scopeId: 'chat-test', downloads: [] }, + }) + expect(session.peekTabsState().tabs).toEqual([]) + }) + + it('reloads the active tab and waits for its load boundary', async () => { + await driver.executeTool('chat-test', 'browser_open_tab', {}) + const contents = session.requireTab().view.webContents + vi.useFakeTimers() + try { + const reload = driver.executeTool('chat-test', 'browser_reload', {}) + await vi.advanceTimersByTimeAsync(500) + + await expect(reload).resolves.toMatchObject({ ok: true }) + expect(contents.reload).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('uses the shared failed-page recovery path when reloading', async () => { + await driver.executeTool('chat-test', 'browser_open_tab', {}) + const tab = session.requireTab() + tab.pageIssue = { + kind: 'load-error', + url: 'https://example.com/failed', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + } + vi.useFakeTimers() + try { + const result = driver.executeTool('chat-test', 'browser_reload', {}) + await vi.advanceTimersByTimeAsync(500) + + await expect(result).resolves.toMatchObject({ ok: true }) + expect(tab.view.webContents.loadURL).toHaveBeenCalledWith('https://example.com/failed') + expect(tab.view.webContents.reload).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('keeps a takeover pending when the clock advances beyond twelve hours', async () => { await driver.executeTool('chat-test', 'browser_open_tab', {}) vi.useFakeTimers() @@ -1598,62 +1645,48 @@ describe('executeTool', () => { ) }) - it('does not let a snapshot that resolves after timeout overwrite newer refs', async () => { - vi.useFakeTimers() - try { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - const contents = session.requireTab().view.webContents - vi.mocked(contents.getURL).mockReturnValue('https://example.com/') - let resolveLate: ((value: unknown) => void) | undefined - let snapshotCalls = 0 - vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { - if (!isPageCall(expression, 'collectSnapshot')) return Promise.resolve(undefined) - snapshotCalls++ - if (snapshotCalls === 1) { + it.each(['browser_snapshot', 'browser_find'] as const)( + 'does not let a timed-out %s restore refs', + async (tool) => { + vi.useFakeTimers() + try { + await driver.executeTool('chat-test', 'browser_open_tab', {}) + const contents = session.requireTab().view.webContents + vi.mocked(contents.getURL).mockReturnValue('https://example.com/') + let resolveLate: ((value: unknown) => void) | undefined + vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { + if (!isPageCall(expression, 'collectSnapshot')) return Promise.resolve(undefined) return new Promise((resolve) => { resolveLate = resolve }) - } - return Promise.resolve({ + }) + + const late = driver.executeTool('chat-test', tool, { query: 'Late' }) + await vi.advanceTimersByTimeAsync(20_000) + await expect(late).resolves.toMatchObject({ ok: false }) + resolveLate?.({ url: 'https://example.com/', - title: 'Fresh', - outline: '- button "Fresh" [ref=10]', + title: 'Late', + outline: '- button "Late" [ref=0]', truncated: false, - refIds: [10], - refLineIndexes: { 10: 0 }, - nextElementId: 11, + refIds: [0], + refLineIndexes: { 0: 0 }, + nextElementId: 1, }) - }) - - const late = driver.executeTool('chat-test', 'browser_snapshot', {}) - await vi.advanceTimersByTimeAsync(20_000) - await expect(late).resolves.toMatchObject({ ok: false }) - await expect(driver.executeTool('chat-test', 'browser_snapshot', {})).resolves.toMatchObject({ - ok: true, - }) - - resolveLate?.({ - url: 'https://example.com/', - title: 'Late', - outline: '- button "Late" [ref=0]', - truncated: false, - refIds: [0], - refLineIndexes: { 0: 0 }, - nextElementId: 1, - }) - await Promise.resolve() - await Promise.resolve() - - await expect( - driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) - ).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('not present in the current snapshot'), - }) - } finally { - vi.useRealTimers() + await Promise.resolve() + await Promise.resolve() + + await expect( + driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) + ).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('Call browser_snapshot'), + }) + } finally { + vi.useRealTimers() + } } - }) + ) it('merges cross-origin structure and routes its refs through production frame isolation', async () => { const win = new BrowserWindow() @@ -1752,7 +1785,7 @@ describe('executeTool', () => { if (isPageCall(expression, 'collectSnapshot')) { return Promise.resolve({ url: 'https://ogs.google.com/u/0/widget/app', - title: 'Google apps', + title: 'Google apps [ref=0]', outline: '- link "Drive" [ref=1]\n- textbox "Search apps" [ref=2]', truncated: false, refIds: [1, 2], @@ -1838,7 +1871,7 @@ describe('executeTool', () => { expect(snapshot).toMatchObject({ ok: true, result: { - outline: expect.stringContaining('cross-origin iframe "Google apps"'), + outline: expect.stringContaining('cross-origin iframe "Google apps [ref\u200b=0]"'), capturedCrossOriginFrames: 1, unreadableCrossOriginFrames: 1, hiddenCrossOriginFrames: 1, @@ -3197,6 +3230,456 @@ describe('credential protection', () => { expect(result.error).toMatch(/same point/) }) + it('finds only fresh ref-bearing snapshot lines with literal text matching', async () => { + const contents = await openPage() + respondWith(contents, { + collectSnapshot: { + url: 'https://example.com/login', + title: 'Example', + outline: + '- button "Continue [ref\u200b=999]" [ref=4]\n- heading "Continue without a ref"\n- button "Other" [ref=5]', + truncated: false, + refIds: [4, 5], + refLineIndexes: { 4: 0, 5: 2 }, + nextElementId: 6, + }, + }) + + const result = await driver.executeTool('chat-test', 'browser_find', { query: 'continue' }) + + expect(result).toMatchObject({ + ok: true, + result: { + matches: [{ elementId: 4 }], + totalMatches: 1, + truncated: false, + }, + }) + }) + + it.each(['browser_snapshot', 'browser_find'] as const)( + 'omits an absent scope from the serialized %s page call', + async (tool) => { + const contents = await openPage() + vi.mocked(contents.executeJavaScript).mockClear() + await driver.executeTool('chat-test', tool, { query: 'Continue' }) + const expressions = vi + .mocked(contents.executeJavaScript) + .mock.calls.map(([expression]) => expression) + .filter((expression) => isPageCall(expression, 'collectSnapshot')) + expect(expressions).toHaveLength(1) + expect(expressions[0]).toContain('.apply(null, [1])') + } + ) + + it.each(['browser_snapshot', 'browser_find'] as const)( + 'passes the current root ref to %s and invalidates previous refs', + async (tool) => { + const contents = await openPage() + respondWith(contents, { + collectSnapshot: { + url: 'https://example.com/login', + title: 'Scoped', + scoped: true, + outline: '- button "Save" [ref=1]', + truncated: false, + refIds: [1], + refLineIndexes: { 1: 0 }, + nextElementId: 2, + }, + }) + const result = await driver.executeTool('chat-test', tool, { elementId: 0, query: 'Save' }) + expect(result).toMatchObject({ ok: true, result: { scoped: true } }) + expect( + vi + .mocked(contents.executeJavaScript) + .mock.calls.some( + ([expression]) => + isPageCall(expression, 'collectSnapshot') && + expression.includes('.apply(null, [1,0])') + ) + ).toBe(true) + const stale = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) + expect(stale).toMatchObject({ ok: false }) + } + ) + + it.each([ + [1, false, 1], + [100, false, 50], + [100, true, 50], + ])( + 'bounds find results for maxResults=%s and snapshot truncation=%s', + async (maxResults, truncated, expectedCount) => { + const contents = await openPage() + const ids = Array.from({ length: 60 }, (_, index) => index + 1) + respondWith(contents, { + collectSnapshot: { + url: 'https://example.com/login', + title: 'Example', + outline: ids.map((id) => `- button "Continue ${id}" [ref=${id}]`).join('\n'), + truncated, + refIds: ids, + refLineIndexes: Object.fromEntries(ids.map((id, index) => [id, index])), + nextElementId: 61, + }, + }) + const response = await driver.executeTool('chat-test', 'browser_find', { + query: 'Continue', + maxResults, + }) + expect(response).toMatchObject({ ok: true, result: { totalMatches: 60, truncated: true } }) + expect((response.result as { matches: unknown[] }).matches).toHaveLength(expectedCount) + } + ) + + it('reports incomplete search coverage even when no ref matches', async () => { + const contents = await openPage() + respondWith(contents, { + collectSnapshot: { + url: 'https://example.com/login', + title: 'Example', + outline: '- button "Other" [ref=1]', + truncated: true, + refIds: [1], + refLineIndexes: { 1: 0 }, + nextElementId: 2, + }, + }) + const response = await driver.executeTool('chat-test', 'browser_find', { query: 'Missing' }) + expect(response).toMatchObject({ + ok: true, + result: { matches: [], totalMatches: 0, truncated: true }, + }) + }) + + it('rejects oversized search text before taking a snapshot', async () => { + const contents = await openPage() + vi.mocked(contents.executeJavaScript).mockClear() + const response = await driver.executeTool('chat-test', 'browser_find', { + query: 'x'.repeat(4097), + }) + expect(response).toMatchObject({ ok: false }) + expect(contents.executeJavaScript).not.toHaveBeenCalled() + }) + + it.each([ + { kind: 'input:checkbox', checked: true, disabled: false, readOnly: false }, + { kind: 'input:radio', checked: false, disabled: false, readOnly: false }, + { kind: 'role:radio', checked: false, disabled: false, readOnly: false }, + { kind: 'role:menuitemradio', checked: false, disabled: false, readOnly: false }, + { kind: 'input:checkbox', checked: true, disabled: true, readOnly: false }, + { kind: 'input:checkbox', checked: true, disabled: false, readOnly: true }, + ])('does not click a control already in the requested state: %j', async (before) => { + const contents = await openPage() + respondWith(contents, { + readCheckableElementState: before, + }) + + const result = await driver.executeTool('chat-test', 'browser_set_checked', { + elementId: 0, + checked: before.checked, + }) + + expect(result).toMatchObject({ + ok: true, + result: { checked: before.checked, changed: false, dispatched: false }, + }) + expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0) + }) + + it.each([ + { checked: true, kind: 'input:radio', error: 'cannot be unchecked' }, + { checked: true, kind: 'role:radio', error: 'cannot be unchecked' }, + { checked: true, kind: 'role:menuitemradio', error: 'cannot be unchecked' }, + { checked: false, kind: 'input:checkbox', disabled: true, error: 'disabled' }, + { checked: false, kind: 'input:checkbox', readOnly: true, error: 'read-only' }, + ])('rejects a prohibited state change: %j', async (before) => { + const contents = await openPage() + respondWith(contents, { readCheckableElementState: before }) + const result = await driver.executeTool('chat-test', 'browser_set_checked', { + elementId: 0, + checked: !before.checked, + }) + expect(result.ok).toBe(false) + expect(result.error).toContain(before.error) + expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0) + }) + + it.each([false, 'mixed'])( + 'uses the trusted click path from %s and verifies a changed checkable control', + async (initialState) => { + const contents = await openPage() + let stateReads = 0 + vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { + if (isPageCall(expression, 'readCheckableElementState')) { + stateReads++ + return Promise.resolve({ + checked: stateReads > 1 ? true : initialState, + disabled: false, + readOnly: false, + kind: 'input:checkbox', + }) + } + if (isPageCall(expression, 'clickElement')) { + return Promise.resolve({ dispatched: false, x: 24, y: 48, element: 'Checkbox' }) + } + if (isPageCall(expression, 'readPageActionState')) { + return Promise.resolve({ + url: 'https://example.com/login', + title: 'Example', + focus: 'body', + mutationRevision: 0, + dialogs: [], + scroll: [0], + }) + } + if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({}) + return Promise.resolve(undefined) + }) + + const result = await driver.executeTool('chat-test', 'browser_set_checked', { + elementId: 0, + checked: true, + }) + + expect(result).toMatchObject({ + ok: true, + result: { checked: true, changed: true, dispatched: true, trusted: true }, + }) + expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(3) + } + ) + + it('waits for URL and semantic element state together', async () => { + const contents = await openPage() + respondWith(contents, { + readPageActionState: { + targetState: { present: true, rendered: true, disabled: false }, + }, + }) + + const result = await driver.executeTool('chat-test', 'browser_wait_for', { + urlContains: '/login', + elementId: 0, + state: 'enabled', + timeoutMs: 1_000, + }) + + expect(result).toMatchObject({ + ok: true, + result: { found: true, matched: ['url', 'element'] }, + }) + }) + + it.each([true, false])( + 'polls delayed checkable state without redispatching input (updates=%s)', + async (updates) => { + const contents = await openPage() + let reads = 0 + vi.mocked(contents.executeJavaScript).mockImplementation(async (expression: string) => { + if (isPageCall(expression, 'readCheckableElementState')) { + reads++ + return { checked: updates && reads >= 4, kind: 'input:checkbox' } + } + if (isPageCall(expression, 'clickElement')) + return { dispatched: false, x: 24, y: 48, element: 'Checkbox' } + if (isPageCall(expression, 'readPageActionState')) + return { + url: 'https://example.com/login', + title: 'Example', + focus: 'body', + mutationRevision: 0, + dialogs: [], + scroll: [0], + } + if (isPageCall(expression, 'readActiveElementState')) return {} + }) + vi.useFakeTimers() + try { + const pending = driver.executeTool('chat-test', 'browser_set_checked', { + elementId: 0, + checked: true, + }) + await vi.advanceTimersByTimeAsync(2000) + const result = await pending + expect(result.ok).toBe(updates) + expect(reads).toBeGreaterThanOrEqual(4) + expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(3) + if (!updates) expect(result.error).toContain('did not reach the requested checked state') + } finally { + vi.useRealTimers() + } + } + ) + + it.each(['hidden', 'detached'])( + 'does not treat a failed probe as element state %s', + async (state) => { + const contents = await openPage() + vi.mocked(contents.executeJavaScript).mockRejectedValue( + new Error('Execution context destroyed') + ) + const response = await driver.executeTool('chat-test', 'browser_wait_for', { + elementId: 0, + state, + timeoutMs: 1000, + }) + expect(response).toMatchObject({ ok: false }) + expect(response.error).toContain('Execution context destroyed') + } + ) + + it('rejects navigation while inspecting an element wait condition', async () => { + const contents = await openPage() + vi.mocked(contents.executeJavaScript).mockImplementation(async () => { + vi.mocked(contents.getURL).mockReturnValue('https://example.com/next') + return { targetState: { present: false, rendered: false } } + }) + const response = await driver.executeTool('chat-test', 'browser_wait_for', { + elementId: 0, + state: 'detached', + timeoutMs: 1000, + }) + expect(response).toMatchObject({ ok: false }) + expect(response.error).toContain('page changed') + }) + + it.each([ + ['detached', { present: true, rendered: false }], + ['collapsed', { present: true, rendered: true }], + ['expanded', { present: true, rendered: true }], + ['unchecked', { present: true, rendered: true, checked: 'mixed' }], + ['unchecked', { present: true, rendered: true }], + ])('does not satisfy %s from an incompatible element state', async (state, targetState) => { + const contents = await openPage() + respondWith(contents, { readPageActionState: { targetState } }) + vi.useFakeTimers() + try { + const response = driver.executeTool('chat-test', 'browser_wait_for', { + elementId: 0, + state, + timeoutMs: 100, + }) + await vi.advanceTimersByTimeAsync(1000) + await expect(response).resolves.toMatchObject({ ok: true, result: { found: false } }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['expanded', { open: true }], + ['collapsed', { open: false }], + ['expanded', { ariaExpanded: 'true' }], + ['collapsed', { ariaExpanded: 'false' }], + ])('waits for %s on native and ARIA disclosure controls', async (state, semanticState) => { + const contents = await openPage() + respondWith(contents, { + readPageActionState: { targetState: { present: true, rendered: true, ...semanticState } }, + }) + await expect( + driver.executeTool('chat-test', 'browser_wait_for', { + elementId: 0, + state, + timeoutMs: 100, + }) + ).resolves.toMatchObject({ ok: true, result: { found: true } }) + }) + + it.each(['x', 'y', 'width', 'height', 'detached'])( + 'rejects an element screenshot when %s changes during capture', + async (change) => { + const contents = await openPage() + let captured = false + vi.mocked(contents.executeJavaScript).mockImplementation(async (expression: string) => { + if (!isPageCall(expression, 'getElementScreenshotRect')) return undefined + if (captured && change === 'detached') return { error: 'stale' } + return { x: 20, y: 30, width: 200, height: 100, ...(captured ? { [change]: 50 } : {}) } + }) + const capture = vi.spyOn(cdp, 'captureScreenshot').mockImplementation(async () => { + captured = true + return { + dataUrl: 'data:image/jpeg;base64,c2lt', + scale: 1, + viewport: { width: 800, height: 600 }, + imageSize: { width: 200, height: 100 }, + } + }) + try { + const result = await driver.executeTool('chat-test', 'browser_screenshot', { elementId: 0 }) + expect(result.ok).toBe(false) + expect(result.error).toMatch(change === 'detached' ? /stale/ : /element moved/) + expect(capture).toHaveBeenCalledTimes(1) + } finally { + capture.mockRestore() + } + } + ) + + it('crops an element screenshot without changing the live viewport', async () => { + const contents = await openPage() + respondWith(contents, { + getElementScreenshotRect: { + x: 20, + y: 30, + width: 200, + height: 100, + element: 'button', + refRecovered: false, + }, + }) + const capture = vi.spyOn(cdp, 'captureScreenshot').mockResolvedValue({ + dataUrl: 'data:image/jpeg;base64,c2lt', + scale: 1, + viewport: { width: 800, height: 600 }, + imageSize: { width: 200, height: 100 }, + }) + + 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(result).toMatchObject({ + ok: true, + result: { element: 'button', clip: { x: 20, y: 30, width: 200, height: 100 } }, + }) + } finally { + capture.mockRestore() + } + }) + + it('rejects navigation during an element screenshot measurement', async () => { + const contents = await openPage() + vi.mocked(contents.executeJavaScript).mockImplementation(async (expression: string) => { + if (isPageCall(expression, 'getElementScreenshotRect')) { + vi.mocked(contents.getURL).mockReturnValue('https://example.com/next') + return { x: 20, y: 30, width: 200, height: 100 } + } + }) + const capture = vi.spyOn(cdp, 'captureScreenshot') + try { + const result = await driver.executeTool('chat-test', 'browser_screenshot', { elementId: 0 }) + expect(result).toMatchObject({ ok: false }) + expect(result.error).toMatch(/page changed/) + expect(capture).not.toHaveBeenCalled() + } finally { + capture.mockRestore() + } + }) + + it('zooms by a standard step and invalidates existing element refs', async () => { + const contents = await openPage() + + const zoomed = await driver.executeTool('chat-test', 'browser_zoom', { action: 'in' }) + const staleRef = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) + + expect(zoomed).toMatchObject({ ok: true, result: { action: 'in' } }) + expect(contents.setZoomFactor).toHaveBeenCalled() + expect(staleRef).toMatchObject({ ok: false }) + expect(staleRef.error).toMatch(/Call browser_snapshot/) + }) + it('returns the screenshot scale for coordinate mapping', async () => { const contents = await openPage() mockScreenshotImage({ width: 1024, height: 512 }) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 985bc6ea4f6..671b189bbfb 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -52,11 +52,13 @@ import { describeFocusedEditable, describePointTarget, focusElementForTyping, + getElementScreenshotRect, getViewportInfo, hoverElement, pageContainsText, pressKeyOnPage, readActiveElementState, + readCheckableElementState, readChildFrameElementState, readPageActionState, readPageText, @@ -91,6 +93,27 @@ const MAX_CROSS_ORIGIN_SNAPSHOT_FRAMES = 8 const MAX_CROSS_ORIGIN_SCAN_FRAMES = 32 const COMBINED_SNAPSHOT_LINE_CAP = 900 const BROWSER_AGENT_ISOLATED_WORLD_ID = 1001 +const BROWSER_WAIT_ELEMENT_STATES = [ + 'attached', + 'detached', + 'visible', + 'hidden', + 'enabled', + 'disabled', + 'checked', + 'unchecked', + 'expanded', + 'collapsed', + 'selected', + 'unselected', +] as const +const BROWSER_WAIT_ELEMENT_STATE_SET: ReadonlySet = new Set(BROWSER_WAIT_ELEMENT_STATES) + +type BrowserWaitElementState = (typeof BROWSER_WAIT_ELEMENT_STATES)[number] + +function isBrowserWaitElementState(value: string): value is BrowserWaitElementState { + return BROWSER_WAIT_ELEMENT_STATE_SET.has(value) +} type PageExecutionTarget = WebContents | WebFrameMain @@ -865,6 +888,7 @@ export function browserToolWatchdogMs( tool === 'browser_open_url' || tool === 'browser_go_back' || tool === 'browser_go_forward' || + tool === 'browser_reload' || tool === 'browser_open_tab' || tool === 'browser_switch_tab' ) { @@ -889,6 +913,67 @@ function requireNum(params: Record, key: string): number { return value } +function browserElementStateMatches( + targetState: Record, + requestedState: BrowserWaitElementState +): boolean { + const present = targetState.present === true + const rendered = targetState.rendered === true + const checked = + targetState.checked === 'mixed' || targetState.ariaChecked === 'mixed' + ? undefined + : typeof targetState.checked === 'boolean' + ? targetState.checked + : targetState.ariaChecked === 'true' || targetState.ariaPressed === 'true' + ? true + : targetState.ariaChecked === 'false' || targetState.ariaPressed === 'false' + ? false + : undefined + const expanded = + typeof targetState.open === 'boolean' + ? targetState.open + : targetState.ariaExpanded === 'true' + ? true + : targetState.ariaExpanded === 'false' + ? false + : undefined + const selected = + typeof targetState.selected === 'boolean' + ? targetState.selected + : targetState.ariaSelected === 'true' + ? true + : targetState.ariaSelected === 'false' + ? false + : undefined + + switch (requestedState) { + case 'attached': + return present + case 'detached': + return !present + case 'visible': + return present && rendered + case 'hidden': + return !present || !rendered || targetState.hidden === true + case 'enabled': + return present && targetState.disabled !== true + case 'disabled': + return present && targetState.disabled === true + case 'checked': + return present && checked === true + case 'unchecked': + return present && checked === false + case 'expanded': + return present && expanded === true + case 'collapsed': + return present && expanded === false + case 'selected': + return present && selected === true + case 'unselected': + return present && selected === false + } +} + /** * Serializes a self-contained page function with JSON-encoded arguments. * WebContents runs it in a persistent isolated world so page scripts cannot @@ -1111,6 +1196,26 @@ function unwrapPageResult(result: unknown): unknown { if (code === 'not-select') { throw new ToolError('That element is not a + + + +
+
Activity
+ +
+ ` + for (const element of document.body.children) visible(element as HTMLElement) + document.querySelector('[aria-label="Partial selection"]')!.indeterminate = + true + const outline = outlineOf(collectSnapshot()) + + expect(outline).toMatch(/checkbox "Email alerts" \[ref=\d+\] unchecked/) + expect(outline).toMatch(/radio "Weekly" \[ref=\d+\] checked/) + expect(outline).toMatch(/checkbox "Partial selection" \[ref=\d+\] mixed/) + expect(outline).toMatch(/button "Filters" \[ref=\d+\] aria-expanded=false aria-pressed=true/) + expect(outline).toMatch(/switch "Dark mode" \[ref=\d+\] aria-checked=mixed/) + expect(outline).toMatch(/tab "Activity" \[ref=\d+\] aria-selected=true/) + expect(outline).toMatch(/textbox "Notes" \[ref=\d+\] readonly required/) + expect(outline).toMatch(/textbox "Summary" \[ref=\d+\] aria-readonly aria-required/) + }) + it('does not duplicate every descendant of an inherited pointer target', () => { document.body.innerHTML = `
@@ -923,6 +953,305 @@ describe('collectSnapshot', () => { }) }) +describe('semantic control state', () => { + it('scopes a fresh snapshot to one card without reading sibling geometry', () => { + document.body.innerHTML = + '
' + for (const element of document.querySelectorAll('*')) visible(element) + const full = outlineOf(collectSnapshot()) + const oldRootRef = refFor(full, 'Selected card') + const outside = document.querySelector('body > button')! + const outsideGeometry = vi.spyOn(outside, 'getBoundingClientRect') + + const scoped = runSerialized(collectSnapshot, [100, oldRootRef]) as { + scoped: boolean + outline: string + refIds: number[] + } + expect(scoped.scoped).toBe(true) + expect(scoped.outline).toContain('Selected card') + expect(scoped.outline).toContain('Save card') + expect(scoped.outline).not.toContain('Outside card') + expect(scoped.outline).not.toContain('private') + expect(scoped.refIds.every((id) => id >= 100)).toBe(true) + expect(window.__simAgentResolveElement?.(oldRootRef)).toBeNull() + expect(outsideGeometry).not.toHaveBeenCalled() + }) + + it('rejects stale or framed snapshot roots without replacing the page registry', () => { + const button = document.createElement('button') + document.body.append(visible(button)) + register(button) + button.remove() + expect(collectSnapshot(10, 0)).toMatchObject({ error: 'stale' }) + expect(window.__simAgentElements?.[0]).toBe(button) + + const frame = document.createElement('iframe') + document.body.append(frame) + const child = frame.contentDocument!.createElement('button') + frame.contentDocument!.body.append(visible(child)) + register(child) + expect(collectSnapshot(10, 0)).toEqual({ error: 'framed-snapshot' }) + }) + + it.each(['detached', 'hidden', 'renamed'])( + 'rejects a %s root before scoped capture without adopting a lookalike', + (change) => { + document.body.innerHTML = + '
' + for (const element of document.querySelectorAll('*')) visible(element) + const root = document.querySelector('#card')! + const rootRef = refFor(outlineOf(collectSnapshot()), 'Selected card') + const replacement = root.cloneNode(true) as HTMLElement + for (const element of [replacement, ...replacement.querySelectorAll('*')]) visible(element) + if (change === 'detached') root.replaceWith(replacement) + else { + root.after(replacement) + if (change === 'hidden') root.setAttribute('hidden', '') + else root.setAttribute('aria-label', 'Different card') + } + + expect(runSerialized(collectSnapshot, [100, rootRef])).toMatchObject({ error: 'stale' }) + expect(window.__simAgentElements?.[rootRef]).toBe(root) + expect(runSerialized(collectSnapshot, [100, rootRef])).toMatchObject({ error: 'stale' }) + } + ) + + it('marks unreadable scoped frame content truncated', () => { + const root = visible(document.createElement('div')) + const frame = visible(document.createElement('iframe')) + root.append(frame) + document.body.append(root) + register(root) + Object.defineProperty(frame, 'contentDocument', { value: null }) + expect(collectSnapshot(10, 0)).toMatchObject({ scoped: true, truncated: true }) + }) + + it('does not recover scoped refs into a different card after the original closes', () => { + document.body.innerHTML = + '
' + for (const element of document.querySelectorAll('*')) visible(element) + const root = document.querySelector('body > div')! + const rootRef = refFor(outlineOf(collectSnapshot()), 'Selected card') + const saveRef = refFor(outlineOf(collectSnapshot(10, rootRef)), 'Save card') + const replacement = root.cloneNode(true) as HTMLElement + for (const element of [replacement, ...replacement.querySelectorAll('*')]) visible(element) + root.replaceWith(replacement) + + expect(window.__simAgentResolveElement?.(saveRef)).toBeNull() + expect(window.__simAgentStaleReason).toContain('scoped snapshot root') + }) + + it('keeps scoped snapshots bounded when a selected container is very large', () => { + const root = document.createElement('div') + root.tabIndex = 0 + root.setAttribute('aria-label', 'Large card') + document.body.append(visible(root)) + register(root) + for (let index = 0; index < 400; index++) { + const button = visible(document.createElement('button')) + button.textContent = `Action ${index}` + root.append(button) + } + const scoped = collectSnapshot(10, 0) as { refIds: number[]; truncated: boolean } + expect(scoped.refIds).toHaveLength(300) + expect(scoped.truncated).toBe(true) + }) + + it('distinguishes hidden registered nodes from detached nodes without action recovery', () => { + const button = visible(document.createElement('button')) + document.body.append(button) + register(button) + window.__simAgentResolveElement = vi.fn(() => null) + button.style.display = 'none' + + expect(readPageActionState(false, 0, 'registered')).toMatchObject({ + targetState: { present: true, rendered: false }, + }) + button.remove() + expect(readPageActionState(false, 0, 'registered')).toMatchObject({ + targetState: { present: false, rendered: false }, + }) + expect(window.__simAgentResolveElement).not.toHaveBeenCalled() + }) + + it('does not report a text input as an unchecked control', () => { + const input = visible(document.createElement('input')) + document.body.append(input) + register(input) + + expect(readPageActionState(false, 0, 'registered')).toMatchObject({ + targetState: { present: true, checked: undefined }, + }) + expect(readPageActionState(false, 1, 'registered')).toEqual({ error: 'stale' }) + }) + + it.each([true, false])( + 'reports native disclosure state as %s without inventing it on other elements', + (open) => { + document.body.innerHTML = + '
Details
' + const details = document.querySelector('details')! + const dialog = document.querySelector('dialog')! + details.open = open + dialog.open = open + const elements = [ + details, + document.querySelector('summary')!, + dialog, + document.querySelector('button')!, + ] + register(...elements.map(visible)) + for (let id = 0; id < elements.length; id++) { + expect(runSerialized(readPageActionState, [false, id, 'registered'])).toMatchObject({ + targetState: { open: id === 3 ? undefined : open }, + }) + } + } + ) + + it.each(['checkbox', 'radio'])('reads native %s state with XHTML tag casing', (type) => { + const input = visible(document.createElement('input')) + input.type = type + input.checked = true + Object.defineProperty(input, 'tagName', { value: 'input' }) + document.body.append(input) + register(input) + + expect(runSerialized(readPageActionState, [false, 0, 'registered'])).toMatchObject({ + targetState: { checked: true }, + }) + }) + + it('preserves native and ARIA mixed states instead of reporting unchecked', () => { + document.body.innerHTML = + '
' + const checkbox = visible(document.querySelector('input') as HTMLInputElement) + checkbox.indeterminate = true + register(checkbox, visible(document.querySelector('div') as HTMLDivElement)) + + expect(readCheckableElementState(0)).toMatchObject({ checked: 'mixed' }) + expect(readCheckableElementState(1)).toMatchObject({ checked: 'mixed' }) + }) + + it('honors disabled fieldsets and ARIA-disabled ancestors', () => { + document.body.innerHTML = + '
' + register( + visible(document.querySelector('input') as HTMLInputElement), + visible(document.querySelector('button') as HTMLButtonElement) + ) + + expect(readCheckableElementState(0)).toMatchObject({ disabled: true }) + expect(readCheckableElementState(1)).toMatchObject({ disabled: true }) + }) + + it.each([true, false])( + 'reads ARIA-disabled=%s across shadow boundaries for waits and checked state', + (disabled) => { + const host = visible(document.createElement('div')) + host.setAttribute('aria-disabled', String(disabled)) + const nestedHost = visible(document.createElement('div')) + host.attachShadow({ mode: 'open' }).append(nestedHost) + const checkbox = visible(document.createElement('input')) + checkbox.type = 'checkbox' + nestedHost.attachShadow({ mode: 'open' }).append(checkbox) + document.body.append(host) + register(checkbox) + + expect(runSerialized(readCheckableElementState, [0])).toMatchObject({ disabled }) + expect(runSerialized(readPageActionState, [false, 0, 'registered'])).toMatchObject({ + targetState: { disabled }, + }) + } + ) + + it('reads native and ARIA checkable controls without mutating them', () => { + document.body.innerHTML = ` + + + ` + const checkbox = visible(document.querySelector('input') as HTMLInputElement) + const toggle = visible(document.querySelector('button') as HTMLButtonElement) + register(checkbox, toggle) + + expect(readCheckableElementState(0)).toMatchObject({ + checked: true, + disabled: false, + kind: 'input:checkbox', + }) + expect(readCheckableElementState(1)).toMatchObject({ + checked: false, + disabled: true, + kind: 'role:switch', + }) + expect(checkbox.checked).toBe(true) + }) + + it('returns a viewport-clamped element screenshot rectangle', () => { + const button = visible(document.createElement('button')) + button.scrollIntoView = vi.fn() + button.getBoundingClientRect = () => + ({ + x: -10, + y: 5, + width: 120, + height: 20, + top: 5, + left: -10, + right: 110, + bottom: 25, + }) as DOMRect + document.body.append(button) + register(button) + + expect(getElementScreenshotRect(0)).toMatchObject({ + x: 0, + y: 5, + width: 110, + height: 20, + element: 'button', + }) + expect(button.scrollIntoView).not.toHaveBeenCalled() + }) + + it('rejects a replacement screenshot target even when its geometry matches', () => { + const button = visible(document.createElement('button')) + button.id = 'save' + button.textContent = 'Save' + document.body.append(button) + const ref = refFor(outlineOf(collectSnapshot()), 'Save') + expect(getElementScreenshotRect(ref)).toMatchObject({ width: 100, height: 20 }) + button.replaceWith(visible(button.cloneNode(true) as HTMLElement)) + + expect(runSerialized(getElementScreenshotRect, [ref])).toMatchObject({ error: 'stale' }) + expect(window.__simAgentElements?.[ref]).toBe(button) + }) + + it('rejects same-origin frame crops rather than using frame-local coordinates', () => { + const frame = document.createElement('iframe') + document.body.append(frame) + const button = frame.contentDocument!.createElement('button') + frame.contentDocument!.body.append(button) + register(visible(button)) + + expect(getElementScreenshotRect(0)).toEqual({ error: 'framed-screenshot' }) + expect(readPageActionState(false, 0, 'registered')).toEqual({ error: 'framed-wait' }) + }) + + it('does not scroll an offscreen element into view for a screenshot', () => { + const button = visible(document.createElement('button')) + button.scrollIntoView = vi.fn() + document.body.append(button) + button.getBoundingClientRect = () => + ({ left: 0, right: 20, top: 5000, bottom: 5020, width: 20, height: 20 }) as DOMRect + register(button) + + expect(getElementScreenshotRect(0)).toEqual({ error: 'not-visible' }) + expect(button.scrollIntoView).not.toHaveBeenCalled() + }) +}) + describe('scrollPage', () => { function makeScroller(scrollTop: number): { scroller: HTMLDivElement diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index e9924381d9c..2bcd8fc3198 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -29,7 +29,10 @@ declare global { interface Window { __simAgentElements?: Element[] - __simAgentResolveElement?: (id: number) => { element: Element; recovered: boolean } | null + __simAgentResolveElement?: ( + id: number, + allowRecovery?: boolean + ) => { element: Element; recovered: boolean } | null __simAgentMutationStates?: Array<{ root: Node observer: MutationObserver @@ -50,7 +53,18 @@ declare global { * interactive elements carrying numeric ids, walking open shadow roots and * same-origin iframes. Rebuilds the element registry as a side effect. */ -export function collectSnapshot(startingElementId = 0): unknown { +export function collectSnapshot(startingElementId = 0, elementId?: number): unknown { + const resolver = window.__simAgentResolveElement + const scopedRoot = + elementId === undefined + ? undefined + : resolver + ? resolver(elementId, false)?.element + : window.__simAgentElements?.[elementId] + if (elementId !== undefined) { + if (!scopedRoot?.isConnected) return { error: 'stale', reason: window.__simAgentStaleReason } + if (scopedRoot.ownerDocument !== document) return { error: 'framed-snapshot' } + } const refCap = 300 const lineCap = 600 const nodeCap = 12_000 @@ -448,10 +462,13 @@ export function collectSnapshot(startingElementId = 0): unknown { // like any other. Redaction above is realm-safe and runs first, so // widening this cannot expose a credential field. const value = (el as HTMLInputElement).value - if (tag === 'INPUT' && (el as HTMLInputElement).type === 'file') { + const inputType = tag === 'INPUT' ? (el as HTMLInputElement).type : '' + if (inputType === 'file') { parts.push('upload-unsupported') - } else if (value && isSensitiveValueField(el)) parts.push('value-withheld') - else if (value) parts.push(`value=${quote(cut(String(value), 120))}`) + } else if (inputType !== 'checkbox' && inputType !== 'radio') { + if (value && isSensitiveValueField(el)) parts.push('value-withheld') + else if (value) parts.push(`value=${quote(cut(String(value), 120))}`) + } } if (tag === 'A') { const href = el.getAttribute('href') @@ -459,7 +476,28 @@ export function collectSnapshot(startingElementId = 0): unknown { } if ((el as HTMLInputElement).disabled === true) parts.push('disabled') if (el.getAttribute('aria-disabled') === 'true') parts.push('aria-disabled') - if ((el as HTMLInputElement).checked === true) parts.push('checked') + if (el.getAttribute('aria-readonly') === 'true') parts.push('aria-readonly') + if (el.getAttribute('aria-required') === 'true') parts.push('aria-required') + if (tag === 'INPUT') { + const input = el as HTMLInputElement + if (input.type === 'checkbox' || input.type === 'radio') { + parts.push(input.indeterminate ? 'mixed' : input.checked ? 'checked' : 'unchecked') + } + if (input.readOnly) parts.push('readonly') + if (input.required) parts.push('required') + } else if (tag === 'TEXTAREA') { + 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') + } + for (const attribute of ['aria-checked', 'aria-expanded', 'aria-pressed', 'aria-selected']) { + const value = el.getAttribute(attribute) + if (value === 'true' || value === 'false' || value === 'mixed') { + parts.push(`${attribute}=${value}`) + } + } const suffix = parts.length > 0 ? ` ${parts.join(' ')}` : '' const lineIndex = lines.length if (push(`${indent}- ${role} ${quote(name)} [ref=${id}]${suffix}`)) { @@ -524,12 +562,12 @@ export function collectSnapshot(startingElementId = 0): unknown { ) } - const walk = (root: ParentNode, depth: number, suppressTextCoveredBy = ''): void => { + const walk = (elements: Iterable, depth: number, suppressTextCoveredBy = ''): void => { if (refCount >= refCap || depth > depthCap) { truncated = true return } - for (const el of Array.from(root.children)) { + for (const el of elements) { visitedNodes++ if (refCount >= refCap || visitedNodes > nodeCap) { truncated = true @@ -582,21 +620,24 @@ export function collectSnapshot(startingElementId = 0): unknown { const innerDoc = (el as HTMLIFrameElement).contentDocument if (innerDoc?.body && isVisible(el)) { if (!push(`${indent}- iframe:`)) return - walk(innerDoc.body, childDepth + 1, coveredText) + walk(innerDoc.body.children, childDepth + 1, coveredText) + } else if (scopedRoot && !innerDoc && visible) { + truncated = true } } catch { - // Cross-origin iframe — not readable. + if (scopedRoot && visible) truncated = true } continue } const shadow = (el as HTMLElement).shadowRoot - if (shadow) walk(shadow, childDepth, coveredText) - walk(el, childDepth, coveredText) + if (shadow) walk(shadow.children, childDepth, coveredText) + walk(el.children, childDepth, coveredText) } } - if (document.body) walk(document.body, 0) + if (scopedRoot) walk([scopedRoot], 0) + else if (document.body) walk(document.body.children, 0) /** * React commonly replaces a control's DOM node while preserving its @@ -604,7 +645,11 @@ export function collectSnapshot(startingElementId = 0): unknown { * fingerprint still identify one candidate; a weak or ambiguous match is a * real stale ref, never permission to click something nearby. */ - window.__simAgentResolveElement = (id: number) => { + window.__simAgentResolveElement = (id: number, allowRecovery = true) => { + if (scopedRoot && !scopedRoot.isConnected) { + window.__simAgentStaleReason = 'the scoped snapshot root left the DOM' + return null + } const locator = locators[id] if (!locator) { window.__simAgentStaleReason = `id ${id} is not in the current snapshot's registry` @@ -737,6 +782,11 @@ export function collectSnapshot(startingElementId = 0): unknown { } } + if (!allowRecovery) { + window.__simAgentStaleReason = 'the original snapshot node is detached or hidden' + return null + } + // Past this point the original node is gone or hidden, so anything returned // is a DIFFERENT node adopted by structural resemblance. Identity matching // compares origins only — deliberately, so a pushState between snapshot and @@ -767,7 +817,7 @@ export function collectSnapshot(startingElementId = 0): unknown { let candidateCount = 0 const collect = (root: ParentNode, depth = 0): void => { if (depth > depthCap || candidateCount >= nodeCap) return - for (const element of Array.from(root.children)) { + for (const element of root.children) { candidateCount++ if (candidateCount > nodeCap) return reachable.push(element) @@ -785,7 +835,8 @@ export function collectSnapshot(startingElementId = 0): unknown { collect(element, depth + 1) } } - if (document.body) collect(document.body) + if (scopedRoot) collect(scopedRoot) + else if (document.body) collect(document.body) const scored = reachable .filter((candidate) => identityMatches(candidate) && isCurrentlyVisible(candidate)) @@ -862,6 +913,7 @@ export function collectSnapshot(startingElementId = 0): unknown { url: cut(window.location.href, 4096), title: cut(document.title, 500), outline: lines.join('\n'), + ...(scopedRoot ? { scoped: true } : {}), truncated, scrollY: Math.round(window.scrollY), pageHeight: Math.round(document.documentElement.scrollHeight), @@ -2041,14 +2093,24 @@ export function pressKeyOnPage( * Captures non-sensitive page state around a trusted input event. The driver * compares two readings so “the event was dispatched” is never confused with * “the page visibly reacted.” + * Registered-node waits return only target state, without action ref recovery + * or the broader page-effect scan. */ -export function readPageActionState(resetMutationRevision = false, elementId?: number): unknown { +export function readPageActionState( + resetMutationRevision = false, + elementId?: number, + targetResolution: 'actionable' | 'registered' = 'actionable' +): unknown { const registeredElement = typeof elementId === 'number' ? (window.__simAgentElements || [])[elementId] : undefined const resolver = window.__simAgentResolveElement + if (targetResolution === 'registered') { + if (!registeredElement) return { error: 'stale' } + if (registeredElement.ownerDocument !== document) return { error: 'framed-wait' } + } const resolved = typeof elementId === 'number' - ? resolver + ? resolver && targetResolution === 'actionable' ? resolver(elementId) : registeredElement?.isConnected ? { element: registeredElement, recovered: false } @@ -2058,6 +2120,89 @@ export function readPageActionState(resetMutationRevision = false, elementId?: n const observedDocument = observedElement?.ownerDocument ?? registeredElement?.ownerDocument ?? document const observedWindow = observedDocument.defaultView ?? window + const isEffectivelyRendered = (element: Element): boolean => { + const rect = element.getBoundingClientRect() + const view = element.ownerDocument.defaultView + if ( + !view || + rect.width <= 1 || + rect.height <= 1 || + rect.right <= 0 || + rect.bottom <= 0 || + rect.left >= view.innerWidth || + rect.top >= view.innerHeight + ) { + return false + } + for (let current: Element | null = element; current; ) { + const currentView: Window | null = current.ownerDocument.defaultView + const style = currentView?.getComputedStyle(current) + const opacity = Number.parseFloat(style?.opacity || '1') + if ( + !style || + style.display === 'none' || + style.visibility === 'hidden' || + style.contentVisibility === 'hidden' || + (Number.isFinite(opacity) && opacity <= 0.01) || + current.hasAttribute('hidden') || + current.getAttribute('aria-hidden') === 'true' + ) { + return false + } + if (current.parentElement) current = current.parentElement + else { + const root = current.getRootNode() + current = 'host' in root ? (root.host as Element) : null + } + } + return true + } + + let disabled = observedElement?.matches(':disabled') === true + for (let ancestor = observedElement; ancestor && !disabled; ) { + disabled = ancestor.getAttribute('aria-disabled') === 'true' + const root = ancestor.getRootNode() + ancestor = ancestor.parentElement ?? ('host' in root ? (root.host as Element) : null) + } + const observedTag = observedElement?.tagName.toUpperCase() + const disclosure = + observedTag === 'DETAILS' || observedTag === 'DIALOG' + ? observedElement + : observedTag === 'SUMMARY' && + observedElement?.parentElement?.tagName.toUpperCase() === 'DETAILS' + ? observedElement.parentElement + : undefined + const targetState = + typeof elementId !== 'number' + ? undefined + : observedElement + ? { + present: true, + rendered: isEffectivelyRendered(observedElement), + ariaExpanded: observedElement.getAttribute('aria-expanded'), + ariaSelected: observedElement.getAttribute('aria-selected'), + ariaPressed: observedElement.getAttribute('aria-pressed'), + ariaChecked: observedElement.getAttribute('aria-checked'), + checked: + observedTag !== 'INPUT' || + !['checkbox', 'radio'].includes((observedElement as HTMLInputElement).type) + ? undefined + : (observedElement as HTMLInputElement).indeterminate === true + ? 'mixed' + : Boolean((observedElement as HTMLInputElement).checked), + disabled, + selected: + 'selected' in observedElement + ? Boolean((observedElement as HTMLOptionElement).selected) + : undefined, + open: disclosure?.hasAttribute('open'), + hidden: + observedElement.hasAttribute('hidden') || + observedElement.getAttribute('aria-hidden') === 'true', + } + : { present: false, rendered: false } + if (targetResolution === 'registered') return { targetState } + const observationRoot = observedDocument.body const roots: ParentNode[] = observationRoot ? [observationRoot] : [] @@ -2242,70 +2387,6 @@ export function readPageActionState(resetMutationRevision = false, elementId?: n .slice(0, 30) .map((element) => `${element.tagName}:${Math.round((element as HTMLElement).scrollTop)}`) - const isEffectivelyRendered = (element: Element): boolean => { - const rect = element.getBoundingClientRect() - const view = element.ownerDocument.defaultView - if ( - !view || - rect.width <= 1 || - rect.height <= 1 || - rect.right <= 0 || - rect.bottom <= 0 || - rect.left >= view.innerWidth || - rect.top >= view.innerHeight - ) { - return false - } - for (let current: Element | null = element; current; ) { - const currentView: Window | null = current.ownerDocument.defaultView - const style = currentView?.getComputedStyle(current) - const opacity = Number.parseFloat(style?.opacity || '1') - if ( - !style || - style.display === 'none' || - style.visibility === 'hidden' || - style.contentVisibility === 'hidden' || - (Number.isFinite(opacity) && opacity <= 0.01) || - current.hasAttribute('hidden') || - current.getAttribute('aria-hidden') === 'true' - ) { - return false - } - if (current.parentElement) current = current.parentElement - else { - const root = current.getRootNode() - current = 'host' in root ? (root.host as Element) : null - } - } - return true - } - - const targetState = - typeof elementId !== 'number' - ? undefined - : observedElement - ? { - present: true, - rendered: isEffectivelyRendered(observedElement), - ariaExpanded: observedElement.getAttribute('aria-expanded'), - ariaSelected: observedElement.getAttribute('aria-selected'), - ariaPressed: observedElement.getAttribute('aria-pressed'), - ariaChecked: observedElement.getAttribute('aria-checked'), - checked: - 'checked' in observedElement - ? Boolean((observedElement as HTMLInputElement).checked) - : undefined, - selected: - 'selected' in observedElement - ? Boolean((observedElement as HTMLOptionElement).selected) - : undefined, - open: observedElement.hasAttribute('open'), - hidden: - observedElement.hasAttribute('hidden') || - observedElement.getAttribute('aria-hidden') === 'true', - } - : { present: false, rendered: false } - return { url: observedWindow.location.href.slice(0, 4096), title: observedDocument.title.slice(0, 500), @@ -2591,6 +2672,102 @@ export function readSelectElementState(id: number): unknown { } } +export function readCheckableElementState(id: number): unknown { + const resolver = window.__simAgentResolveElement + const resolved = resolver?.(id) + const registered = resolver ? resolved?.element : (window.__simAgentElements || [])[id] + const candidate = + String(registered?.tagName || '').toUpperCase() === 'LABEL' + ? (registered as HTMLLabelElement).control + : registered + if (!candidate || !candidate.isConnected) { + return { error: 'stale', reason: window.__simAgentStaleReason } + } + + const tag = String(candidate.tagName || '').toUpperCase() + const type = + tag === 'INPUT' ? String((candidate as HTMLInputElement).type || '').toLowerCase() : '' + const role = String(candidate.getAttribute('role') || '').toLowerCase() + const isNative = tag === 'INPUT' && (type === 'checkbox' || type === 'radio') + const isAria = ['checkbox', 'radio', 'switch', 'menuitemcheckbox', 'menuitemradio'].includes(role) + if (!isNative && !isAria) return { error: 'not-checkable' } + + const ariaChecked = candidate.getAttribute('aria-checked') + const checked = isNative + ? (candidate as HTMLInputElement).indeterminate + ? 'mixed' + : Boolean((candidate as HTMLInputElement).checked) + : ariaChecked === 'true' + ? true + : ariaChecked === 'false' + ? false + : ariaChecked + let disabled = candidate.matches(':disabled') + for (let ancestor: Element | null = candidate; ancestor && !disabled; ) { + disabled = ancestor.getAttribute('aria-disabled') === 'true' + const root = ancestor.getRootNode() + ancestor = ancestor.parentElement ?? ('host' in root ? (root.host as Element) : null) + } + return { + checked, + disabled, + readOnly: + (candidate as Element & { readOnly?: boolean }).readOnly === true || + candidate.getAttribute('aria-readonly') === 'true', + kind: isNative ? `input:${type}` : `role:${role}`, + refRecovered: resolved?.recovered === true, + } +} + +export function getElementScreenshotRect(id: number): unknown { + const resolver = window.__simAgentResolveElement + const resolved = resolver?.(id, false) + const element = resolver ? resolved?.element : (window.__simAgentElements || [])[id] + if (!element || !element.isConnected) { + return { error: 'stale', reason: window.__simAgentStaleReason } + } + + if (element.ownerDocument !== document) return { error: 'framed-screenshot' } + const rect = element.getBoundingClientRect() + const view = element.ownerDocument.defaultView + if (!view) return { error: 'stale', reason: window.__simAgentStaleReason } + for (let current: Element | null = element; current; ) { + const currentView: Window | null = current.ownerDocument.defaultView + const style = currentView?.getComputedStyle(current) + const opacity = Number.parseFloat(style?.opacity || '1') + if ( + !style || + style.display === 'none' || + style.visibility === 'hidden' || + style.contentVisibility === 'hidden' || + (Number.isFinite(opacity) && opacity <= 0.01) || + current.hasAttribute('hidden') || + current.getAttribute('aria-hidden') === 'true' + ) { + return { error: 'not-visible' } + } + if (current.parentElement) current = current.parentElement + else { + const root = current.getRootNode() + current = 'host' in root ? (root.host as Element) : null + } + } + + const left = Math.max(0, rect.left) + const top = Math.max(0, rect.top) + const right = Math.min(view.innerWidth, rect.right) + const bottom = Math.min(view.innerHeight, rect.bottom) + if (right - left <= 1 || bottom - top <= 1) return { error: 'not-visible' } + return { + x: left, + y: top, + width: right - left, + height: bottom - top, + element: element.tagName.toLowerCase().slice(0, 80), + refRecovered: resolved?.recovered === true, + } +} + export function hoverElement(id: number): unknown { const resolver = window.__simAgentResolveElement const resolved = resolver?.(id) diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 2f81cc51f8d..b7f62e74266 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -4661,12 +4661,14 @@ describe('reopening a closed tab', () => { }) describe('importAgentCookies', () => { - /** Points the mocked partition at a cookie jar and returns its `set` spy. */ - function withCookieJar(set: ReturnType): SessionModule { + function withCookieJar( + set: ReturnType, + flushStore = vi.fn(async () => {}) + ): SessionModule { // The partition is resolved per call, not captured at module load, so // re-mocking it here is enough — no module reload required. vi.mocked(electronSession.fromPartition).mockReturnValue({ - cookies: { set }, + cookies: { set, flushStore }, } as unknown as ReturnType) return sessionModule } @@ -4683,7 +4685,8 @@ describe('importAgentCookies', () => { it('writes every cookie into the dedicated browser profile', async () => { const set = vi.fn(async () => {}) - const session = withCookieJar(set) + const flushStore = vi.fn(async () => {}) + const session = withCookieJar(set, flushStore) const result = await session.importAgentCookies([cookie('a'), cookie('b')]) @@ -4691,6 +4694,8 @@ describe('importAgentCookies', () => { expect(electronSession.fromPartition).toHaveBeenCalledWith('persist:sim-browser-agent') expect(set).toHaveBeenCalledTimes(2) expect(set).toHaveBeenNthCalledWith(1, cookie('a')) + expect(flushStore).toHaveBeenCalledOnce() + expect(flushStore.mock.invocationCallOrder[0]).toBeGreaterThan(set.mock.invocationCallOrder[1]) }) it('counts a rejected cookie without losing the rest', async () => { @@ -4709,9 +4714,22 @@ describe('importAgentCookies', () => { it('does nothing when there is nothing to import', async () => { const set = vi.fn(async () => {}) - const session = withCookieJar(set) + const flushStore = vi.fn(async () => {}) + const session = withCookieJar(set, flushStore) await expect(session.importAgentCookies([])).resolves.toEqual({ imported: 0, failed: 0 }) expect(set).not.toHaveBeenCalled() + expect(flushStore).not.toHaveBeenCalled() + }) + + it('does not report a durable import when flushing to disk fails', async () => { + const session = withCookieJar( + vi.fn(async () => {}), + vi.fn(async () => { + throw new Error('Disk unavailable') + }) + ) + + await expect(session.importAgentCookies([cookie('a')])).rejects.toThrow('Disk unavailable') }) }) diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 9bedd83f938..2d3807af204 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -1196,6 +1196,7 @@ export async function importAgentCookies( failed += 1 } } + if (imported > 0) await jar.flushStore() return { imported, failed } } diff --git a/apps/desktop/src/main/browser-credentials/vault.test.ts b/apps/desktop/src/main/browser-credentials/vault.test.ts index 3fc1d958544..0a22efa919a 100644 --- a/apps/desktop/src/main/browser-credentials/vault.test.ts +++ b/apps/desktop/src/main/browser-credentials/vault.test.ts @@ -39,6 +39,25 @@ const CANDIDATES = [ ] describe('CredentialVault', () => { + it('retains imported logins when the vault is reopened and does not duplicate a re-import', async () => { + const original = new CredentialVault(vaultPath, encryption()) + await original.importCredentials(CANDIDATES, 'replace') + const metadata = await original.list() + + const reopened = new CredentialVault(vaultPath, encryption()) + expect(await reopened.list()).toEqual(metadata) + expect(await reopened.readForFill(metadata[0].id, metadata[0].origin)).toEqual({ + username: 'ada', + password: 'hunter2', + }) + expect(await reopened.importCredentials(CANDIDATES, 'replace')).toEqual({ + added: 0, + updated: 0, + skipped: 2, + }) + expect(await reopened.list()).toEqual(metadata) + }) + it('stores and lists credentials without their passwords', async () => { const vault = new CredentialVault(vaultPath, encryption()) diff --git a/apps/desktop/src/main/browser-import/import-service.test.ts b/apps/desktop/src/main/browser-import/import-service.test.ts index f1c9567c1a7..75d04ce9722 100644 --- a/apps/desktop/src/main/browser-import/import-service.test.ts +++ b/apps/desktop/src/main/browser-import/import-service.test.ts @@ -616,26 +616,33 @@ describe('importChromePasswords', () => { expect(importCredentials).toHaveBeenCalledWith([expect.any(Object)], 'replace') }) - it('keeps credentials from one password store when the other is unreadable', async () => { - const localPath = '/arc/Default/Login Data' - const accountPath = '/arc/Default/Login Data For Account' - const deps = createDeps({ - listProfiles: async () => [ - { ...PROFILES[1], id: 'arc:Default', loginDataPaths: [localPath, accountPath] }, - ], - readPasswords: async (path) => { - if (path === accountPath) { - throw new ImportFailure('unsupported-schema', 'unknown account-store schema') - } - return readPasswords() - }, - }) + it.each(['local', 'account'])( + 'reports a partial import when the %s password store is unreadable', + async (failedStore) => { + const localPath = '/arc/Default/Login Data' + const accountPath = '/arc/Default/Login Data For Account' + const deps = createDeps({ + listProfiles: async () => [ + { ...PROFILES[1], id: 'arc:Default', loginDataPaths: [localPath, accountPath] }, + ], + readPasswords: async (path) => { + if (path === (failedStore === 'local' ? localPath : accountPath)) { + throw new ImportFailure('unsupported-schema', 'unknown account-store schema') + } + return readPasswords() + }, + }) - await expect(importChromePasswords('arc:Default', 'replace', deps)).resolves.toMatchObject({ - passwordsAdded: 1, - passwordsSkipped: 0, - }) - }) + await expect(importChromeData('arc:Default', 'replace', deps)).resolves.toMatchObject({ + cookies: { cookiesImported: 1 }, + passwords: { + passwordsAdded: 1, + passwordsSkipped: 0, + error: 'unsupported-schema', + }, + }) + } + ) it('surfaces a failed password store when the other store only has unreadable rows', async () => { const localPath = '/arc/Default/Login Data' diff --git a/apps/desktop/src/main/browser-import/import-service.ts b/apps/desktop/src/main/browser-import/import-service.ts index 11c943efa12..8c704361d16 100644 --- a/apps/desktop/src/main/browser-import/import-service.ts +++ b/apps/desktop/src/main/browser-import/import-service.ts @@ -302,6 +302,7 @@ async function runPasswordImport( passwordsAdded: outcome.added, passwordsUpdated: outcome.updated, passwordsSkipped: outcome.skipped + read.skipped, + ...(read.error ? { error: read.error } : {}), } logger.info('Chrome password import finished', { added: result.passwordsAdded, @@ -321,6 +322,7 @@ async function runPasswordImport( * source order breaks ties deterministically before applying the vault policy. * * One damaged store does not discard credentials already read from the other. + * A partial read carries its failure to the UI alongside the imported counts. * If no store can produce any useful signal, the first concrete reader error * is surfaced instead of reporting a misleading successful import of zero. */ @@ -328,7 +330,7 @@ async function readProfilePasswords( paths: readonly string[], key: Buffer, deps: ImportServiceDeps -): Promise { +): Promise { const combined: ReadPasswordsResult = { credentials: [], skipped: 0, rowsSeen: 0 } const credentialIndexes = new Map() let successfulReads = 0 @@ -379,6 +381,7 @@ async function readProfilePasswords( if (firstFailure !== undefined) { // Category only: database names and paths are deliberately absent. logger.warn('Could not read every password store in the selected browser profile') + return { ...combined, error: categorize(firstFailure, 'password') } } return combined } diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 23ddfd3d0ce..867a81af5c2 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2710,6 +2710,25 @@ export function BrexIcon(props: SVGProps) { ) } +/** + * Official QuickBooks circular mark, cropped from the user-supplied + * Intuit_QuickBooks_logo.svg wordmark. + */ +export function QuickBooksIcon(props: SVGProps) { + return ( + + + + + ) +} + export function BrightDataIcon(props: SVGProps) { return ( ) { ) } +export function SSMIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function SQSIcon(props: SVGProps) { return ( ) { ) } +export function CloudTrailIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function CloudWatchIcon(props: SVGProps) { return ( = { clickup: ClickUpIcon, cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, + cloudtrail: CloudTrailIcon, cloudwatch: CloudWatchIcon, codepipeline: CodePipelineIcon, confluence: ConfluenceIcon, @@ -500,6 +504,7 @@ export const blockTypeToIconMap: Record = { pulse_v2: PulseIcon, qdrant: QdrantIcon, quartr: QuartrIcon, + quickbooks: QuickBooksIcon, quiver: QuiverIcon, rabbitmq: RabbitmqIcon, railway: RailwayIcon, @@ -546,6 +551,7 @@ export const blockTypeToIconMap: Record = { sqs: SQSIcon, square: SquareIcon, ssh: SshIcon, + ssm: SSMIcon, stagehand: StagehandIcon, stripe: StripeIcon, sts: STSIcon, diff --git a/apps/docs/content/docs/desktop/index.mdx b/apps/docs/content/docs/desktop/index.mdx new file mode 100644 index 00000000000..05c219bb2e8 --- /dev/null +++ b/apps/docs/content/docs/desktop/index.mdx @@ -0,0 +1,155 @@ +--- +title: Sim Desktop +description: Install the macOS app — Sim in its own window, with a built-in browser, a terminal, and access to local folders. +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Sim Desktop is the macOS app for your Sim workspace. Everything the web app does, it does — plus the things a browser tab cannot: a built-in browser and terminal, read-only access to folders you pick, and native notifications when a task finishes. + + + +## Download + +**[Download Sim Desktop for macOS](https://sim.ai/api/desktop/update/download)** + +One universal build runs natively on both Apple Silicon and Intel Macs. It is signed and notarized by Sim, so Gatekeeper accepts it with no override. + + + That link is never version-pinned — it is a redirect resolved at request time, so it always lands on the newest release. Bookmark it, share it, or put it in an MDM policy; it stays correct. + + It resolves against sim.ai. Every deployment serves the same endpoint on its own origin, so if you self-host use yours — `https://sim.example.com/api/desktop/update/download` — to get the build your deployment offers. + + +To install a specific version instead of the newest one, pick it from [the releases page](https://github.com/simstudioai/sim/releases) and download that release's `Sim--universal.dmg`. + +## Install + + + + + +### Open the disk image and drag Sim to Applications + + + Install to `/Applications`. macOS App Translocation runs an app from a randomized read-only path when it is launched from Downloads, which silently breaks auto-updates. + + + + + + +### Sign in + +Launch Sim and sign in as you normally would. + +Google, Microsoft, and SSO sign-ins finish in your default browser — those providers refuse to render inside an embedded browser. Sim opens the page, you approve, and the browser hands the session back to the app. The app gets its own session, so signing out of one surface does not sign out the other. + + + + + +### Point it at your deployment, if you self-host + +Fresh installs open sim.ai. To use your own deployment, choose **Sim → Server…** in the menu bar and enter its URL. See [Desktop App on Your Deployment](/platform/self-hosting/desktop) for what changes when you switch. + + + + + +## What the desktop app adds + +- **A built-in browser.** A real browser inside the app, with its own tabs, saved passwords, and sessions. Chat can drive it — sign in once and your agents work on the sites you are already signed into. +- **A built-in terminal.** Real shell sessions in a panel next to Chat, with tmux and shell integration, that Chat can run commands in. +- **Local folder access.** When a task needs a folder on your Mac, Chat offers to open the native folder picker. The grant is read-only, scoped to the folder you picked, and revocable. +- **Notifications.** A native notification when a Chat task finishes. Clicking it opens that chat. Tasks that end in an error, or that have another message queued behind them, do not notify. +- **Control Center.** A menu-bar icon with your recent chats, so Sim is one click away from any app. +- **Launch at login.** Sim starts with your Mac, and however you launch it, it opens where you left off. + +Both the browser and the terminal are capabilities you grant, not defaults you are stuck with — each has a single switch in settings that turns it off entirely. + + + **What a folder grant does and does not do.** Granting a folder does not copy or upload it, and an agent cannot attach or stage a file from it — that stays your deliberate act. But when an agent reads or searches inside the grant, what it reads is a tool result, and tool results go to your Sim server and to the model like anything else in the conversation. The file stays on your Mac; what an agent reads out of it does not. + + +## Keyboard shortcuts + +These are the app's own shortcuts. The [workflow editor and table shortcuts](/keyboard-shortcuts) work the same in the app as in the browser. + +| Shortcut | Action | +|---|---| +| `Cmd` + `K` | Search | +| `Cmd` + `B` | Toggle the sidebar | +| `Cmd` + `N` | New chat | +| `Cmd` + `Shift` + `N` | New window | +| `Cmd` + `,` | Settings | +| `Cmd` + `[` | Back | +| `Cmd` + `R` | Reload | +| `Cmd` + `0` / `+` / `-` | Reset, increase, or decrease zoom | + +With the built-in browser or terminal focused, the tab shortcuts act on its tabs rather than on the window: + +| Shortcut | Action | +|---|---| +| `Cmd` + `T` | New tab | +| `Cmd` + `W` | Close tab | +| `Cmd` + `Shift` + `T` | Reopen the last closed tab | +| `Ctrl` + `Tab` / `Ctrl` + `Shift` + `Tab` | Next / previous tab | +| `Cmd` + `1`–`8` | Jump to that tab | +| `Cmd` + `9` | Jump to the last tab | +| `Cmd` + `L` | Focus the address bar (browser only) | +| `Cmd` + `F` | Find on the page (browser only) | + +## Settings + +The desktop app adds three sections under **Settings → Account**. They appear only when you are running the app, and they apply to this Mac rather than to your account. + +### Desktop + +- **Launch Sim at login** +- **Show Sim in Control Center** — the menu-bar icon +- **Automatically download updates** +- **Enable desktop notifications**, with **Play notification sounds** and **Notify only when Sim isn't focused** + +It also shows the installed version, and the version waiting to be applied when an update is ready. + +### Browser + +- **Let Chat browse the web** — the master switch for the built-in browser +- **Search suggestions**, **Theme**, **Default zoom**, and **Download location** +- **Browsing data** — clear cookies, site data, and cached images and files + +### Terminal + +- **Let Chat run commands** — the master switch for the built-in terminal +- **Theme** and **Default zoom** + +The menu bar carries the rest: **Sim → Settings…** (`Cmd` + `,`), **Server…** to change deployments, **Check for Updates…**, and **Sign Out**. + +## Updates + +Sim checks the deployment it is pointed at rather than a global feed. How it applies what it finds depends on how the app was installed. Nothing is ever forced mid-session either way. + +**Installed in `/Applications`, signed by Sim** — what the download link above gives you. The app replaces itself. With **Automatically download updates** on, it downloads in the background and offers to restart; choose **Later** and the update applies the next time you quit. With it off, nothing downloads until you ask: **Sim → Check for Updates…** reports the available version and waits for you to choose **Download**. + +**Anywhere else** — outside `/Applications`, or a build not signed with a Developer ID. The app cannot replace itself, so it offers you the installer to download and swap in by hand. + +Updates come from the deployment you are connected to, so a self-hosted install controls which build its own users are offered. That control depends on the feed staying reachable: if it is not, a self-updating stable build falls back to Sim's public GitHub releases rather than stalling. See [Desktop App on Your Deployment](/platform/self-hosting/desktop). + +## Requirements + +- **macOS 12 Monterey or later**, on Apple Silicon or Intel. +- **Outbound access to your Sim deployment**, and to `github.com`, which is where installers and updates are downloaded from. +- **If you self-host**, your Sim server needs its own outbound access to both `api.github.com`, which is what resolves *which* release to offer, and `github.com`. An allowlist carrying only `github.com` leaves the download endpoint answering `502`. +- **A system-trusted TLS certificate**, if you self-host. The app rejects certificate errors outright and offers no override, so a private CA must be installed in the macOS keychain. + + + The desktop app is macOS-only today. The web app works in any browser on any platform, and your account, workspaces, and workflows are the same either way — the app adds native capabilities on top, it does not hold anything of its own. + diff --git a/apps/docs/content/docs/integrations/cloudtrail.mdx b/apps/docs/content/docs/integrations/cloudtrail.mdx new file mode 100644 index 00000000000..bffd59d6b61 --- /dev/null +++ b/apps/docs/content/docs/integrations/cloudtrail.mdx @@ -0,0 +1,480 @@ +--- +title: CloudTrail +description: Audit who did what in AWS with CloudTrail event history and Lake queries +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[AWS CloudTrail](https://aws.amazon.com/cloudtrail/) records who did what in your AWS accounts. An API call — from the console, the CLI, an SDK, or another AWS service — is captured as an event with the calling identity, source IP, parameters, and result. It is the system of record for security investigation, compliance evidence, and answering "what changed?" + +What lands in that record is set by configuration, not assumed. Trails and event data stores log management events by default; data events, network activity events, and Insights events are captured only where you configure selectors for them. Read a trail's selectors before you treat its history as complete. + +With AWS CloudTrail, you can: + +- **Look up recent activity**: Search the last 90 days of management events by user, event name, resource, or event source +- **Inspect trail configuration**: Describe trails, check logging status, and read the event and Insights selectors that decide what gets captured +- **Query history with SQL**: Run CloudTrail Lake queries across event data stores for analysis that reaches further back than event lookup +- **Confirm coverage**: Verify that logging is actually enabled and that multi-region and organization trails are delivering + +In Sim, CloudTrail is the audit half of the AWS story. Where IAM and Identity Center answer *who has access*, CloudTrail answers *what they actually did with it* — so an agent can take a suspicious permission change and trace it back to the principal, the source IP, and the moment it happened, then hand a written timeline to whoever needs to act on it. + +This block is read-only with one narrow exception: `Cancel Query` stops a running CloudTrail Lake query. It never enables or disables logging, alters trail configuration, or deletes a trail. Every operation it ships is covered by this policy, with no residual write risk: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "cloudtrail:DescribeTrails", + "cloudtrail:GetTrail", + "cloudtrail:GetTrailStatus", + "cloudtrail:GetEventSelectors", + "cloudtrail:GetInsightSelectors", + "cloudtrail:GetEventDataStore", + "cloudtrail:ListTrails", + "cloudtrail:ListEventDataStores", + "cloudtrail:ListTags", + "cloudtrail:LookupEvents", + "cloudtrail:StartQuery", + "cloudtrail:DescribeQuery", + "cloudtrail:GetQueryResults", + "cloudtrail:CancelQuery" + ], + "Resource": "*" + } + ] +} +``` + +`cloudtrail:CancelQuery` is the action `Cancel Query` needs, and it is not implied by `Describe*`, `Get*`, or `List*` — omit it and that one operation fails with an access-denied error. Note that `Start Query` is billed per GB scanned and consumes your account's concurrent-query quota of 10. + +`Lookup Events` is limited by AWS to two requests per second per account per Region. Each call uses AWS adaptive retry mode and allows up to six attempts, so a throttled request backs off exponentially with jitter and usually succeeds instead of surfacing an error. That is a retry budget, not a guarantee: sustained throttling past six attempts fails the call with a `ThrottlingException`, and because a fresh SDK client is built per invocation, adaptive mode's client-side rate limiter carries no pacing state between calls. `Lookup Events` also returns one page per call — feed `nextToken` back in to walk a broad search, and expect to handle a throttling error on a long paging loop. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate AWS CloudTrail into workflows. Look up the last 90 days of management and Insights events by user, event name, resource, or access key; inspect trail configuration, logging status, and event selectors; and run SQL queries against CloudTrail Lake event data stores. This block never changes trail or event data store configuration, and never starts or stops logging. Starting and cancelling a Lake query are the only actions that are not reads, and AWS bills Lake queries on the data they scan. Requires AWS access key and secret access key. + + + +## Actions + +### CloudTrail Look Up Events + +Look up AWS CloudTrail management or Insights events from the last 90 days in a Region + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `attributeKey` | string | No | Lookup attribute to filter on: AccessKeyId, EventId, EventName, EventSource, ReadOnly, ResourceName, ResourceType, or Username. Must be paired with attributeValue | +| `attributeValue` | string | No | Value the lookup attribute must equal. Must be paired with attributeKey | +| `startTime` | string | No | Only return events at or after this ISO 8601 timestamp | +| `endTime` | string | No | Only return events at or before this ISO 8601 timestamp | +| `eventCategory` | string | No | Set to the value insight to return CloudTrail Insights events instead of management events | +| `maxResults` | number | No | Number of events to return, 1 to 50 \(default 50\) | +| `nextToken` | string | No | Pagination token from a previous lookup, which must repeat the same filters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `events` | array | Matching events, most recent first | +| ↳ `eventId` | string | CloudTrail event ID | +| ↳ `eventName` | string | API action that was called | +| ↳ `readOnly` | string | Whether the action was read-only, as the string 'true' or 'false' | +| ↳ `accessKeyId` | string | Access key ID used to make the call, when applicable | +| ↳ `eventTime` | string | When the event occurred \(ISO 8601\) | +| ↳ `eventSource` | string | AWS service endpoint that recorded the event | +| ↳ `username` | string | Name of the principal that made the call | +| ↳ `resources` | array | Resources referenced by the event, as resourceType and resourceName | +| ↳ `cloudTrailEvent` | object | Full CloudTrail event record parsed from JSON, including userIdentity, sourceIPAddress, userAgent, requestParameters, responseElements, and errorCode | +| ↳ `cloudTrailEventRaw` | string | Raw CloudTrail event JSON string, populated only when it could not be parsed | +| `nextToken` | string | Pagination token for the next page of events | + +### CloudTrail Describe Trails + +Retrieve the full configuration of one or more CloudTrail trails in the current Region + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `trailNameList` | string | No | Comma-separated trail names or ARNs. Leave empty to describe every trail in the Region. Trails in another Region must be given as ARNs | +| `includeShadowTrails` | boolean | No | Include shadow trails \(replications of trails created in another Region, and organization trails in member accounts\). Defaults to true | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `trails` | array | Full configuration of each matching trail | +| ↳ `name` | string | Trail name | +| ↳ `s3BucketName` | string | S3 bucket that receives log files | +| ↳ `s3KeyPrefix` | string | S3 key prefix for delivered log files | +| ↳ `snsTopicName` | string | SNS topic notified on log delivery | +| ↳ `snsTopicArn` | string | ARN of that SNS topic | +| ↳ `includeGlobalServiceEvents` | boolean | Whether global service events are recorded | +| ↳ `isMultiRegionTrail` | boolean | Whether the trail records events in all Regions | +| ↳ `homeRegion` | string | Region in which the trail was created | +| ↳ `trailArn` | string | ARN of the trail | +| ↳ `logFileValidationEnabled` | boolean | Whether log file integrity validation is enabled | +| ↳ `cloudWatchLogsLogGroupArn` | string | CloudWatch Logs log group receiving events | +| ↳ `cloudWatchLogsRoleArn` | string | Role CloudTrail assumes to write to CloudWatch Logs | +| ↳ `kmsKeyId` | string | KMS key used to encrypt log files | +| ↳ `hasCustomEventSelectors` | boolean | Whether the trail has custom event selectors | +| ↳ `hasInsightSelectors` | boolean | Whether the trail has Insights event selectors | +| ↳ `isOrganizationTrail` | boolean | Whether the trail is an organization trail | + +### CloudTrail Get Trail + +Retrieve the settings of a single CloudTrail trail by name or ARN + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `name` | string | Yes | Trail name, or the trail ARN for a trail in another Region | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Trail name | +| `s3BucketName` | string | Name of the S3 bucket that receives log files | +| `s3KeyPrefix` | string | S3 key prefix prepended to delivered log files | +| `snsTopicName` | string | Name of the SNS topic notified on log delivery | +| `snsTopicArn` | string | ARN of the SNS topic notified on log delivery | +| `includeGlobalServiceEvents` | boolean | Whether the trail records global service events | +| `isMultiRegionTrail` | boolean | Whether the trail records events in all Regions | +| `homeRegion` | string | Region in which the trail was created | +| `trailArn` | string | ARN of the trail | +| `logFileValidationEnabled` | boolean | Whether log file integrity validation is enabled | +| `cloudWatchLogsLogGroupArn` | string | ARN of the CloudWatch Logs log group receiving events | +| `cloudWatchLogsRoleArn` | string | ARN of the role CloudTrail assumes to write to CloudWatch Logs | +| `kmsKeyId` | string | KMS key used to encrypt log files | +| `hasCustomEventSelectors` | boolean | Whether the trail has custom event selectors | +| `hasInsightSelectors` | boolean | Whether the trail has Insights event selectors | +| `isOrganizationTrail` | boolean | Whether the trail is an organization trail | + +### CloudTrail Get Trail Status + +Check whether a CloudTrail trail is logging and surface its most recent delivery errors + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `name` | string | Yes | Trail name, or the trail ARN. An organization trail read from a member account must be given as an ARN | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `isLogging` | boolean | Whether the trail is currently recording API calls | +| `latestDeliveryError` | string | Most recent S3 error encountered delivering log files | +| `latestDeliveryTime` | string | When log files were last delivered to S3 \(ISO 8601\) | +| `latestNotificationError` | string | Most recent SNS error encountered sending a notification | +| `latestNotificationTime` | string | When the last SNS notification was sent \(ISO 8601\) | +| `latestCloudWatchLogsDeliveryError` | string | Most recent CloudWatch Logs delivery error | +| `latestCloudWatchLogsDeliveryTime` | string | When events were last delivered to CloudWatch Logs \(ISO 8601\) | +| `latestDigestDeliveryError` | string | Most recent S3 error encountered delivering a digest file | +| `latestDigestDeliveryTime` | string | When a digest file was last delivered to S3 \(ISO 8601\) | +| `startLoggingTime` | string | When logging was most recently started \(ISO 8601\) | +| `stopLoggingTime` | string | When logging was most recently stopped \(ISO 8601\) | + +### CloudTrail List Trails + +List the ARN, name, and home Region of every CloudTrail trail visible to the account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `nextToken` | string | No | Pagination token from a previous list request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `trails` | array | Trail summaries | +| ↳ `trailArn` | string | ARN of the trail | +| ↳ `name` | string | Trail name | +| ↳ `homeRegion` | string | Region in which the trail was created | +| `nextToken` | string | Pagination token for the next page of trails, or null on the last page | + +### CloudTrail Get Event Selectors + +Read which management, data, and network activity events a CloudTrail trail is configured to log + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `trailName` | string | Yes | Trail name or trail ARN | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `trailArn` | string | ARN of the trail that owns these selectors | +| `eventSelectors` | array | Basic event selectors configured on the trail | +| ↳ `readWriteType` | string | All, ReadOnly, or WriteOnly | +| ↳ `includeManagementEvents` | boolean | Whether management events are recorded | +| ↳ `dataResources` | array | Data resources logged by the selector, as type and values | +| ↳ `excludeManagementEventSources` | array | Event sources excluded from management event logging | +| `advancedEventSelectors` | array | Advanced event selectors configured on the trail | +| ↳ `name` | string | Name of the advanced event selector | +| ↳ `fieldSelectors` | array | Field selectors, each with field plus its equals, startsWith, endsWith, notEquals, notStartsWith, and notEndsWith values | + +### CloudTrail Get Insight Selectors + +Read which CloudTrail Insights types are enabled on a trail or event data store + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `trailName` | string | No | Trail name or trail ARN. Cannot be combined with eventDataStore | +| `eventDataStore` | string | No | Event data store ARN, or the ID suffix of that ARN. Cannot be combined with trailName | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `trailArn` | string | ARN of the trail whose Insights selectors were read | +| `eventDataStoreArn` | string | ARN of the source event data store that enabled Insights events | +| `insightsDestination` | string | ARN of the destination event data store that logs Insights events | +| `insightSelectors` | array | Enabled Insights types and their event categories | +| ↳ `insightType` | string | ApiCallRateInsight or ApiErrorRateInsight | +| ↳ `eventCategories` | array | Event categories the Insights type applies to: Management, Data, or both | + +### CloudTrail Start Query + +Start a CloudTrail Lake SQL query over an event data store + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `queryStatement` | string | No | SQL query to run, up to 10,000 characters. The event data store ID is named in the FROM clause. Supply this or queryAlias, not both | +| `queryAlias` | string | No | Alias of a query template used by CloudTrail Lake dashboards. Supply this or queryStatement, not both | +| `queryParameters` | string | No | Comma-separated parameter values for the query alias, up to 10 values | +| `deliveryS3Uri` | string | No | S3 URI where CloudTrail delivers the query results \(e.g., s3://my-bucket/results\) | +| `eventDataStoreOwnerAccountId` | string | No | Account ID of the event data store owner, for a shared event data store | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queryId` | string | ID of the started query. Pass it to Describe Query to poll status, or to Get Query Results to page through rows | +| `eventDataStoreOwnerAccountId` | string | Account ID of the event data store owner | + +### CloudTrail Describe Query + +Check the status, run time, and scan statistics of a CloudTrail Lake query + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `queryId` | string | No | ID of the query returned by Start Query. Supply this or queryAlias, not both | +| `queryAlias` | string | No | Query template alias; returns the last run for that alias. Supply this or queryId, not both | +| `refreshId` | string | No | Dashboard refresh ID, used together with queryAlias | +| `eventDataStoreOwnerAccountId` | string | No | Account ID of the event data store owner, for a shared event data store | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queryId` | string | ID of the query | +| `queryString` | string | SQL body of the query | +| `queryStatus` | string | QUEUED, RUNNING, FINISHED, FAILED, CANCELLED, or TIMED_OUT | +| `errorMessage` | string | Error message returned if the query failed | +| `deliveryS3Uri` | string | S3 URI the results were delivered to, if configured | +| `deliveryStatus` | string | Delivery status of the S3 results \(SUCCESS, FAILED, PENDING, and similar\) | +| `prompt` | string | Natural-language prompt used to generate the query, if it was generated | +| `eventDataStoreOwnerAccountId` | string | Account ID of the event data store owner | +| `eventsMatched` | number | Number of events that matched the query | +| `eventsScanned` | number | Number of events scanned by the query | +| `bytesScanned` | number | Bytes scanned by the query | +| `executionTimeInMillis` | number | Query run time in milliseconds | +| `creationTime` | string | When the query was created \(ISO 8601\) | + +### CloudTrail Get Query Results + +Fetch a page of result rows from a finished CloudTrail Lake query + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `queryId` | string | Yes | ID of the query returned by Start Query | +| `maxQueryResults` | number | No | Maximum rows to return on a single page, 1 to 1000 | +| `nextToken` | string | No | Pagination token from a previous results request | +| `eventDataStoreOwnerAccountId` | string | No | Account ID of the event data store owner, for a shared event data store | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queryStatus` | string | QUEUED, RUNNING, FINISHED, FAILED, CANCELLED, or TIMED_OUT | +| `rows` | array | Result rows, each flattened into a single object keyed by the query column names | +| `resultsCount` | number | Number of rows on this page | +| `totalResultsCount` | number | Total number of rows the query produced | +| `bytesScanned` | number | Bytes scanned by the query | +| `errorMessage` | string | Error message returned if the query failed | +| `nextToken` | string | Pagination token for the next page of rows | + +### CloudTrail Cancel Query + +Cancel a running CloudTrail Lake query + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `queryId` | string | Yes | ID of the query returned by Start Query | +| `eventDataStoreOwnerAccountId` | string | No | Account ID of the event data store owner, for a shared event data store | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queryId` | string | ID of the cancelled query | +| `queryStatus` | string | Status AWS reported for the query after the cancellation request. Cancellation is asynchronous, so this is typically RUNNING or CANCELLED — poll Describe Lake Query for the terminal status | +| `eventDataStoreOwnerAccountId` | string | Account ID of the event data store owner, when the query was cross-account | + +### CloudTrail List Event Data Stores + +List the CloudTrail Lake event data stores in the account for the current Region + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `maxResults` | number | No | Maximum event data stores to return on a single page, 1 to 1000 | +| `nextToken` | string | No | Pagination token from a previous list request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventDataStores` | array | Event data stores in the account for the current Region | +| ↳ `eventDataStoreArn` | string | ARN of the event data store | +| ↳ `name` | string | Name of the event data store | +| ↳ `status` | string | CREATED, ENABLED, PENDING_DELETION, or an ingestion state | +| ↳ `advancedEventSelectors` | array | Advanced event selectors that define what the store ingests | +| ↳ `multiRegionEnabled` | boolean | Whether the store collects events from all Regions | +| ↳ `organizationEnabled` | boolean | Whether the store collects events for the organization | +| ↳ `retentionPeriod` | number | Retention period in days | +| ↳ `terminationProtectionEnabled` | boolean | Whether termination protection is enabled | +| ↳ `createdTimestamp` | string | When the store was created \(ISO 8601\) | +| ↳ `updatedTimestamp` | string | When the store was last updated \(ISO 8601\) | +| `nextToken` | string | Pagination token for the next page of event data stores | + +### CloudTrail Get Event Data Store + +Retrieve the configuration of a single CloudTrail Lake event data store + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `eventDataStore` | string | Yes | Event data store ARN, or the ID suffix of that ARN | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventDataStoreArn` | string | ARN of the event data store | +| `name` | string | Name of the event data store | +| `status` | string | CREATED, ENABLED, PENDING_DELETION, or an ingestion state | +| `advancedEventSelectors` | array | Advanced event selectors that define what the store ingests | +| ↳ `name` | string | Name of the advanced event selector | +| ↳ `fieldSelectors` | array | Field selectors, each with field plus its equals, startsWith, endsWith, notEquals, notStartsWith, and notEndsWith values | +| `multiRegionEnabled` | boolean | Whether the store collects events from all Regions | +| `organizationEnabled` | boolean | Whether the store collects events for the organization | +| `retentionPeriod` | number | Retention period in days | +| `terminationProtectionEnabled` | boolean | Whether termination protection is enabled | +| `createdTimestamp` | string | When the store was created \(ISO 8601\) | +| `updatedTimestamp` | string | When the store was last updated \(ISO 8601\) | +| `kmsKeyId` | string | KMS key used to encrypt the store | +| `billingMode` | string | EXTENDABLE_RETENTION_PRICING or FIXED_RETENTION_PRICING | +| `federationStatus` | string | Lake Formation federation status | +| `federationRoleArn` | string | ARN of the role used for Lake Formation federation | +| `partitionKeys` | array | Partition keys of the event data store | +| ↳ `name` | string | Partition key name | +| ↳ `type` | string | Partition key data type | + +### CloudTrail List Tags + +List the tags on CloudTrail trails, event data stores, dashboards, or channels + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `resourceIdList` | string | Yes | Comma-separated CloudTrail resource ARNs, up to 20 | +| `nextToken` | string | No | Reserved for future use by AWS | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `resourceTags` | array | Tags for each requested resource | +| ↳ `resourceId` | string | ARN of the tagged resource | +| ↳ `tags` | array | Tags on the resource, as key and value | +| `nextToken` | string | Reserved for future use by AWS | + + diff --git a/apps/docs/content/docs/integrations/embeddings.mdx b/apps/docs/content/docs/integrations/embeddings.mdx index d20a6c47628..18e123c8566 100644 --- a/apps/docs/content/docs/integrations/embeddings.mdx +++ b/apps/docs/content/docs/integrations/embeddings.mdx @@ -13,19 +13,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} An embedding turns a piece of text into a list of numbers that captures its meaning. Two texts that mean similar things get similar numbers, so you can compare meaning directly instead of matching keywords. That is what powers semantic search, grouping related items, and spotting near-duplicates that are worded differently. -The Embeddings block generates those numbers using OpenAI, Google Gemini, Cohere, or Mistral. Pick a provider, pick one of its models, pass in text, and get a vector back — one vector per input, in the order you supplied them. You can embed a single string or a list of strings in one call. +The Embeddings block generates those numbers using OpenAI, Google Gemini, Cohere, Mistral, OpenRouter, or a model on your own Ollama server. Pick a provider, pick one of its models, pass in text, and get a vector back — one vector per input, in the order you supplied them. You can embed a single string or a list of strings in one call. Models differ in what they are good at and what they cost. `text-embedding-3-small` is the cost-efficient general choice, `gemini-embedding-001` gives the highest retrieval quality, `embed-v4.0` handles multilingual content, and `codestral-embed` is tuned for source code. Some models also let you trade vector size against quality, and some accept a task type so the vector is conditioned for how it will be used — the block only offers those controls for the models that actually support them. Two things worth knowing before you build on it. Vectors are only comparable when they come from the same model at the same size, so changing either means re-embedding everything you intend to compare. And input longer than the model's limit is shortened to fit rather than rejected, with a warning in the run, so chunk long documents yourself when the tail matters. -Sim's knowledge bases embed separately, at a fixed vector width and from a smaller set of models. This block is for embedding text yourself inside a workflow. +Ollama is the exception to most of the above. It runs on your own deployment, so it needs no API key and adds no provider charge — Sim's own per-run charge still applies — and the model list is whatever you have pulled onto that server rather than a catalog Sim maintains. The block reads it live, drops the models that report a non-embedding capability, and shows each one's vector width next to its name where Ollama reports one. A server too old to report either will list its chat models too and label none of them, so check the model you pick. The block offers no task-type or dimension control for Ollama: task conditioning has no equivalent there, and while recent Ollama builds do accept a dimension override for Matryoshka models, older ones silently ignore it, so Sim uses each model's own width rather than one that may or may not take effect. Point Sim at the server with `OLLAMA_URL`. Sim Cloud runs no Ollama of its own, so without that variable the list comes back empty rather than dialling a loopback address that cannot answer — set it to a reachable server and Cloud will use it like any other deployment. + +Sim's knowledge bases embed separately: a base fixes one model and one vector width when it is created, from a smaller set of models. This block is for embedding text yourself inside a workflow. {/* MANUAL-CONTENT-END */} ## Usage Instructions -Turn text into embedding vectors for semantic search, clustering, and similarity. Supports OpenAI, OpenRouter, Google Gemini, Cohere, and Mistral embedding models. +Turn text into embedding vectors for semantic search, clustering, and similarity. Supports OpenAI, OpenRouter, Google Gemini, Cohere, and Mistral embedding models, plus embedding models on a self-hosted Ollama. @@ -151,4 +153,25 @@ Generate embeddings from text using Mistral's embedding models | `dimensions` | number | Dimensionality of each vector | | `usage` | json | Token usage | +### Ollama Embeddings + +Generate embeddings on a self-hosted Ollama server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `input` | string | Yes | Text to embed, or an array of texts to embed in one call | +| `model` | string | Yes | Embedding model pulled on the configured Ollama server | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `embeddings` | json | Generated embeddings | +| `model` | string | Model used | +| `provider` | string | Provider used | +| `dimensions` | number | Dimensionality of each vector | +| `usage` | json | Token usage | + diff --git a/apps/docs/content/docs/integrations/iam.mdx b/apps/docs/content/docs/integrations/iam.mdx index 03527393ce6..ba14525abd9 100644 --- a/apps/docs/content/docs/integrations/iam.mdx +++ b/apps/docs/content/docs/integrations/iam.mdx @@ -19,9 +19,14 @@ With AWS IAM, you can: - **Create roles**: Define IAM roles with specific permissions that can be assumed by users, services, or applications for temporary access - **Attach policies**: Assign managed policies to users and roles to define what actions they can perform on which resources - **Organize with groups**: Create IAM groups to manage permissions for collections of users, simplifying access management at scale -- **Control access keys**: Generate and manage programmatic access key pairs for API and CLI access to AWS services +- **Control access keys**: Generate, list, deactivate, and delete programmatic access key pairs for API and CLI access to AWS services +- **Simulate policies**: Test whether a principal is allowed to perform specific actions against specific resources, before granting or revoking anything In Sim, the AWS IAM integration allows your workflows to automate identity management tasks such as provisioning new users, assigning roles and permissions, managing group memberships, and rotating access keys. This is particularly useful for onboarding automation, security compliance workflows, access reviews, and incident response — enabling your agents to manage AWS access control programmatically. + +Policy simulation deserves a note, because AWS's model is easy to misread. `Simulate Principal Policy` returns one result per action regardless of how many resource ARNs you pass. The top-level decision is the **aggregate** across every resource — most restrictive wins — and the top-level resource name is an ARN *template* for the resource type, not one of your ARNs. Per-resource answers live in `resourceSpecificResults`, and when you supply concrete ARNs, missing context keys are reported there too rather than at the top level. Read `resourceSpecificResults` whenever you simulate against more than one resource: the aggregate alone will tell you a principal is denied when it is in fact allowed on some of them. + +The secret half of a new access key is returned once and is hidden from block output display and execution logs. It stays resolvable downstream, so rotation workflows can pass it straight to the system that needs it — but a block you pass it into will log it under that block's own inputs. {/* MANUAL-CONTENT-END */} @@ -317,7 +322,7 @@ List managed IAM policies | `region` | string | Yes | AWS region \(e.g., us-east-1\) | | `accessKeyId` | string | Yes | AWS access key ID | | `secretAccessKey` | string | Yes | AWS secret access key | -| `scope` | string | No | Filter by scope: All, AWS \(AWS-managed\), or Local \(customer-managed\) | +| `scope` | string | No | Filter by scope. Must be exactly one of: All, AWS \(AWS-managed\), Local \(customer-managed\) | | `onlyAttached` | boolean | No | If true, only return policies attached to an entity | | `pathPrefix` | string | No | Path prefix to filter policies | | `maxItems` | number | No | Maximum number of policies to return \(1-1000, default 100\) | @@ -327,11 +332,41 @@ List managed IAM policies | Parameter | Type | Description | | --------- | ---- | ----------- | -| `policies` | json | List of policies with policyName, arn, attachmentCount, and dates | +| `policies` | json | List of policies with policyName, policyId, arn, path, attachmentCount, isAttachable, defaultVersionId, permissionsBoundaryUsageCount, and dates. AWS never returns policy descriptions from ListPolicies — use IAM Get Policy for a description. | | `isTruncated` | boolean | Whether there are more results available | | `marker` | string | Pagination marker for the next page of results | | `count` | number | Number of policies returned | +### IAM Get Policy + +Get details about a managed IAM policy, including its description — the field ListPolicies never returns + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `policyArn` | string | Yes | ARN of the managed policy to retrieve \(e.g., arn:aws:iam::aws:policy/ReadOnlyAccess\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `policyName` | string | The friendly name of the policy | +| `policyId` | string | The stable unique ID of the policy | +| `arn` | string | The ARN of the policy | +| `path` | string | The path to the policy | +| `attachmentCount` | number | Number of entities the policy is attached to | +| `isAttachable` | boolean | Whether the policy can be attached | +| `createDate` | string | Date the policy was created | +| `updateDate` | string | Date the policy was last updated | +| `description` | string | The policy description | +| `defaultVersionId` | string | The identifier of the default policy version | +| `permissionsBoundaryUsageCount` | number | Number of entities using the policy as a permissions boundary | +| `tags` | json | Tags attached to the policy \(key, value pairs\) | + ### IAM Create Access Key Create a new access key pair for an IAM user @@ -376,6 +411,51 @@ Delete an access key pair for an IAM user | --------- | ---- | ----------- | | `message` | string | Operation status message | +### IAM List Access Keys + +List an IAM user's access key IDs with their status and age — use to find stale keys and to confirm which keys remain after a rotation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `userName` | string | No | The IAM user whose keys to list \(defaults to the calling user if omitted\) | +| `maxItems` | number | No | Maximum number of access keys to return \(1-1000\) | +| `marker` | string | No | Pagination marker from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessKeys` | json | Access key metadata: accessKeyId, userName, status \(Active/Inactive\), createDate. The secret access key is never returned by this operation. | +| `isTruncated` | boolean | Whether there are more results available | +| `marker` | string | Pagination marker for the next page of results | +| `count` | number | Number of access keys returned | + +### IAM Update Access Key + +Activate or deactivate an IAM access key — deactivate an old key and verify nothing breaks before deleting it + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `accessKeyIdToUpdate` | string | Yes | The access key ID whose status to change | +| `status` | string | Yes | The status to set. Must be exactly one of: Active, Inactive. An Inactive key is rejected by AWS but can be reactivated. | +| `userName` | string | No | The IAM user that owns the key \(defaults to the calling user if omitted\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + ### IAM List Groups List IAM groups in your AWS account @@ -503,7 +583,8 @@ Simulate whether a user, role, or group is allowed to perform specific AWS actio | `secretAccessKey` | string | Yes | AWS secret access key | | `policySourceArn` | string | Yes | ARN of the user, group, or role to simulate \(e.g., arn:aws:iam::123456789012:user/alice\) | | `actionNames` | string | Yes | Comma-separated list of AWS actions to simulate \(e.g., s3:GetObject,ec2:DescribeInstances\) | -| `resourceArns` | string | No | Comma-separated list of resource ARNs to simulate against \(defaults to * if not provided\) | +| `resourceArns` | string | No | Comma-separated list of resource ARNs to simulate against \(defaults to * if not provided\). Read the per-ARN verdict from resourceSpecificResults, not from evalDecision. | +| `contextEntries` | array | No | Condition context keys to supply to the simulation. Without these, any policy gated by a Condition simulates as denied with missing context values. | | `maxResults` | number | No | Maximum number of simulation results to return \(1-1000\) | | `marker` | string | No | Pagination marker from a previous request | @@ -511,7 +592,7 @@ Simulate whether a user, role, or group is allowed to perform specific AWS actio | Parameter | Type | Description | | --------- | ---- | ----------- | -| `evaluationResults` | json | Simulation results per action: evalActionName, evalResourceName, evalDecision \(allowed/explicitDeny/implicitDeny\), matchedStatements \(sourcePolicyId, sourcePolicyType\), missingContextValues | +| `evaluationResults` | json | One result per simulated action. evalDecision is the AGGREGATE, most-restrictive decision across every resource ARN, and evalResourceName is the resource-type ARN template \(e.g. an arn:aws:s3:::BUCKET/KEY shape with the bucket and key left as placeholders\), not a customer ARN. For the verdict on an individual ARN read resourceSpecificResults\[\]: evalResourceName, evalResourceDecision \(allowed/explicitDeny/implicitDeny\), matchedStatements, missingContextValues, permissionsBoundaryAllowed. When concrete resource ARNs are supplied, missing context values appear there rather than at the top level. | | `isTruncated` | boolean | Whether there are more results available | | `marker` | string | Pagination marker for the next page of results | | `count` | number | Number of evaluation results returned | diff --git a/apps/docs/content/docs/integrations/identity_center.mdx b/apps/docs/content/docs/integrations/identity_center.mdx index 22b620939ac..3490979219f 100644 --- a/apps/docs/content/docs/integrations/identity_center.mdx +++ b/apps/docs/content/docs/integrations/identity_center.mdx @@ -21,9 +21,16 @@ With AWS IAM Identity Center, you can: - **List permission sets**: Enumerate the available permission sets (e.g., ReadOnly, PowerUser, AdministratorAccess) defined in your Identity Center instance - **Monitor assignment status**: Poll the provisioning status of create/delete operations, which are asynchronous in AWS - **List accounts in your organization**: Enumerate all AWS accounts in your AWS Organizations structure to populate access request dropdowns -- **Manage groups**: List groups and resolve group IDs by display name for group-based access grants +- **Manage groups**: List groups, resolve group IDs by display name, and enumerate group memberships for group-based access grants +- **Audit an account's access**: List the assignments on a given AWS account for one permission set, then resolve each principal ID back to the user or group behind it In Sim, the AWS Identity Center integration is designed to power **TEAM (Temporary Elevated Access Management)** workflows — automated pipelines where users request elevated access, approvers approve or deny it, access is provisioned with a time limit, and auto-revocation removes it when the window expires. This replaces manual console-based access management with auditable, agent-driven workflows that integrate with Slack, email, ticketing systems, and CloudTrail for full traceability. + +The same operations support the reverse direction — access review. Starting from an account, an agent can list its assignments, resolve the principals, expand groups into their members, and produce a written report of exactly who can reach that account and through which permission set. + +One detail shapes how that review has to be built. AWS requires a permission set ARN alongside the account ID on this call, so *List Assignments For Account* returns only the assignments granted through that one permission set — not every assignment on the account. To cover an account completely, run *List Permission Sets* for the instance first, then call *List Assignments For Account* once per permission set and combine the results. Skipping that loop silently omits access granted through the permission sets you did not ask about. + +Two AWS behaviors are worth knowing. Creating and deleting an account assignment are **asynchronous**: both return a request ID, and each has its own status poller — use *Check Assignment Status* for creations and *Check Assignment Deletion Status* for deletions, as the two request-ID types are not interchangeable. And the account-listing operations call AWS Organizations, which is global per partition; the block resolves the correct endpoint for commercial, GovCloud, and China regions automatically. {/* MANUAL-CONTENT-END */} @@ -53,7 +60,14 @@ List all AWS IAM Identity Center instances in your account | Parameter | Type | Description | | --------- | ---- | ----------- | -| `instances` | json | List of Identity Center instances with instanceArn, identityStoreId, name, status, statusReason | +| `instances` | array | Identity Center instances in the region | +| ↳ `instanceArn` | string | ARN of the Identity Center instance | +| ↳ `identityStoreId` | string | Identity Store ID backing the instance | +| ↳ `name` | string | Instance name | +| ↳ `status` | string | Instance status | +| ↳ `statusReason` | string | Explanation when the instance is not ACTIVE | +| ↳ `ownerAccountId` | string | AWS account that owns the instance | +| ↳ `createdDate` | string | ISO 8601 date the instance was created | | `nextToken` | string | Pagination token for the next page of results | | `count` | number | Number of instances returned | @@ -68,14 +82,20 @@ List all AWS accounts in your organization | `region` | string | Yes | AWS region \(e.g., us-east-1\) | | `accessKeyId` | string | Yes | AWS access key ID | | `secretAccessKey` | string | Yes | AWS secret access key | -| `maxResults` | number | No | Maximum number of accounts to return | +| `maxResults` | number | No | Maximum number of accounts to return \(1-20; the AWS Organizations ceiling\) | | `nextToken` | string | No | Pagination token from a previous request | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `accounts` | json | List of AWS accounts with id, arn, name, email, status | +| `accounts` | array | Accounts in the AWS organization | +| ↳ `id` | string | AWS account ID | +| ↳ `arn` | string | AWS account ARN | +| ↳ `name` | string | Account name | +| ↳ `email` | string | Root email address of the account | +| ↳ `status` | string | Account status \(e.g., ACTIVE, SUSPENDED\) | +| ↳ `joinedTimestamp` | string | ISO 8601 date the account joined the organization | | `nextToken` | string | Pagination token for the next page of results | | `count` | number | Number of accounts returned | @@ -115,14 +135,19 @@ List all permission sets defined in an IAM Identity Center instance | `accessKeyId` | string | Yes | AWS access key ID | | `secretAccessKey` | string | Yes | AWS secret access key | | `instanceArn` | string | Yes | ARN of the Identity Center instance | -| `maxResults` | number | No | Maximum number of permission sets to return | +| `maxResults` | number | No | Maximum number of permission sets to return \(1-100\) | | `nextToken` | string | No | Pagination token from a previous request | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `permissionSets` | json | List of permission sets with permissionSetArn, name, description, sessionDuration | +| `permissionSets` | array | Permission sets defined on the instance | +| ↳ `permissionSetArn` | string | ARN of the permission set | +| ↳ `name` | string | Permission set name | +| ↳ `description` | string | Permission set description | +| ↳ `sessionDuration` | string | ISO 8601 session duration \(e.g., PT1H\) | +| ↳ `createdDate` | string | ISO 8601 date the permission set was created | | `nextToken` | string | Pagination token for the next page of results | | `count` | number | Number of permission sets returned | @@ -149,6 +174,34 @@ Look up a user in the Identity Store by email address | `displayName` | string | Display name of the user | | `email` | string | Email address of the user | +### Identity Center Describe User + +Resolve an Identity Store user ID to the user behind it. Use to turn the principalId on an account assignment into a name and email. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `identityStoreId` | string | Yes | Identity Store ID \(e.g., d-1234567890\) | +| `userId` | string | Yes | Identity Store user ID, such as the principalId on a USER account assignment | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `userId` | string | Identity Store user ID | +| `userName` | string | Username in the Identity Store | +| `displayName` | string | Display name of the user, or null when the Identity Store omits it | +| `email` | string | Primary email address, or null when the user has no email attribute | +| `userStatus` | string | Account status \(ENABLED or DISABLED\), or null when the Identity Store omits it | +| `title` | string | Job title, or null when the Identity Store omits it | +| `externalIds` | array | External identity provider IDs linked to the user | +| ↳ `issuer` | string | Identity provider that issued the ID | +| ↳ `id` | string | Identifier at the issuer | + ### Identity Center Get Group Look up a group in the Identity Store by display name @@ -171,6 +224,31 @@ Look up a group in the Identity Store by display name | `displayName` | string | Display name of the group | | `description` | string | Group description | +### Identity Center Describe Group + +Resolve an Identity Store group ID to the group behind it. Use to turn the principalId on an account assignment into a group name. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `identityStoreId` | string | Yes | Identity Store ID \(e.g., d-1234567890\) | +| `groupId` | string | Yes | Identity Store group ID, such as the principalId on a GROUP account assignment | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `groupId` | string | Identity Store group ID | +| `displayName` | string | Display name of the group | +| `description` | string | Group description | +| `externalIds` | array | External identity provider IDs linked to the group | +| ↳ `issuer` | string | Identity provider that issued the ID | +| ↳ `id` | string | Identifier at the issuer | + ### Identity Center List Groups List all groups in the Identity Store @@ -183,17 +261,50 @@ List all groups in the Identity Store | `accessKeyId` | string | Yes | AWS access key ID | | `secretAccessKey` | string | Yes | AWS secret access key | | `identityStoreId` | string | Yes | Identity Store ID \(from the Identity Center instance\) | -| `maxResults` | number | No | Maximum number of groups to return | +| `maxResults` | number | No | Maximum number of groups to return \(1-100\) | | `nextToken` | string | No | Pagination token from a previous request | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `groups` | json | List of groups with groupId, displayName, description | +| `groups` | array | Groups in the Identity Store | +| ↳ `groupId` | string | Identity Store group ID \(use as principalId\) | +| ↳ `displayName` | string | Group display name | +| ↳ `description` | string | Group description | +| ↳ `externalIds` | array | External identity provider IDs linked to the group | +| ↳ `issuer` | string | Identity provider that issued the ID | +| ↳ `id` | string | Identifier at the issuer | | `nextToken` | string | Pagination token for the next page of results | | `count` | number | Number of groups returned | +### Identity Center List Group Memberships + +List the users who belong to an Identity Store group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `identityStoreId` | string | Yes | Identity Store ID \(e.g., d-1234567890\) | +| `groupId` | string | Yes | Identity Store group ID whose members to list | +| `maxResults` | number | No | Maximum number of memberships to return \(1-100\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `memberships` | array | Members of the group | +| ↳ `membershipId` | string | Identity Store membership ID | +| ↳ `groupId` | string | Identity Store group ID | +| ↳ `userId` | string | Identity Store user ID of the member — resolve with Describe User. Null when the member is not a user. | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of memberships returned | + ### Identity Center Create Account Assignment Grant a user or group access to an AWS account via a permission set (temporary elevated access) @@ -248,7 +359,7 @@ Revoke a user or group access to an AWS account by removing a permission set ass | --------- | ---- | ----------- | | `message` | string | Status message | | `status` | string | Deprovisioning status: IN_PROGRESS, FAILED, or SUCCEEDED | -| `requestId` | string | Request ID to use with Check Assignment Status | +| `requestId` | string | Request ID to use with Check Assignment Deletion Status | | `accountId` | string | Target AWS account ID | | `permissionSetArn` | string | Permission set ARN | | `principalType` | string | Principal type \(USER or GROUP\) | @@ -268,7 +379,7 @@ Check the provisioning status of an account assignment creation request | `accessKeyId` | string | Yes | AWS access key ID | | `secretAccessKey` | string | Yes | AWS secret access key | | `instanceArn` | string | Yes | ARN of the Identity Center instance | -| `requestId` | string | Yes | Request ID returned from Create or Delete Account Assignment | +| `requestId` | string | Yes | Request ID returned from Create Account Assignment. Deletion request IDs are not accepted — use Check Assignment Deletion Status for those. | #### Output @@ -312,9 +423,9 @@ Check the deprovisioning status of an account assignment deletion request | `failureReason` | string | Reason for failure if status is FAILED | | `createdDate` | string | Date the request was created | -### Identity Center List Account Assignments +### Identity Center List Account Assignments For Principal -List all account assignments for a specific user or group across all accounts +List every account and permission set a specific user or group is assigned. Use List Assignments For Account to go the other way, from an account to its principals. #### Input @@ -326,14 +437,47 @@ List all account assignments for a specific user or group across all accounts | `instanceArn` | string | Yes | ARN of the Identity Center instance | | `principalId` | string | Yes | Identity Store ID of the user or group | | `principalType` | string | Yes | Type of principal: USER or GROUP | -| `maxResults` | number | No | Maximum number of assignments to return | +| `maxResults` | number | No | Maximum number of assignments to return \(1-100\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `assignments` | array | Accounts and permission sets the principal is assigned | +| ↳ `accountId` | string | AWS account ID | +| ↳ `permissionSetArn` | string | Permission set ARN | +| ↳ `principalType` | string | Principal type \(USER or GROUP\) | +| ↳ `principalId` | string | Identity Store user or group ID | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of assignments returned | + +### Identity Center List Assignments For Account + +List every principal assigned a specific permission set on a specific AWS account. Use for per-account access reviews. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `instanceArn` | string | Yes | ARN of the Identity Center instance | +| `accountId` | string | Yes | AWS account ID to list assignments for \(12 digits\) | +| `permissionSetArn` | string | Yes | ARN of the permission set to list assignments for | +| `maxResults` | number | No | Maximum number of assignments to return \(1-100\) | | `nextToken` | string | No | Pagination token from a previous request | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `assignments` | json | List of account assignments with accountId, permissionSetArn, principalType, principalId | +| `assignments` | array | Principals assigned this permission set on the account | +| ↳ `accountId` | string | AWS account ID | +| ↳ `permissionSetArn` | string | Permission set ARN | +| ↳ `principalType` | string | Principal type \(USER or GROUP\) | +| ↳ `principalId` | string | Identity Store user or group ID — resolve with Describe User or Describe Group | | `nextToken` | string | Pagination token for the next page of results | | `count` | number | Number of assignments returned | diff --git a/apps/docs/content/docs/integrations/meta.json b/apps/docs/content/docs/integrations/meta.json index 69d1dac6bfc..df2c5f2d169 100644 --- a/apps/docs/content/docs/integrations/meta.json +++ b/apps/docs/content/docs/integrations/meta.json @@ -45,6 +45,7 @@ "clickup-service-account", "cloudflare", "cloudformation", + "cloudtrail", "cloudwatch", "codepipeline", "confluence", @@ -206,6 +207,7 @@ "pulse", "qdrant", "quartr", + "quickbooks", "quiver", "rabbitmq", "railway", @@ -249,6 +251,7 @@ "sqs", "square", "ssh", + "ssm", "stagehand", "stripe", "sts", diff --git a/apps/docs/content/docs/integrations/quickbooks.mdx b/apps/docs/content/docs/integrations/quickbooks.mdx new file mode 100644 index 00000000000..942579aab7d --- /dev/null +++ b/apps/docs/content/docs/integrations/quickbooks.mdx @@ -0,0 +1,3834 @@ +--- +title: QuickBooks +description: Manage QuickBooks Online company, transactions, reports, emails, PDFs, and attachments +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +Connect one QuickBooks Online company per credential. Create an app in the Intuit Developer Portal, register `https:///api/auth/oauth2/callback/quickbooks` as its redirect URI, then enter that app's client ID, client secret, and webhook verifier token in Sim and select its Sandbox or Production environment. Sim encrypts this app configuration on the credential and uses it for authorization, refresh, revocation, and webhook signature verification. + +During OAuth, choose the company that the workflow should access. Intuit returns the realm ID in the callback, and Sim verifies that the issued token can read CompanyInfo through that company-scoped API path before binding it to the credential, so you do not enter a realm ID or API host. The `CompanyInfo.Id` field is a separate entity ID and is not used as the realm ID. + +Master Data, Sales, and Purchasing transaction reads support **List** and **By ID** modes. List actions return at most one page. Use `nextStartPosition` in another workflow step when `hasMore` is true. Sim does not paginate, retry, or fetch related records automatically. + +QuickBooks update actions require the record ID and its current `SyncToken`; provide only the fields you want to change. Sim uses Intuit's documented sparse-update mode where the entity supports it, and otherwise reads the current entity, merges the requested fields, and submits a full update. Use the latest `SyncToken` returned by a read or mutation. Voiding keeps the transaction in QuickBooks with a zeroed financial effect; it is not deletion and requires explicit confirmation. Create actions accept an optional `requestId` that QuickBooks uses for idempotency when the same request may be submitted again. + +Sandbox credentials call only Intuit's sandbox API and are suitable for disposable test data. Production credentials call the production API and affect the selected live company. + +QuickBooks triggers use Intuit's app-level webhook model. After adding a trigger and deploying the workflow once, copy the generated Webhook URL from the block into the matching Development or Production Webhooks settings for the same Intuit app. Enable the CloudEvents payload format and select every entity and operation needed by your deployed workflows. One Intuit endpoint can serve multiple connected companies; Sim verifies the raw-body `intuit-signature` with that app's encrypted verifier token and routes each event by both Intuit app and OAuth-derived realm ID. + +Run Financial Report exposes verified financial statements, aging, balance, sales, and expense reports while preserving QuickBooks' native columns and nested rows. Advanced controls appear only where QuickBooks supports them. Use Read Master Data to discover customer, vendor, account, item, class, and department IDs for report filters. Intuit recommends report periods of six months or less for performance, though Sim does not forbid longer accounting periods. + +Document actions can read attachment metadata, add one File or Note attachment, download an attachment file, and download supported transactions as PDFs. Downloaded files are stored as Sim files for downstream blocks. Attachment deletion, bulk upload/download, and bulk email remain outside this version of the block. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Connect one QuickBooks Online company to manage bounded master-data, sales, purchasing, receivables, payables, accounting, reports, transaction delivery, and document workflows. + + + +## Actions + +### QuickBooks Get Company Info + +Get information about the connected QuickBooks Online company + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `company` | json | Verified QuickBooks CompanyInfo object with tax identifiers removed | +| ↳ `Id` | string | QuickBooks CompanyInfo entity ID \(commonly "1"\); this is not the OAuth realmId | +| ↳ `SyncToken` | string | CompanyInfo sync token | +| ↳ `CompanyName` | string | Company display name | +| ↳ `LegalName` | string | Company legal name | +| ↳ `CompanyAddr` | json | Company address | +| ↳ `CustomerCommunicationAddr` | json | Customer communication address | +| ↳ `LegalAddr` | json | Company legal address | +| ↳ `PrimaryPhone` | json | Primary phone details | +| ↳ `Email` | json | Company email details | +| ↳ `WebAddr` | json | Company website details | +| ↳ `CompanyStartDate` | string | Company start date | +| ↳ `Country` | string | Company country code | +| ↳ `FiscalYearStartMonth` | string | Fiscal year starting month | +| ↳ `SupportedLanguages` | string | Comma-separated list of languages supported by the company | +| ↳ `domain` | string | Originating Intuit domain | +| ↳ `sparse` | boolean | Whether QuickBooks returned a partial representation | +| ↳ `NameValue` | array | QuickBooks company settings represented as name/value entries | +| ↳ `MetaData` | json | CompanyInfo creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| `time` | string | QuickBooks response timestamp | + +### QuickBooks Read Master Data + +List or read one account, class, customer, department, employee, item, or vendor + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `recordType` | string | Yes | Master-data entity to read: account, class, customer, department, employee, item, or vendor | +| `readMode` | string | Yes | Whether to list records or read one record by ID | +| `recordId` | string | No | QuickBooks record ID, required for by-ID reads | +| `startPosition` | number | No | One-based position of the first list record to return | +| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `activeStatus` | string | No | List records using the QuickBooks default, active, or inactive status | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordType` | string | Master-data record type returned by this action | +| `item` | json | Single QuickBooks master-data record returned by a by-ID read | +| ↳ `Id` | string | QuickBooks entity ID | +| ↳ `SyncToken` | string | Entity sync token | +| ↳ `Active` | boolean | Whether the entity is active | +| ↳ `MetaData` | json | Entity creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `Name` | string | Account, item, class, or department name | +| ↳ `SubAccount` | boolean | Whether this is a subaccount | +| ↳ `ParentRef` | json | Parent account, item, class, or department reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `FullyQualifiedName` | string | Hierarchical qualified account, item, class, or department name | +| ↳ `Classification` | string | Account classification | +| ↳ `AccountType` | string | Account type | +| ↳ `AccountSubType` | string | Account subtype | +| ↳ `CurrentBalance` | number | Account current balance | +| ↳ `CurrencyRef` | json | Account, customer, or vendor currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `DisplayName` | string | Customer, vendor, or employee display name | +| ↳ `CompanyName` | string | Customer or vendor company name | +| ↳ `GivenName` | string | Given name | +| ↳ `FamilyName` | string | Family name | +| ↳ `Taxable` | boolean | Taxable status for the customer or item | +| ↳ `PrimaryEmailAddr` | json | Customer, vendor, or employee primary email address | +| ↳ `PrimaryPhone` | json | Customer, vendor, or employee primary phone number | +| ↳ `BillAddr` | json | Customer or vendor billing address | +| ↳ `ShipAddr` | json | Customer shipping address | +| ↳ `Balance` | number | Customer or vendor balance | +| ↳ `PrintOnCheckName` | string | Vendor or employee name printed on checks | +| ↳ `Vendor1099` | boolean | Whether the vendor is tracked for 1099 reporting | +| ↳ `AcctNum` | string | Vendor account number | +| ↳ `Description` | string | Item sales description | +| ↳ `UnitPrice` | number | Item sale price | +| ↳ `Type` | string | Item type | +| ↳ `IncomeAccountRef` | json | Item income account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `ExpenseAccountRef` | json | Item expense account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PurchaseDesc` | string | Item purchase description | +| ↳ `PurchaseCost` | number | Item purchase cost | +| ↳ `AssetAccountRef` | json | Inventory asset account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `TrackQtyOnHand` | boolean | Whether QuickBooks tracks quantity on hand | +| ↳ `QtyOnHand` | number | Current quantity on hand | +| ↳ `InvStartDate` | string | Inventory tracking start date | +| ↳ `PrimaryAddr` | json | Employee primary address | +| ↳ `BillableTime` | boolean | Whether employee time is billable | +| ↳ `domain` | string | QuickBooks domain | +| ↳ `sparse` | boolean | Whether this is a sparse entity | +| ↳ `SubClass` | boolean | Whether the Class is nested under another Class | +| ↳ `SubDepartment` | boolean | Whether the Department is nested under another Department | +| `items` | array | QuickBooks master-data records returned by a list read | +| ↳ `Id` | string | QuickBooks entity ID | +| ↳ `SyncToken` | string | Entity sync token | +| ↳ `Active` | boolean | Whether the entity is active | +| ↳ `MetaData` | json | Entity creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `Name` | string | Account, item, class, or department name | +| ↳ `SubAccount` | boolean | Whether this is a subaccount | +| ↳ `ParentRef` | json | Parent account, item, class, or department reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `FullyQualifiedName` | string | Hierarchical qualified account, item, class, or department name | +| ↳ `Classification` | string | Account classification | +| ↳ `AccountType` | string | Account type | +| ↳ `AccountSubType` | string | Account subtype | +| ↳ `CurrentBalance` | number | Account current balance | +| ↳ `CurrencyRef` | json | Account, customer, or vendor currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `DisplayName` | string | Customer, vendor, or employee display name | +| ↳ `CompanyName` | string | Customer or vendor company name | +| ↳ `GivenName` | string | Given name | +| ↳ `FamilyName` | string | Family name | +| ↳ `Taxable` | boolean | Taxable status for the customer or item | +| ↳ `PrimaryEmailAddr` | json | Customer, vendor, or employee primary email address | +| ↳ `PrimaryPhone` | json | Customer, vendor, or employee primary phone number | +| ↳ `BillAddr` | json | Customer or vendor billing address | +| ↳ `ShipAddr` | json | Customer shipping address | +| ↳ `Balance` | number | Customer or vendor balance | +| ↳ `PrintOnCheckName` | string | Vendor or employee name printed on checks | +| ↳ `Vendor1099` | boolean | Whether the vendor is tracked for 1099 reporting | +| ↳ `AcctNum` | string | Vendor account number | +| ↳ `Description` | string | Item sales description | +| ↳ `UnitPrice` | number | Item sale price | +| ↳ `Type` | string | Item type | +| ↳ `IncomeAccountRef` | json | Item income account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `ExpenseAccountRef` | json | Item expense account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PurchaseDesc` | string | Item purchase description | +| ↳ `PurchaseCost` | number | Item purchase cost | +| ↳ `AssetAccountRef` | json | Inventory asset account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `TrackQtyOnHand` | boolean | Whether QuickBooks tracks quantity on hand | +| ↳ `QtyOnHand` | number | Current quantity on hand | +| ↳ `InvStartDate` | string | Inventory tracking start date | +| ↳ `PrimaryAddr` | json | Employee primary address | +| ↳ `BillableTime` | boolean | Whether employee time is billable | +| ↳ `domain` | string | QuickBooks domain | +| ↳ `sparse` | boolean | Whether this is a sparse entity | +| ↳ `SubClass` | boolean | Whether the Class is nested under another Class | +| ↳ `SubDepartment` | boolean | Whether the Department is nested under another Department | +| `recordVersion` | string | Display-safe alias for the native SyncToken on a by-ID record | +| `startPosition` | number | One-based position of the first record in this page | +| `maxResults` | number | Actual number of records returned in this page | +| `nextStartPosition` | number | Position to use when explicitly requesting the next page | +| `hasMore` | boolean | Conservative indication that another page may exist | +| `time` | string | QuickBooks response timestamp | + +### QuickBooks Create Customer + +Create a customer in the connected QuickBooks Online company + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `displayName` | string | No | Unique customer display name. Required unless givenName or familyName is supplied | +| `companyName` | string | No | Customer company name | +| `givenName` | string | No | Customer given name | +| `familyName` | string | No | Customer family name | +| `primaryEmail` | string | No | Customer primary email address | +| `primaryPhone` | string | No | Customer primary phone number | +| `billingAddress` | json | No | Customer billing address | +| `shippingAddress` | json | No | Customer shipping address | +| `taxable` | boolean | No | Whether sales to this customer are taxable | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created QuickBooks Customer record | +| ↳ `Id` | string | QuickBooks entity ID | +| ↳ `SyncToken` | string | Entity sync token | +| ↳ `Active` | boolean | Whether the entity is active | +| ↳ `MetaData` | json | Entity creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `DisplayName` | string | Customer display name | +| ↳ `CompanyName` | string | Customer company name | +| ↳ `GivenName` | string | Given name | +| ↳ `FamilyName` | string | Family name | +| ↳ `Taxable` | boolean | Whether the customer is taxable | +| ↳ `PrimaryEmailAddr` | json | Customer primary email address | +| ↳ `PrimaryPhone` | json | Customer primary phone number | +| ↳ `BillAddr` | json | Customer billing address | +| ↳ `ShipAddr` | json | Customer shipping address | +| ↳ `Balance` | number | Customer balance | +| ↳ `CurrencyRef` | json | Customer currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | + +### QuickBooks Update Customer + +Sparse-update a customer in the connected QuickBooks Online company + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `customerId` | string | Yes | ID of the customer to update | +| `syncToken` | string | Yes | Current customer sync token | +| `displayName` | string | No | Replacement customer display name | +| `companyName` | string | No | Replacement customer company name | +| `givenName` | string | No | Replacement customer given name | +| `familyName` | string | No | Replacement customer family name | +| `primaryEmail` | string | No | Replacement primary email address | +| `primaryPhone` | string | No | Replacement primary phone number | +| `billingAddress` | json | No | Replacement billing address | +| `shippingAddress` | json | No | Replacement shipping address | +| `taxable` | boolean | No | Whether sales to this customer are taxable | +| `activeStatus` | string | No | Customer status change: unchanged, active, or inactive | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated QuickBooks Customer record | +| ↳ `Id` | string | QuickBooks entity ID | +| ↳ `SyncToken` | string | Entity sync token | +| ↳ `Active` | boolean | Whether the entity is active | +| ↳ `MetaData` | json | Entity creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `DisplayName` | string | Customer display name | +| ↳ `CompanyName` | string | Customer company name | +| ↳ `GivenName` | string | Given name | +| ↳ `FamilyName` | string | Family name | +| ↳ `Taxable` | boolean | Whether the customer is taxable | +| ↳ `PrimaryEmailAddr` | json | Customer primary email address | +| ↳ `PrimaryPhone` | json | Customer primary phone number | +| ↳ `BillAddr` | json | Customer billing address | +| ↳ `ShipAddr` | json | Customer shipping address | +| ↳ `Balance` | number | Customer balance | +| ↳ `CurrencyRef` | json | Customer currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | + +### QuickBooks Create Employee + +Create a non-payroll employee profile in the connected QuickBooks Online company + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `displayName` | string | No | Unique employee display name. When omitted QuickBooks derives it from the supplied name components, and it is read-only when QuickBooks Payroll is enabled | +| `givenName` | string | No | Employee given name. At least one of givenName or familyName is required | +| `familyName` | string | No | Employee family name. At least one of givenName or familyName is required | +| `primaryEmail` | string | No | Employee primary email address | +| `primaryPhone` | string | No | Employee primary phone number | +| `primaryAddress` | json | No | Employee primary address | +| `printOnCheckName` | string | No | Employee name printed on checks | +| `billableTime` | boolean | No | Whether employee time is billable | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created QuickBooks Employee record | +| ↳ `Id` | string | QuickBooks entity ID | +| ↳ `SyncToken` | string | Entity sync token | +| ↳ `Active` | boolean | Whether the entity is active | +| ↳ `MetaData` | json | Entity creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `DisplayName` | string | Employee display name | +| ↳ `GivenName` | string | Given name | +| ↳ `FamilyName` | string | Family name | +| ↳ `PrintOnCheckName` | string | Employee name printed on checks | +| ↳ `PrimaryEmailAddr` | json | Employee primary email address | +| ↳ `PrimaryPhone` | json | Employee primary phone number | +| ↳ `PrimaryAddr` | json | Employee primary address | +| ↳ `BillableTime` | boolean | Whether employee time is billable | +| ↳ `domain` | string | QuickBooks domain | +| ↳ `sparse` | boolean | Whether this is a sparse entity | + +### QuickBooks Update Employee + +Read, merge, and full-update a non-payroll employee profile + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `employeeId` | string | Yes | ID of the employee to update | +| `syncToken` | string | Yes | Current employee sync token | +| `displayName` | string | No | Replacement employee display name | +| `givenName` | string | No | Replacement employee given name | +| `familyName` | string | No | Replacement employee family name | +| `primaryEmail` | string | No | Replacement employee primary email address | +| `primaryPhone` | string | No | Replacement employee primary phone number | +| `primaryAddress` | json | No | Replacement employee primary address | +| `printOnCheckName` | string | No | Replacement employee name printed on checks | +| `billableTime` | boolean | No | Whether employee time is billable | +| `activeStatus` | string | No | Employee status change: unchanged, active, or inactive | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated QuickBooks Employee record | +| ↳ `Id` | string | QuickBooks entity ID | +| ↳ `SyncToken` | string | Entity sync token | +| ↳ `Active` | boolean | Whether the entity is active | +| ↳ `MetaData` | json | Entity creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `DisplayName` | string | Employee display name | +| ↳ `GivenName` | string | Given name | +| ↳ `FamilyName` | string | Family name | +| ↳ `PrintOnCheckName` | string | Employee name printed on checks | +| ↳ `PrimaryEmailAddr` | json | Employee primary email address | +| ↳ `PrimaryPhone` | json | Employee primary phone number | +| ↳ `PrimaryAddr` | json | Employee primary address | +| ↳ `BillableTime` | boolean | Whether employee time is billable | +| ↳ `domain` | string | QuickBooks domain | +| ↳ `sparse` | boolean | Whether this is a sparse entity | + +### QuickBooks Create Vendor + +Create a vendor in the connected QuickBooks Online company + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `displayName` | string | No | Unique vendor display name. Required unless givenName or familyName is supplied | +| `companyName` | string | No | Vendor company name | +| `givenName` | string | No | Vendor given name | +| `familyName` | string | No | Vendor family name | +| `primaryEmail` | string | No | Vendor primary email address | +| `primaryPhone` | string | No | Vendor primary phone number | +| `billingAddress` | json | No | Vendor billing address | +| `printOnCheckName` | string | No | Name to print on checks | +| `accountNumber` | string | No | Vendor account number | +| `vendor1099` | boolean | No | Whether the vendor is tracked for 1099 reporting | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created QuickBooks Vendor record | +| ↳ `Id` | string | QuickBooks entity ID | +| ↳ `SyncToken` | string | Entity sync token | +| ↳ `Active` | boolean | Whether the entity is active | +| ↳ `MetaData` | json | Entity creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `DisplayName` | string | Vendor display name | +| ↳ `CompanyName` | string | Vendor company name | +| ↳ `GivenName` | string | Given name | +| ↳ `FamilyName` | string | Family name | +| ↳ `PrintOnCheckName` | string | Name printed on checks | +| ↳ `Vendor1099` | boolean | Whether the vendor is tracked for 1099 reporting | +| ↳ `PrimaryEmailAddr` | json | Vendor primary email address | +| ↳ `PrimaryPhone` | json | Vendor primary phone number | +| ↳ `BillAddr` | json | Vendor billing address | +| ↳ `AcctNum` | string | Vendor account number | +| ↳ `Balance` | number | Vendor balance | +| ↳ `CurrencyRef` | json | Vendor currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | + +### QuickBooks Update Vendor + +Read, merge, and full-update a vendor in QuickBooks Online + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `vendorId` | string | Yes | ID of the vendor to update | +| `syncToken` | string | Yes | Current vendor sync token | +| `displayName` | string | No | Replacement vendor display name | +| `companyName` | string | No | Replacement vendor company name | +| `givenName` | string | No | Replacement vendor given name | +| `familyName` | string | No | Replacement vendor family name | +| `primaryEmail` | string | No | Replacement primary email address | +| `primaryPhone` | string | No | Replacement primary phone number | +| `billingAddress` | json | No | Replacement billing address | +| `printOnCheckName` | string | No | Replacement name to print on checks | +| `accountNumber` | string | No | Replacement vendor account number | +| `vendor1099` | boolean | No | Whether the vendor is tracked for 1099 reporting | +| `activeStatus` | string | No | Vendor status change: unchanged, active, or inactive | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated QuickBooks Vendor record | +| ↳ `Id` | string | QuickBooks entity ID | +| ↳ `SyncToken` | string | Entity sync token | +| ↳ `Active` | boolean | Whether the entity is active | +| ↳ `MetaData` | json | Entity creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `DisplayName` | string | Vendor display name | +| ↳ `CompanyName` | string | Vendor company name | +| ↳ `GivenName` | string | Given name | +| ↳ `FamilyName` | string | Family name | +| ↳ `PrintOnCheckName` | string | Name printed on checks | +| ↳ `Vendor1099` | boolean | Whether the vendor is tracked for 1099 reporting | +| ↳ `PrimaryEmailAddr` | json | Vendor primary email address | +| ↳ `PrimaryPhone` | json | Vendor primary phone number | +| ↳ `BillAddr` | json | Vendor billing address | +| ↳ `AcctNum` | string | Vendor account number | +| ↳ `Balance` | number | Vendor balance | +| ↳ `CurrencyRef` | json | Vendor currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | + +### QuickBooks Create Item + +Create a Service or Non-inventory item in QuickBooks Online + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | Yes | Unique item name | +| `itemType` | string | Yes | Writable item type: service or non_inventory | +| `incomeAccountId` | string | No | Sales of Product Income account ID recording proceeds from the sale. Intuit requires it for Service items except in France locales | +| `description` | string | No | Sales description | +| `unitPrice` | number | No | Sales price per unit | +| `purchaseDescription` | string | No | Purchase description | +| `purchaseCost` | number | No | Purchase cost per unit | +| `expenseAccountId` | string | No | Cost of Goods Sold account ID used to pay the vendor for this item. Intuit requires it for Service and Non-inventory items except in France locales | +| `taxable` | boolean | No | Whether the item is taxable | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created QuickBooks Item record | +| ↳ `Id` | string | QuickBooks entity ID | +| ↳ `SyncToken` | string | Entity sync token | +| ↳ `Active` | boolean | Whether the entity is active | +| ↳ `MetaData` | json | Entity creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `Name` | string | Item name | +| ↳ `Description` | string | Item sales description | +| ↳ `FullyQualifiedName` | string | Hierarchical qualified item name | +| ↳ `Taxable` | boolean | Whether the item is taxable | +| ↳ `UnitPrice` | number | Item sale price | +| ↳ `Type` | string | Item type | +| ↳ `IncomeAccountRef` | json | Item income account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `ExpenseAccountRef` | json | Item expense account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PurchaseDesc` | string | Item purchase description | +| ↳ `PurchaseCost` | number | Item purchase cost | +| ↳ `AssetAccountRef` | json | Inventory asset account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `TrackQtyOnHand` | boolean | Whether QuickBooks tracks quantity on hand | +| ↳ `QtyOnHand` | number | Current quantity on hand | +| ↳ `InvStartDate` | string | Inventory tracking start date | +| ↳ `ParentRef` | json | Parent item or category reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | + +### QuickBooks Update Item + +Read, merge, and full-update an item without changing its type + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `itemId` | string | Yes | ID of the item to update | +| `syncToken` | string | Yes | Current item sync token | +| `name` | string | No | Replacement item name | +| `incomeAccountId` | string | No | Replacement income account ID | +| `description` | string | No | Replacement sales description | +| `unitPrice` | number | No | Replacement sales price per unit | +| `purchaseDescription` | string | No | Replacement purchase description | +| `purchaseCost` | number | No | Replacement purchase cost per unit | +| `expenseAccountId` | string | No | Replacement expense account ID | +| `taxable` | boolean | No | Whether the item is taxable | +| `activeStatus` | string | No | Item status change: unchanged, active, or inactive | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated QuickBooks Item record | +| ↳ `Id` | string | QuickBooks entity ID | +| ↳ `SyncToken` | string | Entity sync token | +| ↳ `Active` | boolean | Whether the entity is active | +| ↳ `MetaData` | json | Entity creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `Name` | string | Item name | +| ↳ `Description` | string | Item sales description | +| ↳ `FullyQualifiedName` | string | Hierarchical qualified item name | +| ↳ `Taxable` | boolean | Whether the item is taxable | +| ↳ `UnitPrice` | number | Item sale price | +| ↳ `Type` | string | Item type | +| ↳ `IncomeAccountRef` | json | Item income account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `ExpenseAccountRef` | json | Item expense account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PurchaseDesc` | string | Item purchase description | +| ↳ `PurchaseCost` | number | Item purchase cost | +| ↳ `AssetAccountRef` | json | Inventory asset account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `TrackQtyOnHand` | boolean | Whether QuickBooks tracks quantity on hand | +| ↳ `QtyOnHand` | number | Current quantity on hand | +| ↳ `InvStartDate` | string | Inventory tracking start date | +| ↳ `ParentRef` | json | Parent item or category reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | + +### QuickBooks Read Sales Transactions + +List or read one estimate, invoice, sales receipt, payment, credit memo, or refund receipt + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionType` | string | Yes | Sales transaction type to read | +| `readMode` | string | Yes | Whether to list transactions or read one transaction by ID | +| `transactionId` | string | No | QuickBooks transaction ID, required for by-ID reads | +| `startPosition` | number | No | One-based position of the first list record to return | +| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `startDate` | string | No | List transactions on or after this date in YYYY-MM-DD format | +| `endDate` | string | No | List transactions on or before this date in YYYY-MM-DD format | +| `customerId` | string | No | List transactions for one QuickBooks customer ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transactionType` | string | Sales transaction type returned | +| `item` | json | Single native QuickBooks sales transaction | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| `items` | array | Native QuickBooks sales transactions | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| `recordVersion` | string | Display-safe alias for the native SyncToken on a by-ID transaction | +| `startPosition` | number | One-based position of the first item in this response | +| `maxResults` | number | Actual number of items reported for this response | +| `nextStartPosition` | number | Position to use when explicitly requesting the next page | +| `hasMore` | boolean | Conservative indication that another page may exist | +| `time` | string | QuickBooks response timestamp | + +### QuickBooks Create Estimate + +Create an estimate with bounded item and description lines + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `customerId` | string | Yes | Customer receiving the estimate | +| `lines` | json | Yes | Bounded item and description lines | +| `transactionDate` | string | No | Estimate date in YYYY-MM-DD format | +| `expirationDate` | string | No | Estimate expiration date in YYYY-MM-DD format | +| `documentNumber` | string | No | Optional estimate number | +| `privateNote` | string | No | Internal estimate note | +| `customerMemo` | string | No | Customer-facing estimate memo | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks Estimate | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Estimate + +Sparse-update an estimate using its current sync token + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | Estimate ID to update | +| `syncToken` | string | Yes | Current estimate sync token | +| `customerId` | string | No | Replacement customer ID | +| `lines` | json | No | Complete replacement set of estimate lines: any existing line omitted here is deleted from the estimate | +| `transactionDate` | string | No | Replacement estimate date in YYYY-MM-DD format | +| `expirationDate` | string | No | Replacement expiration date in YYYY-MM-DD format | +| `documentNumber` | string | No | Replacement estimate number | +| `privateNote` | string | No | Replacement internal note | +| `customerMemo` | string | No | Replacement customer-facing memo | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks Estimate | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Create Invoice + +Create an invoice without emailing or collecting payment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `customerId` | string | Yes | Customer receiving the invoice | +| `lines` | json | Yes | Bounded item and description lines | +| `transactionDate` | string | No | Invoice date in YYYY-MM-DD format | +| `dueDate` | string | No | Invoice due date in YYYY-MM-DD format | +| `documentNumber` | string | No | Optional invoice number | +| `privateNote` | string | No | Internal invoice note | +| `customerMemo` | string | No | Customer-facing invoice memo | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks Invoice | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Invoice + +Sparse-update an invoice using its current sync token + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | Invoice ID to update | +| `syncToken` | string | Yes | Current invoice sync token | +| `customerId` | string | No | Replacement customer ID | +| `lines` | json | No | Complete replacement set of invoice lines: any existing line omitted here is deleted from the invoice | +| `transactionDate` | string | No | Replacement invoice date in YYYY-MM-DD format | +| `dueDate` | string | No | Replacement due date in YYYY-MM-DD format | +| `documentNumber` | string | No | Replacement invoice number | +| `privateNote` | string | No | Replacement internal note | +| `customerMemo` | string | No | Replacement customer-facing memo | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks Invoice | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Void Invoice + +Void an invoice after explicit confirmation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | Invoice ID to void | +| `syncToken` | string | Yes | Current invoice sync token | +| `confirmVoid` | boolean | Yes | Explicit confirmation that the invoice should be voided | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `voided` | boolean | Whether QuickBooks voided the transaction | +| `record` | json | Voided native QuickBooks Invoice | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Create Sales Receipt + +Create a sales receipt for a completed customer sale + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `customerId` | string | Yes | Customer for the sales receipt | +| `lines` | json | Yes | Bounded item and description lines | +| `transactionDate` | string | No | Sales receipt date in YYYY-MM-DD format | +| `documentNumber` | string | No | Optional sales receipt number | +| `privateNote` | string | No | Internal sales receipt note | +| `customerMemo` | string | No | Customer-facing sales receipt memo | +| `paymentMethodId` | string | No | QuickBooks payment method ID | +| `paymentReferenceNumber` | string | No | Payment reference number | +| `depositAccountId` | string | No | QuickBooks deposit account ID | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks SalesReceipt | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Sales Receipt + +Sparse-update a sales receipt using its current sync token + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | Sales receipt ID to update | +| `syncToken` | string | Yes | Current sales receipt sync token | +| `customerId` | string | No | Replacement customer ID | +| `lines` | json | No | Complete replacement set of sales receipt lines: any existing line omitted here is deleted from the sales receipt | +| `transactionDate` | string | No | Replacement receipt date in YYYY-MM-DD format | +| `documentNumber` | string | No | Replacement sales receipt number | +| `privateNote` | string | No | Replacement internal note | +| `customerMemo` | string | No | Replacement customer-facing memo | +| `paymentMethodId` | string | No | Replacement payment method ID | +| `paymentReferenceNumber` | string | No | Replacement payment reference number | +| `depositAccountId` | string | No | Replacement deposit account ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks SalesReceipt | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Create Customer Payment + +Record a customer payment with optional bounded invoice allocations + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `customerId` | string | Yes | Customer making the payment | +| `totalAmount` | number | Yes | Positive total payment amount | +| `transactionDate` | string | No | Payment date in YYYY-MM-DD format | +| `privateNote` | string | No | Internal payment note | +| `paymentReferenceNumber` | string | No | Payment reference number such as a check number | +| `paymentMethodId` | string | No | QuickBooks payment method ID | +| `depositAccountId` | string | No | QuickBooks deposit account ID | +| `invoiceAllocations` | json | No | Up to 100 invoice allocations with invoiceId and positive amount | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks Payment | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Customer Payment + +Read, merge, and full-update a customer payment using its current sync token + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `paymentId` | string | Yes | Payment ID to update | +| `syncToken` | string | Yes | Current payment sync token | +| `customerId` | string | No | Replacement customer ID | +| `totalAmount` | number | No | Replacement positive payment total | +| `transactionDate` | string | No | Replacement payment date in YYYY-MM-DD format | +| `privateNote` | string | No | Replacement internal note | +| `paymentReferenceNumber` | string | No | Replacement payment reference number | +| `paymentMethodId` | string | No | Replacement payment method ID | +| `depositAccountId` | string | No | Replacement deposit account ID | +| `invoiceAllocations` | json | No | Bounded invoice allocations to apply. Each entry sets the amount applied to that invoice; invoices already applied on the payment and not listed here keep their current amounts | +| `unapplyOmittedInvoices` | boolean | No | Replace the payment allocations outright. Requires a non-empty invoiceAllocations list; every invoice not listed is UNAPPLIED and returns to open | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks Payment | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Void Customer Payment + +Void a customer payment after explicit confirmation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | Payment ID to void | +| `syncToken` | string | Yes | Current payment sync token | +| `confirmVoid` | boolean | Yes | Explicit confirmation that the payment should be voided | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `voided` | boolean | Whether QuickBooks voided the transaction | +| `record` | json | Voided native QuickBooks Payment | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Create Credit Memo + +Create a customer credit memo with bounded sales lines + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `customerId` | string | Yes | Customer receiving the credit memo | +| `lines` | json | Yes | Bounded item and description lines | +| `transactionDate` | string | No | Credit memo date in YYYY-MM-DD format | +| `documentNumber` | string | No | Optional credit memo number | +| `privateNote` | string | No | Internal credit memo note | +| `customerMemo` | string | No | Customer-facing credit memo memo | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks CreditMemo | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Credit Memo + +Read, merge, and full-update a credit memo using its current sync token + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | Credit memo ID to update | +| `syncToken` | string | Yes | Current credit memo sync token | +| `customerId` | string | No | Replacement customer ID | +| `lines` | json | No | Complete replacement set of credit memo lines: any existing line omitted here is deleted from the credit memo | +| `transactionDate` | string | No | Replacement credit memo date in YYYY-MM-DD format | +| `documentNumber` | string | No | Replacement credit memo number | +| `privateNote` | string | No | Replacement internal note | +| `customerMemo` | string | No | Replacement customer-facing memo | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks CreditMemo | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Create Refund Receipt + +Create a customer refund receipt against a required deposit account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `customerId` | string | Yes | Customer receiving the refund | +| `lines` | json | Yes | Bounded item and description lines | +| `depositAccountId` | string | Yes | QuickBooks bank account funding the refund | +| `transactionDate` | string | No | Refund receipt date in YYYY-MM-DD format | +| `documentNumber` | string | No | Optional refund receipt number | +| `privateNote` | string | No | Internal refund receipt note | +| `customerMemo` | string | No | Customer-facing refund memo | +| `paymentMethodId` | string | No | QuickBooks payment method ID | +| `paymentReferenceNumber` | string | No | Refund payment reference number | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks RefundReceipt | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Refund Receipt + +Read, merge, and full-update a refund receipt using its current sync token + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | Refund receipt ID to update | +| `syncToken` | string | Yes | Current refund receipt sync token | +| `customerId` | string | No | Replacement customer ID | +| `lines` | json | No | Complete replacement set of refund receipt lines: any existing line omitted here is deleted from the refund receipt | +| `transactionDate` | string | No | Replacement refund date in YYYY-MM-DD format | +| `documentNumber` | string | No | Replacement refund receipt number | +| `privateNote` | string | No | Replacement internal note | +| `customerMemo` | string | No | Replacement customer-facing memo | +| `paymentMethodId` | string | No | Replacement payment method ID | +| `paymentReferenceNumber` | string | No | Replacement payment reference number | +| `depositAccountId` | string | No | Replacement deposit account ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks RefundReceipt | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Read Purchasing Transactions + +List or read one purchase order, bill, bill payment, vendor credit, or purchase + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionType` | string | Yes | Purchasing transaction type to read | +| `readMode` | string | Yes | Whether to list transactions or read one transaction by ID | +| `transactionId` | string | No | QuickBooks transaction ID, required for by-ID reads | +| `startPosition` | number | No | One-based position of the first list record to return | +| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `startDate` | string | No | List transactions on or after this date in YYYY-MM-DD format | +| `endDate` | string | No | List transactions on or before this date in YYYY-MM-DD format | +| `vendorId` | string | No | List transactions for one supported QuickBooks vendor ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transactionType` | string | Purchasing transaction type returned | +| `item` | json | Single native QuickBooks purchasing transaction | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| `items` | array | Native QuickBooks purchasing transactions | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| `recordVersion` | string | Display-safe alias for the native SyncToken on a by-ID transaction | +| `startPosition` | number | One-based position of the first item in this response | +| `maxResults` | number | Actual number of items reported for this response | +| `nextStartPosition` | number | Position to use when explicitly requesting the next page | +| `hasMore` | boolean | Conservative indication that another page may exist | +| `time` | string | QuickBooks response timestamp | + +### QuickBooks Create Purchase Order + +Create a purchase order with bounded expense lines + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `vendorId` | string | Yes | Purchase-order vendor ID | +| `apAccountId` | string | Yes | Accounts-payable account ID | +| `lines` | json | Yes | Bounded account-based or item-based expense lines | +| `transactionDate` | string | No | Purchase-order date in YYYY-MM-DD format | +| `documentNumber` | string | No | Optional purchase-order number | +| `privateNote` | string | No | Internal purchase-order note | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks PurchaseOrder | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Purchase Order + +Read, merge, and full-update purchase-order header fields + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `purchaseOrderId` | string | Yes | Purchase Order ID to update | +| `syncToken` | string | Yes | Current purchase-order sync token | +| `vendorId` | string | No | Replacement vendor ID | +| `apAccountId` | string | No | Replacement accounts-payable account ID | +| `transactionDate` | string | No | Replacement date in YYYY-MM-DD format | +| `documentNumber` | string | No | Replacement purchase-order number | +| `privateNote` | string | No | Replacement internal note | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks PurchaseOrder | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Create Bill + +Create a vendor bill with optional Purchase Order line links without paying it + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `vendorId` | string | Yes | Bill vendor ID | +| `lines` | json | Yes | Bounded account-based or item-based expense lines with optional paired Purchase Order and line IDs | +| `apAccountId` | string | No | Optional accounts-payable account ID | +| `transactionDate` | string | No | Bill date in YYYY-MM-DD format | +| `dueDate` | string | No | Bill due date in YYYY-MM-DD format | +| `documentNumber` | string | No | Optional bill number | +| `privateNote` | string | No | Internal bill note | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `linkingRequested` | boolean | Whether any Purchase Order line links were requested | +| `linkingSucceeded` | boolean | Whether QuickBooks returned every requested Purchase Order line link | +| `linkedLines` | array | Requested Purchase Order line links confirmed by QuickBooks | +| ↳ `purchaseOrderId` | string | Requested Purchase Order ID | +| ↳ `purchaseOrderLineId` | string | Requested Purchase Order line ID | +| ↳ `billLineId` | string | Created Bill line ID carrying the confirmed link | +| `missingLinks` | array | Requested Purchase Order line links omitted by QuickBooks | +| ↳ `purchaseOrderId` | string | Requested Purchase Order ID | +| ↳ `purchaseOrderLineId` | string | Requested Purchase Order line ID | +| `linkingWarning` | string | Warning that the Bill was created without every requested Purchase Order link | +| `record` | json | Created native QuickBooks Bill | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Bill + +Read, merge, and full-update bill header fields using its current sync token + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `billId` | string | Yes | Bill ID to update | +| `syncToken` | string | Yes | Current bill sync token | +| `vendorId` | string | No | Replacement vendor ID; omit to preserve the current vendor | +| `apAccountId` | string | No | Replacement accounts-payable account ID | +| `transactionDate` | string | No | Replacement bill date in YYYY-MM-DD format | +| `dueDate` | string | No | Replacement due date in YYYY-MM-DD format | +| `documentNumber` | string | No | Replacement bill number | +| `privateNote` | string | No | Replacement internal note | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks Bill | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Create Bill Payment + +Record a check or credit-card payment allocated to one or more bills + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `vendorId` | string | Yes | Vendor whose bills are being paid | +| `totalAmount` | number | Yes | Positive total payment amount | +| `paymentType` | string | Yes | Check or credit-card payment type | +| `paymentAccountId` | string | Yes | Bank or credit-card account ID matching the payment type | +| `billAllocations` | json | No | Optional bounded Bill-only allocations; any unallocated amount becomes vendor credit | +| `transactionDate` | string | No | Payment date in YYYY-MM-DD format | +| `privateNote` | string | No | Internal payment note | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks BillPayment | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Bill Payment + +Read, merge, and full-update a BillPayment without changing allocations + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `billPaymentId` | string | Yes | BillPayment ID to update | +| `syncToken` | string | Yes | Current BillPayment sync token | +| `vendorId` | string | No | Replacement vendor ID; omit to preserve the current vendor | +| `transactionDate` | string | No | Replacement payment date in YYYY-MM-DD format | +| `privateNote` | string | No | Replacement internal note | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks BillPayment | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Create Vendor Credit + +Create a vendor credit without applying it to a bill + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `vendorId` | string | Yes | Vendor issuing the credit | +| `lines` | json | Yes | Bounded account-based or item-based expense lines | +| `apAccountId` | string | No | Optional accounts-payable account ID | +| `transactionDate` | string | No | Credit date in YYYY-MM-DD format | +| `documentNumber` | string | No | Optional vendor-credit number | +| `privateNote` | string | No | Internal vendor-credit note | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks VendorCredit | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Vendor Credit + +Read, merge, and full-update vendor-credit header fields + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `vendorCreditId` | string | Yes | VendorCredit ID to update | +| `syncToken` | string | Yes | Current vendor-credit sync token | +| `vendorId` | string | No | Replacement vendor ID; omit to preserve the current vendor | +| `apAccountId` | string | No | Replacement accounts-payable account ID | +| `transactionDate` | string | No | Replacement date in YYYY-MM-DD format | +| `documentNumber` | string | No | Replacement vendor-credit number | +| `privateNote` | string | No | Replacement internal note | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks VendorCredit | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Create Purchase + +Record a cash, check, or credit-card purchase with bounded expense lines + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `paymentType` | string | Yes | Cash, check, or credit-card purchase type | +| `paymentAccountId` | string | Yes | Bank or credit-card account ID matching the purchase type | +| `lines` | json | Yes | Bounded account-based or item-based expense lines | +| `vendorId` | string | No | Optional vendor payee ID | +| `transactionDate` | string | No | Purchase date in YYYY-MM-DD format | +| `paymentReference` | string | No | Optional transaction reference number, such as a check number, sent as the purchase DocNumber | +| `privateNote` | string | No | Internal purchase note | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks Purchase | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Purchase + +Read, merge, and full-update purchase header fields without changing lines + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `purchaseId` | string | Yes | Purchase ID to update | +| `syncToken` | string | Yes | Current purchase sync token | +| `vendorId` | string | No | Replacement vendor payee ID | +| `transactionDate` | string | No | Replacement purchase date in YYYY-MM-DD format | +| `paymentReference` | string | No | Replacement transaction reference number, such as a check number, sent as the purchase DocNumber | +| `privateNote` | string | No | Replacement internal note | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks Purchase | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Read Accounting Transactions + +List or read one journal entry, deposit, or transfer + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionType` | string | Yes | Accounting transaction type to read | +| `readMode` | string | Yes | Whether to list transactions or read one transaction by ID | +| `transactionId` | string | No | QuickBooks transaction ID, required for by-ID reads | +| `startPosition` | number | No | One-based position of the first list record to return | +| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `startDate` | string | No | List transactions on or after this date in YYYY-MM-DD format | +| `endDate` | string | No | List transactions on or before this date in YYYY-MM-DD format | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transactionType` | string | Accounting transaction type returned | +| `item` | json | Single native QuickBooks accounting transaction | +| ↳ `Id` | string | QuickBooks accounting transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `Adjustment` | boolean | Whether the journal entry is an adjusting entry | +| ↳ `DepositToAccountRef` | json | Account receiving a deposit | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `FromAccountRef` | json | Transfer source account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `ToAccountRef` | json | Transfer destination account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks journal or deposit lines | +| ↳ `Amount` | number | Transfer amount | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| `items` | array | Native QuickBooks accounting transactions | +| ↳ `Id` | string | QuickBooks accounting transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `Adjustment` | boolean | Whether the journal entry is an adjusting entry | +| ↳ `DepositToAccountRef` | json | Account receiving a deposit | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `FromAccountRef` | json | Transfer source account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `ToAccountRef` | json | Transfer destination account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks journal or deposit lines | +| ↳ `Amount` | number | Transfer amount | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| `recordVersion` | string | Display-safe alias for the native SyncToken on a by-ID transaction | +| `startPosition` | number | One-based position of the first item in this response | +| `maxResults` | number | Actual number of items reported for this response | +| `nextStartPosition` | number | Position to use when explicitly requesting the next page | +| `hasMore` | boolean | Conservative indication that another page may exist | +| `time` | string | QuickBooks response timestamp | + +### QuickBooks Create Journal Entry + +Post a balanced journal entry after explicit confirmation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `lines` | json | Yes | Two to 100 balanced debit and credit lines | +| `confirmPosting` | boolean | Yes | Explicit confirmation that this journal entry should be posted | +| `transactionDate` | string | No | Journal-entry date in YYYY-MM-DD format | +| `documentNumber` | string | No | Optional journal-entry number | +| `privateNote` | string | No | Internal journal-entry note | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks JournalEntry | +| ↳ `Id` | string | QuickBooks accounting transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `Adjustment` | boolean | Whether the journal entry is an adjusting entry | +| ↳ `DepositToAccountRef` | json | Account receiving a deposit | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `FromAccountRef` | json | Transfer source account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `ToAccountRef` | json | Transfer destination account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks journal or deposit lines | +| ↳ `Amount` | number | Transfer amount | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Journal Entry + +Sparse-update journal-entry header fields after explicit confirmation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `journalEntryId` | string | Yes | Journal Entry ID to update | +| `syncToken` | string | Yes | Current journal-entry sync token | +| `confirmPosting` | boolean | Yes | Explicit confirmation that this journal-entry update should be posted | +| `transactionDate` | string | No | Replacement date in YYYY-MM-DD format | +| `documentNumber` | string | No | Replacement journal-entry number | +| `privateNote` | string | No | Replacement internal note | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks JournalEntry | +| ↳ `Id` | string | QuickBooks accounting transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `Adjustment` | boolean | Whether the journal entry is an adjusting entry | +| ↳ `DepositToAccountRef` | json | Account receiving a deposit | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `FromAccountRef` | json | Transfer source account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `ToAccountRef` | json | Transfer destination account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks journal or deposit lines | +| ↳ `Amount` | number | Transfer amount | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Create Deposit + +Create a deposit with bounded account lines + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `depositAccountId` | string | Yes | Bank or asset account receiving the deposit | +| `lines` | json | Yes | One to 100 account-based deposit lines | +| `transactionDate` | string | No | Deposit date in YYYY-MM-DD format | +| `privateNote` | string | No | Internal deposit note | +| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Created native QuickBooks Deposit | +| ↳ `Id` | string | QuickBooks accounting transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `Adjustment` | boolean | Whether the journal entry is an adjusting entry | +| ↳ `DepositToAccountRef` | json | Account receiving a deposit | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `FromAccountRef` | json | Transfer source account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `ToAccountRef` | json | Transfer destination account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks journal or deposit lines | +| ↳ `Amount` | number | Transfer amount | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Update Deposit + +Sparse-update deposit header fields using the current sync token and destination account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `depositId` | string | Yes | Deposit ID to update | +| `syncToken` | string | Yes | Current deposit sync token | +| `depositAccountId` | string | Yes | Current QuickBooks account receiving the deposit | +| `transactionDate` | string | No | Replacement date in YYYY-MM-DD format | +| `privateNote` | string | No | Replacement internal note | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `record` | json | Updated native QuickBooks Deposit | +| ↳ `Id` | string | QuickBooks accounting transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `Adjustment` | boolean | Whether the journal entry is an adjusting entry | +| ↳ `DepositToAccountRef` | json | Account receiving a deposit | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `FromAccountRef` | json | Transfer source account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `ToAccountRef` | json | Transfer destination account | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks journal or deposit lines | +| ↳ `Amount` | number | Transfer amount | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Run Financial Report + +Run a fixed QuickBooks financial report with verified accountant-focused filters + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `reportType` | string | Yes | Fixed QuickBooks financial report to run | +| `startDate` | string | No | Report start date in YYYY-MM-DD format; Intuit recommends periods of six months or less for performance | +| `endDate` | string | No | Report end or as-of date in YYYY-MM-DD format | +| `accountingMethod` | string | No | Use the QuickBooks default, cash basis, or accrual basis | +| `summarizeBy` | string | No | Time period or business dimension used to summarize report columns | +| `customerId` | string | No | Single QuickBooks customer ID filter | +| `vendorId` | string | No | Single QuickBooks vendor ID filter | +| `accountId` | string | No | Single QuickBooks account ID filter | +| `itemId` | string | No | Single QuickBooks item ID filter | +| `classId` | string | No | Single QuickBooks class ID filter | +| `departmentId` | string | No | Single QuickBooks department ID filter | +| `agingMethod` | string | No | Age open balances from the report date or current date | +| `agingDays` | number | No | Positive number of days in each aging period | +| `transactionType` | string | No | Transaction type filter for Transaction List | +| `groupBy` | string | No | Grouping dimension for Transaction List | +| `accountsPayablePaid` | string | No | Accounts-payable paid status for Transaction List | +| `accountsReceivablePaid` | string | No | Accounts-receivable paid status for Transaction List | +| `clearedStatus` | string | No | Cleared status filter for Transaction List | +| `documentNumber` | string | No | Document number filter for Transaction List | +| `sourceAccountType` | string | No | Source account type filter for Transaction List | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `reportType` | string | Financial report type that was run | +| `header` | json | Native QuickBooks report header with name, periods, basis, currency, summarization, filters, and options | +| ↳ `Time` | string | QuickBooks report generation timestamp | +| ↳ `ReportName` | string | Native QuickBooks report name | +| ↳ `DateMacro` | string | QuickBooks date macro, when returned | +| ↳ `ReportBasis` | string | Cash or accrual basis | +| ↳ `StartPeriod` | string | Report start date | +| ↳ `EndPeriod` | string | Report end or as-of date | +| ↳ `SummarizeColumnsBy` | string | Dimension or time period used for report columns | +| ↳ `Currency` | string | Report currency | +| ↳ `Customer` | string | Applied customer filter | +| ↳ `Vendor` | string | Applied vendor filter | +| ↳ `Account` | string | Applied account filter | +| ↳ `Item` | string | Applied item filter | +| ↳ `Class` | string | Applied class filter | +| ↳ `Department` | string | Applied department filter | +| ↳ `Option` | array | Native QuickBooks report options, including no-data indicators when present | +| `columns` | json | Native QuickBooks report column definitions | +| ↳ `Column` | array | Native report column definitions with titles, types, and metadata | +| ↳ `ColTitle` | string | Column title | +| ↳ `ColType` | string | QuickBooks column data type | +| ↳ `MetaData` | array | Native column metadata name/value entries | +| `rows` | json | Native hierarchical QuickBooks report rows and section summaries | +| ↳ `Row` | array | Native hierarchical report rows; section rows may contain Header, nested Rows, and Summary, while data rows contain ColData values, IDs, and links | +| ↳ `type` | string | QuickBooks row type | +| ↳ `group` | string | QuickBooks section group | +| ↳ `Header` | json | Section header column data | +| ↳ `ColData` | array | Row values with optional operational IDs and links | +| ↳ `Rows` | json | Nested native QuickBooks report rows | +| ↳ `Summary` | json | Section summary column data | +| `time` | string | QuickBooks response timestamp | + +### QuickBooks Email Transaction + +Send a supported QuickBooks transaction by email. This causes an external email and Intuit limits sandbox email delivery. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionType` | string | Yes | Supported transaction type to email | +| `transactionId` | string | Yes | QuickBooks transaction ID | +| `recipient` | string | No | Required for Customer Payments; otherwise an optional single recipient override | +| `confirmSend` | boolean | Yes | Explicit confirmation that an external email should be sent | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transactionType` | string | Emailed QuickBooks transaction type | +| `transactionId` | string | Emailed QuickBooks transaction ID | +| `sent` | boolean | Whether QuickBooks accepted the email send request | +| `record` | json | Native QuickBooks transaction returned after sending | +| ↳ `Id` | string | QuickBooks transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Transaction due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks sales or purchasing transaction lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `SalesItemLineDetail` | json | Native QuickBooks sales item line details | +| ↳ `DescriptionLineDetail` | json | Native QuickBooks description line details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `POStatus` | string | Purchase order status | +| `time` | string | QuickBooks response timestamp | + +### QuickBooks Download Transaction PDF + +Download a supported QuickBooks transaction as a bounded PDF file + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionType` | string | Yes | Supported transaction type to download | +| `transactionId` | string | Yes | QuickBooks transaction ID | +| `fileName` | string | No | Optional safe PDF filename override | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | Downloaded file stored in execution files | +| `fileName` | string | Safe downloaded filename | +| `mimeType` | string | Downloaded file MIME type | +| `size` | number | Downloaded file size in bytes | +| `transactionType` | string | Downloaded QuickBooks transaction type | +| `transactionId` | string | Downloaded QuickBooks transaction ID | + +### QuickBooks Read Attachments + +List attachment metadata for a fixed QuickBooks entity or read one attachment by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `readMode` | string | Yes | Read mode: list or by_id | +| `targetType` | string | No | Fixed QuickBooks entity type for List mode | +| `targetId` | string | No | QuickBooks entity ID for List mode | +| `attachmentId` | string | No | QuickBooks attachment ID for By ID mode | +| `startPosition` | number | No | One-based list start position; defaults to 1 | +| `maxResults` | number | No | List page size from 1 through 100; defaults to 25 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `startPosition` | number | One-based position of the first item in this response | +| `maxResults` | number | Actual number of items reported for this response | +| `nextStartPosition` | number | Position to use when explicitly requesting the next page | +| `hasMore` | boolean | Conservative indication that another page may exist | +| `time` | string | QuickBooks response timestamp | +| `item` | json | Native QuickBooks attachment metadata | +| ↳ `Id` | string | QuickBooks attachment ID | +| ↳ `SyncToken` | string | Attachment sync token | +| ↳ `FileName` | string | Attached file name | +| ↳ `ContentType` | string | Attached file MIME type | +| ↳ `Size` | number | Attached file size in bytes | +| ↳ `Note` | string | Attachment note or description | +| ↳ `Category` | string | Native QuickBooks attachment category | +| ↳ `AttachableRef` | array | QuickBooks entities referenced by this attachment | +| ↳ `EntityRef` | json | Attached entity type and operational ID | +| ↳ `IncludeOnSend` | boolean | Whether QuickBooks includes the attachment when sending | +| ↳ `MetaData` | json | Attachment creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `domain` | string | QuickBooks domain | +| ↳ `sparse` | boolean | Whether this is a sparse entity | +| `items` | array | Native QuickBooks attachment metadata page | +| ↳ `Id` | string | QuickBooks attachment ID | +| ↳ `SyncToken` | string | Attachment sync token | +| ↳ `FileName` | string | Attached file name | +| ↳ `ContentType` | string | Attached file MIME type | +| ↳ `Size` | number | Attached file size in bytes | +| ↳ `Note` | string | Attachment note or description | +| ↳ `Category` | string | Native QuickBooks attachment category | +| ↳ `AttachableRef` | array | QuickBooks entities referenced by this attachment | +| ↳ `EntityRef` | json | Attached entity type and operational ID | +| ↳ `IncludeOnSend` | boolean | Whether QuickBooks includes the attachment when sending | +| ↳ `MetaData` | json | Attachment creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `domain` | string | QuickBooks domain | +| ↳ `sparse` | boolean | Whether this is a sparse entity | + +### QuickBooks Add Attachment + +Attach one supported file or one note to a fixed QuickBooks entity + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `attachmentKind` | string | Yes | Attachment kind: file or note | +| `targetType` | string | Yes | Fixed QuickBooks entity type to attach to | +| `targetId` | string | Yes | QuickBooks target entity ID | +| `file` | file | No | Single Sim file to upload | +| `fileName` | string | No | Optional safe filename override | +| `contentType` | string | No | Optional compatible QuickBooks MIME type override | +| `description` | string | No | Optional file attachment description | +| `note` | string | No | Required nonempty note text in Note mode | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `attachment` | json | Created native QuickBooks attachment metadata | +| ↳ `Id` | string | QuickBooks attachment ID | +| ↳ `SyncToken` | string | Attachment sync token | +| ↳ `FileName` | string | Attached file name | +| ↳ `ContentType` | string | Attached file MIME type | +| ↳ `Size` | number | Attached file size in bytes | +| ↳ `Note` | string | Attachment note or description | +| ↳ `Category` | string | Native QuickBooks attachment category | +| ↳ `AttachableRef` | array | QuickBooks entities referenced by this attachment | +| ↳ `EntityRef` | json | Attached entity type and operational ID | +| ↳ `IncludeOnSend` | boolean | Whether QuickBooks includes the attachment when sending | +| ↳ `MetaData` | json | Attachment creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `domain` | string | QuickBooks domain | +| ↳ `sparse` | boolean | Whether this is a sparse entity | +| `attachmentId` | string | Created QuickBooks attachment ID | +| `attachmentKind` | string | Created attachment kind | +| `targetType` | string | QuickBooks target entity type | +| `targetId` | string | QuickBooks target entity ID | +| `time` | string | QuickBooks response timestamp | + +### QuickBooks Download Attachment + +Download a QuickBooks file attachment as a stored Sim file + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `attachmentId` | string | Yes | QuickBooks attachment ID | +| `fileName` | string | No | Optional safe filename override | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | Downloaded file stored in execution files | +| `fileName` | string | Safe downloaded filename | +| `mimeType` | string | Downloaded file MIME type | +| `size` | number | Downloaded file size in bytes | +| `attachmentId` | string | Downloaded QuickBooks attachment ID | + + + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### QuickBooks Account Events + +Trigger when selected Account events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_account_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Bill Events + +Trigger when selected Bill events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_bill_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Bill Payment Events + +Trigger when selected Bill Payment events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_bill_payment_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Budget Events + +Trigger when selected Budget events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_budget_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Class Events + +Trigger when selected Class events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_class_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Credit Memo Events + +Trigger when selected Credit Memo events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_credit_memo_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Currency Events + +Trigger when selected Currency events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_currency_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Customer Events + +Trigger when selected Customer events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_customer_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Department Events + +Trigger when selected Department events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_department_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Deposit Events + +Trigger when selected Deposit events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_deposit_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Employee Events + +Trigger when selected Employee events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_employee_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Estimate Events + +Trigger when selected Estimate events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_estimate_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Invoice Events + +Trigger when selected Invoice events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_invoice_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Item Events + +Trigger when selected Item events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_item_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Journal Code Events + +Trigger when selected Journal Code events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_journal_code_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Journal Entry Events + +Trigger when selected Journal Entry events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_journal_entry_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Payment Events + +Trigger when selected Payment events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_payment_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Payment Method Events + +Trigger when selected Payment Method events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_payment_method_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Preferences Updated + +Trigger when QuickBooks Preferences are updated + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Purchase Events + +Trigger when selected Purchase events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_purchase_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Purchase Order Events + +Trigger when selected Purchase Order events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_purchase_order_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Refund Receipt Events + +Trigger when selected Refund Receipt events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_refund_receipt_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Sales Receipt Events + +Trigger when selected Sales Receipt events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_sales_receipt_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Tax Agency Events + +Trigger when selected Tax Agency events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_tax_agency_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Term Events + +Trigger when selected Term events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_term_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Time Activity Events + +Trigger when selected Time Activity events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_time_activity_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Transfer Events + +Trigger when selected Transfer events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_transfer_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Vendor Credit Events + +Trigger when selected Vendor Credit events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_vendor_credit_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Vendor Events + +Trigger when selected Vendor events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes_quickbooks_vendor_events` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + diff --git a/apps/docs/content/docs/integrations/sqs.mdx b/apps/docs/content/docs/integrations/sqs.mdx index aff6b8179e0..17c9c60c038 100644 --- a/apps/docs/content/docs/integrations/sqs.mdx +++ b/apps/docs/content/docs/integrations/sqs.mdx @@ -21,17 +21,20 @@ With Amazon SQS, you can: - **Ensure reliability**: Built-in redundancy and high availability - **Support FIFO queues**: Maintain strict message ordering and exactly-once processing -In Sim, the SQS integration enables your agents to send messages to Amazon SQS queues securely and programmatically. Supported operations include: +In Sim, the SQS integration gives your agents both sides of the queue — producing work and consuming it. Supported operations cover: -- **Send Message**: Send messages to SQS queues with optional message group ID and deduplication ID for FIFO queues +- **Messages**: Send one message or a batch of up to 10, receive with long polling, delete individually or in batches, and extend visibility timeouts while work is still in flight +- **Queues**: Create, delete, and purge queues, look up a queue URL by name, and read or update queue attributes +- **Dead-letter handling**: List the source queues feeding a dead-letter queue, then start, monitor, and cancel message move tasks to redrive failed messages back +- **Tags**: List, add, and remove queue tags for cost allocation and ownership tracking -This integration allows your agents to automate message sending workflows without manual intervention. By connecting Sim with Amazon SQS, you can build agents that publish messages to queues within your workflows—all without handling queue infrastructure or connections. +Because an agent can now drain a queue rather than only fill it, SQS becomes a way to hand work to Sim as well as from it. A workflow can long-poll a queue for jobs, process each message, delete it on success, and let the visibility timeout return anything it fails to finish — the standard reliable-consumer pattern, without running a worker of your own. {/* MANUAL-CONTENT-END */} ## Usage Instructions -Integrate Amazon SQS into the workflow. Can send messages to SQS queues. +Integrate Amazon SQS into the workflow. Send and receive messages one at a time or in batches of ten, delete messages, extend visibility timeouts, manage queues along with their attributes and tags, and redrive messages out of a dead-letter queue. @@ -49,7 +52,9 @@ Send a message to an Amazon SQS queue | `accessKeyId` | string | Yes | AWS access key ID | | `secretAccessKey` | string | Yes | AWS secret access key | | `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | -| `data` | object | Yes | Message body to send as JSON object \(e.g., \{ "action": "process", "payload": \{...\} \}\) | +| `data` | json | Yes | Message body to send as JSON object \(e.g., \{ "action": "process", "payload": \{...\} \}\) | +| `delaySeconds` | number | No | Seconds to delay delivery of this message, 0-900. Not supported per-message on FIFO queues | +| `messageAttributes` | json | No | Message attributes keyed by name, each \{ "dataType": "String" \| "Number", "stringValue": "..." \}. A custom label such as Number.float is allowed; Binary attributes are not supported | | `messageGroupId` | string | No | Message group ID for FIFO queues \(e.g., "order-processing-group"\) | | `messageDeduplicationId` | string | No | Message deduplication ID for FIFO queues \(e.g., "order-12345-v1"\) | @@ -59,5 +64,470 @@ Send a message to an Amazon SQS queue | --------- | ---- | ----------- | | `message` | string | Operation status message | | `id` | string | Message ID | +| `md5OfMessageBody` | string | MD5 digest of the message body, for verifying SQS received it intact | +| `md5OfMessageAttributes` | string | MD5 digest of the message attributes | +| `sequenceNumber` | string | Large, non-consecutive sequence number assigned by a FIFO queue | + +### SQS Send Message Batch + +Send up to 10 messages to an Amazon SQS queue in a single request + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | +| `entries` | array | Yes | Up to 10 entries, each \{ "id": "unique-id", "data": \{ ... \}, "delaySeconds"?, "messageGroupId"?, "messageDeduplicationId"?, "messageAttributes"? \} | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `successful` | array | Entries that were accepted | +| ↳ `id` | string | Id supplied for this batch entry | +| ↳ `messageId` | string | Message ID assigned by SQS | +| ↳ `md5OfMessageBody` | string | MD5 digest of the message body | +| ↳ `md5OfMessageAttributes` | string | MD5 digest of the message attributes | +| ↳ `sequenceNumber` | string | Sequence number assigned by a FIFO queue | +| `failed` | array | Entries that were rejected | +| ↳ `id` | string | Id supplied for this batch entry | +| ↳ `senderFault` | boolean | Whether the sender caused the failure | +| ↳ `code` | string | Error code for the failure | +| ↳ `message` | string | Human-readable failure message | +| `successCount` | number | Number of messages accepted | +| `failureCount` | number | Number of messages rejected | + +### SQS Receive Message + +Receive up to 10 messages from an Amazon SQS queue, with optional long polling + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | +| `maxNumberOfMessages` | number | No | Maximum number of messages to return, 1-10 \(default 1\) | +| `waitTimeSeconds` | number | No | Long-poll duration in seconds, 0-20. Waits for a message to arrive before returning \(default 0, short poll\) | +| `visibilityTimeout` | number | No | Seconds the returned messages stay hidden from other consumers, 0-43200. Defaults to the queue setting | +| `messageAttributeNames` | array | No | Names of user-defined message attributes to return. Use \["All"\] to return all of them | +| `messageSystemAttributeNames` | array | No | System attributes to return: All, SenderId, SentTimestamp, ApproximateReceiveCount, ApproximateFirstReceiveTimestamp, SequenceNumber, MessageDeduplicationId, MessageGroupId, AWSTraceHeader, DeadLetterQueueSourceArn | +| `receiveRequestAttemptId` | string | No | FIFO queues only: deduplication token that lets a retried receive return the same messages \(max 128 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `messages` | array | Received messages. Pass a receiptHandle to sqs_delete_message, sqs_delete_message_batch, sqs_change_message_visibility, or sqs_change_message_visibility_batch | +| ↳ `messageId` | string | Unique ID SQS assigned to the message | +| ↳ `receiptHandle` | string | Handle identifying this receipt of the message, required to delete it | +| ↳ `body` | string | Message body as it was sent | +| ↳ `md5OfBody` | string | MD5 digest of the message body | +| ↳ `md5OfMessageAttributes` | string | MD5 digest of the message attributes | +| ↳ `attributes` | json | Requested system attributes as string values keyed by attribute name | +| ↳ `messageAttributes` | json | Requested user-defined attributes, each with dataType, stringValue, and stringListValues | +| `count` | number | Number of messages returned | + +### SQS Delete Message + +Delete a received message from an Amazon SQS queue using its receipt handle + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | +| `receiptHandle` | string | Yes | Receipt handle returned by sqs_receive_message for the message to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### SQS Delete Message Batch + +Delete up to 10 received messages from an Amazon SQS queue in a single request + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | +| `entries` | array | Yes | Up to 10 entries, each \{ "id": "unique-id", "receiptHandle": "..." \}. Receipt handles come from sqs_receive_message | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `successful` | array | Entries that were deleted | +| ↳ `id` | string | Id supplied for this batch entry | +| `failed` | array | Entries that were rejected | +| ↳ `id` | string | Id supplied for this batch entry | +| ↳ `senderFault` | boolean | Whether the sender caused the failure | +| ↳ `code` | string | Error code for the failure | +| ↳ `message` | string | Human-readable failure message | +| `successCount` | number | Number of messages deleted | +| `failureCount` | number | Number of messages rejected | + +### SQS Change Message Visibility + +Change how long a received Amazon SQS message stays hidden from other consumers + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | +| `receiptHandle` | string | Yes | Receipt handle returned by sqs_receive_message for the message to update | +| `visibilityTimeout` | number | Yes | New visibility timeout in seconds, 0-43200 \(12 hours\). 0 makes the message immediately visible again | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### SQS Change Message Visibility Batch + +Change the visibility timeout of up to 10 received Amazon SQS messages at once + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | +| `entries` | array | Yes | Up to 10 entries, each \{ "id": "unique-id", "receiptHandle": "...", "visibilityTimeout": 0-43200 \}. Receipt handles come from sqs_receive_message | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `successful` | array | Entries that were updated | +| ↳ `id` | string | Id supplied for this batch entry | +| `failed` | array | Entries that were rejected | +| ↳ `id` | string | Id supplied for this batch entry | +| ↳ `senderFault` | boolean | Whether the sender caused the failure | +| ↳ `code` | string | Error code for the failure | +| ↳ `message` | string | Human-readable failure message | +| `successCount` | number | Number of messages updated | +| `failureCount` | number | Number of messages rejected | + +### SQS List Queues + +List Amazon SQS queue URLs in a region, optionally filtered by name prefix + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueNamePrefix` | string | No | Return only queues whose name begins with this string \(case-sensitive\) | +| `maxResults` | number | No | Maximum queues to return, 1-1000. Must be set to receive a nextToken \(default returns up to 1000\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queueUrls` | array | Queue URLs returned by the request | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of queue URLs returned | + +### SQS Get Queue URL + +Resolve an Amazon SQS queue name to its queue URL + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueName` | string | Yes | Queue name, up to 80 characters of letters, digits, hyphens and underscores. A FIFO queue name ends in .fifo | +| `queueOwnerAwsAccountId` | string | No | 12-digit AWS account ID of the queue owner, when the queue belongs to another account | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queueUrl` | string | URL of the queue | + +### SQS Get Queue Attributes + +Read configuration and message-count attributes of an Amazon SQS queue + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | +| `attributeNames` | array | No | Attributes to return, e.g. \["All"\], \["ApproximateNumberOfMessages"\], \["QueueArn"\], \["VisibilityTimeout"\], \["RedrivePolicy"\]. Omitting this returns no attributes | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `attributes` | json | Queue attributes as string values keyed by attribute name \(e.g., ApproximateNumberOfMessages, QueueArn, VisibilityTimeout, RedrivePolicy\) | + +### SQS Set Queue Attributes + +Update configuration attributes of an existing Amazon SQS queue + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | +| `attributes` | json | Yes | Attributes to set as string values, e.g. \{ "VisibilityTimeout": "60", "MessageRetentionPeriod": "345600", "RedrivePolicy": "\{...\}" \}. FifoQueue can only be set at creation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### SQS Create Queue + +Create a standard or FIFO Amazon SQS queue + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueName` | string | Yes | Queue name, up to 80 characters of letters, digits, hyphens and underscores. A FIFO queue name must end in .fifo | +| `attributes` | json | No | Queue attributes as string values, e.g. \{ "FifoQueue": "true", "VisibilityTimeout": "30", "DelaySeconds": "0", "RedrivePolicy": "\{...\}" \} | +| `tags` | json | No | Cost-allocation tags to apply to the new queue, as \{ "key": "value" \} pairs | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `queueUrl` | string | URL of the created queue | + +### SQS Delete Queue + +Delete an Amazon SQS queue and every message still in it + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### SQS Purge Queue + +Delete every message in an Amazon SQS queue while keeping the queue itself + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### SQS List Dead-Letter Source Queues + +List the Amazon SQS queues that use a given queue as their dead-letter queue + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | URL of the dead-letter queue whose source queues should be listed | +| `maxResults` | number | No | Maximum source queues to return, 1-1000. Must be set to receive a nextToken \(default returns up to 1000\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queueUrls` | array | URLs of the source queues that redrive to this dead-letter queue | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of source queues returned | + +### SQS List Queue Tags + +List the cost-allocation tags attached to an Amazon SQS queue + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tags` | json | Tags attached to the queue, as string values keyed by tag key | + +### SQS Tag Queue + +Add or overwrite cost-allocation tags on an Amazon SQS queue + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | +| `tags` | json | Yes | Tags to apply as \{ "key": "value" \} pairs. An existing key is overwritten. AWS recommends no more than 50 tags per queue | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### SQS Untag Queue + +Remove cost-allocation tags from an Amazon SQS queue + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `queueUrl` | string | Yes | SQS queue URL \(e.g., https://sqs.us-east-1.amazonaws.com/123456789012/my-queue\) | +| `tagKeys` | array | Yes | Tag keys to remove, e.g. \["env", "team"\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### SQS Start Message Move Task + +Start redriving messages out of an Amazon SQS dead-letter queue + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `sourceArn` | string | Yes | ARN of the dead-letter queue to move messages out of \(e.g., arn:aws:sqs:us-east-1:123456789012:my-dlq\) | +| `destinationArn` | string | No | ARN of the queue to move messages into. Omit to redrive each message to its original source queue | +| `maxNumberOfMessagesPerSecond` | number | No | Throttle the move to at most this many messages per second, up to 500. Omit to move as fast as possible | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `taskHandle` | string | Handle identifying the move task, accepted by sqs_cancel_message_move_task | + +### SQS List Message Move Tasks + +List the most recent message move tasks for an Amazon SQS source queue + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `sourceArn` | string | Yes | ARN of the queue whose move tasks should be listed \(e.g., arn:aws:sqs:us-east-1:123456789012:my-dlq\) | +| `maxResults` | number | No | Maximum move tasks to return, 1-10 \(default 1\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | Move tasks for the source queue | +| ↳ `taskHandle` | string | Handle of the task, populated only while its status is RUNNING | +| ↳ `status` | string | RUNNING, COMPLETED, CANCELLING, CANCELLED, or FAILED | +| ↳ `sourceArn` | string | ARN of the source queue | +| ↳ `destinationArn` | string | ARN of the destination queue, absent when redriving to source queues | +| ↳ `maxNumberOfMessagesPerSecond` | number | Per-second throttle applied to the move | +| ↳ `approximateNumberOfMessagesMoved` | number | Approximate number of messages moved so far | +| ↳ `approximateNumberOfMessagesToMove` | number | Approximate number of messages still to move | +| ↳ `failureReason` | string | Why the task failed, set only when the status is FAILED | +| ↳ `startedTimestamp` | number | Epoch milliseconds when the task started | +| `count` | number | Number of move tasks returned | + +### SQS Cancel Message Move Task + +Cancel an in-progress Amazon SQS message move task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `taskHandle` | string | Yes | Task handle returned by sqs_start_message_move_task or sqs_list_message_move_tasks. Only a RUNNING task can be cancelled | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `approximateNumberOfMessagesMoved` | number | Approximate number of messages already moved before the task was cancelled | diff --git a/apps/docs/content/docs/integrations/ssm.mdx b/apps/docs/content/docs/integrations/ssm.mdx new file mode 100644 index 00000000000..a2600c5bec3 --- /dev/null +++ b/apps/docs/content/docs/integrations/ssm.mdx @@ -0,0 +1,636 @@ +--- +title: AWS Systems Manager +description: Run commands, manage parameters, and audit managed nodes +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[AWS Systems Manager](https://aws.amazon.com/systems-manager/) is the operations hub for AWS. It gives you a single place to run commands across fleets of managed nodes, store configuration and secrets, track patch and compliance state, and execute runbooks — without opening SSH, managing bastion hosts, or distributing long-lived credentials. + +With AWS Systems Manager, you can: + +- **Run commands remotely**: Execute shell or PowerShell across a fleet by instance ID or tag-based targets, with concurrency and error thresholds you control +- **Store configuration and secrets**: Keep parameters in Parameter Store as plain strings, string lists, or KMS-encrypted SecureStrings +- **Inspect your fleet**: List managed nodes with their platform, agent version, and last ping time +- **Track patch state**: Read per-instance patch installations and summary compliance counts +- **Audit compliance**: Query compliance items and summaries across your managed nodes +- **Automate runbooks**: Start, monitor, and stop Automation executions built on SSM documents + +In Sim, the Systems Manager integration is what lets an agent act on infrastructure rather than only report on it. Paired with CloudWatch or CloudTrail for detection, a workflow can investigate an alert, run a diagnostic command against the affected nodes, read the configuration behind the failure from Parameter Store, and kick off an Automation runbook to remediate — end to end, with every step logged in your run history. + +Parameter Store decryption is opt-in: `Get Parameter`, `Get Parameters`, and `Get Parameters By Path` leave `WithDecryption` off unless you explicitly enable it, so a SecureString stays encrypted by default. + +Be precise about what that protects, because it is narrower than it looks. The value you supply to `Put Parameter` is masked in the editor and is never echoed back in the operation's result, and Sim never puts a parameter value into an error message. It is **not** kept out of the run log: block inputs are recorded, and a value typed directly into the field is recorded verbatim. Referencing an environment variable instead — `{{MY_SECRET}}` — keeps the literal out of the log, because references are restored to their placeholder before the log is written. + +Reads are exposed the same way. Once you enable decryption the plaintext is ordinary block output: it flows to downstream blocks as intended, and it is written to the run log and the execution trace like any other output. + +So enable decryption only on the steps that genuinely need the plaintext, prefer environment-variable references over typed literals when writing, and treat the run logs of any workflow that touches SecureString values as secret material. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate AWS Systems Manager into your workflow. Run commands on managed nodes, read and write Parameter Store values, inspect node inventory and patch compliance, and drive Automation runbooks. + + + +## Actions + +### SSM Send Command + +Run an SSM document on managed nodes with AWS Systems Manager Run Command + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `documentName` | string | Yes | Name of the SSM document to run \(e.g., AWS-RunShellScript\) | +| `instanceIds` | json | No | Managed node IDs to target, as an array of strings \(e.g., \["i-0123456789abcdef0"\]\). Provide instanceIds or targets | +| `targets` | json | No | Tag or resource-group targets, as an array of \{Key, Values\} objects. Provide instanceIds or targets | +| `documentVersion` | string | No | Document version to run \($LATEST, $DEFAULT, or a version number\) | +| `parameters` | json | No | Document parameters, as an object mapping each parameter name to an array of string values | +| `comment` | string | No | Comment describing the command, at most 100 characters | +| `executionTimeoutSeconds` | number | No | Seconds to wait for a node to acknowledge the command before it times out \(30-2592000\) | +| `maxConcurrency` | string | No | Number or percentage of nodes to run the command on at once \(e.g., 10 or 50%\) | +| `maxErrors` | string | No | Number or percentage of errors allowed before the command stops \(e.g., 0 or 10%\) | +| `outputS3BucketName` | string | No | S3 bucket to store command output in | +| `outputS3KeyPrefix` | string | No | S3 key prefix for stored command output | +| `serviceRoleArn` | string | No | ARN of the IAM service role Systems Manager uses to publish notifications | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commandId` | string | ID of the command; pass it to ssm_get_command_invocation or ssm_list_command_invocations to read per-node results | +| `documentName` | string | Name of the document that was run | +| `documentVersion` | string | Document version that was run | +| `comment` | string | Comment supplied with the command | +| `status` | string | Command status \(Pending, InProgress, Success, Cancelled, Failed, TimedOut, Cancelling\) | +| `statusDetails` | string | Detailed status of the command | +| `requestedDateTime` | string | When the command was requested | +| `expiresAfter` | string | When the command stops being dispatched to nodes that have not run it | +| `instanceIds` | array | Managed node IDs the command targets | +| `targets` | json | Tag or resource-group targets the command was sent to, as an array of \{key, values\} | +| `maxConcurrency` | string | Concurrency setting the command ran with | +| `maxErrors` | string | Error threshold the command ran with | +| `targetCount` | number | Number of targets the command was sent to | +| `completedCount` | number | Number of targets that have completed the command | +| `errorCount` | number | Number of targets whose command execution failed | +| `deliveryTimedOutCount` | number | Number of targets the command could not be delivered to in time | +| `executionTimeoutSeconds` | number | Acknowledgement timeout the command ran with | +| `outputS3BucketName` | string | S3 bucket command output is written to | +| `outputS3KeyPrefix` | string | S3 key prefix command output is written under | +| `outputS3Region` | string | S3 region reported for command output | +| `serviceRole` | string | IAM service role used for notifications | + +### SSM List Commands + +List Run Command executions in an AWS account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `commandId` | string | No | Return only the command with this ID | +| `instanceId` | string | No | Return only commands sent to this managed node | +| `filters` | json | No | Filters, as an array of \{key, value\} objects. Valid keys: InvokedAfter, InvokedBefore, Status, ExecutionStage, DocumentName | +| `maxResults` | number | No | Maximum number of commands to return \(1-50\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commands` | json | Commands, each with commandId, documentName, status, statusDetails, requestedDateTime, instanceIds, targets, targetCount, completedCount, and errorCount | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of commands returned | + +### SSM List Command Invocations + +List the per-node invocations of Run Command executions + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `commandId` | string | No | Return only invocations of this command | +| `instanceId` | string | No | Return only invocations on this managed node | +| `filters` | json | No | Filters, as an array of \{key, value\} objects. Valid keys: InvokedAfter, InvokedBefore, Status, DocumentName | +| `details` | boolean | No | Include per-plugin detail \(command plugins and their output\) for each invocation | +| `maxResults` | number | No | Maximum number of invocations to return \(1-50\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commandInvocations` | json | Invocations, each with commandId, instanceId, instanceName, status, statusDetails, requestedDateTime, standardOutputUrl, standardErrorUrl, and commandPlugins | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of invocations returned | + +### SSM Get Command Invocation + +Read the output and status of a Run Command execution on one managed node + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `commandId` | string | Yes | ID of the command, as returned by ssm_send_command | +| `instanceId` | string | Yes | Managed node the command ran on \(e.g., i-0123456789abcdef0\) | +| `pluginName` | string | No | Name of the document plugin to read output for; required for documents with more than one plugin | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commandId` | string | ID of the command | +| `instanceId` | string | Managed node the command ran on | +| `comment` | string | Comment supplied with the command | +| `documentName` | string | Document that was run | +| `documentVersion` | string | Document version that was run | +| `pluginName` | string | Plugin the output belongs to | +| `responseCode` | number | Exit code of the command, or -1 if it has not started | +| `executionStartDateTime` | string | When the command started running on the node | +| `executionElapsedTime` | string | How long the command ran, as an ISO 8601 duration | +| `executionEndDateTime` | string | When the command finished running on the node | +| `status` | string | Invocation status \(Pending, InProgress, Delayed, Success, Cancelled, TimedOut, Failed, Cancelling\) | +| `statusDetails` | string | Detailed status of the invocation | +| `standardOutputContent` | string | First 24000 characters of stdout; longer output is available at standardOutputUrl | +| `standardOutputUrl` | string | S3 URL of the full stdout, if S3 output was configured | +| `standardErrorContent` | string | First 8000 characters of stderr; longer output is available at standardErrorUrl | +| `standardErrorUrl` | string | S3 URL of the full stderr, if S3 output was configured | + +### SSM Cancel Command + +Cancel an in-flight Run Command execution + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `commandId` | string | Yes | ID of the command to cancel, as returned by ssm_send_command | +| `instanceIds` | array | No | Managed node IDs to cancel on \(e.g., \["i-0123456789abcdef0"\]\); omit to cancel on every targeted node | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `commandId` | string | ID of the command that was cancelled | + +### SSM Get Parameter + +Read one parameter from AWS Systems Manager Parameter Store + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `name` | string | Yes | Name of the parameter, optionally with a :version or :label suffix | +| `withDecryption` | boolean | No | Return the decrypted value of a SecureString parameter; ignored for String and StringList parameters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the parameter | +| `type` | string | Parameter type \(String, StringList, or SecureString\) | +| `value` | string | Parameter value; encrypted unless withDecryption was set for a SecureString | +| `version` | number | Version of the parameter | +| `selector` | string | Version or label selector used to read the parameter | +| `sourceResult` | string | Raw result from the source for a parameter served by another service | +| `lastModifiedDate` | string | When the parameter was last changed | +| `arn` | string | ARN of the parameter | +| `dataType` | string | Data type of the parameter \(text, aws:ec2:image, or aws:ssm:integration\) | + +### SSM Get Parameters + +Read up to ten parameters from AWS Systems Manager Parameter Store by name + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `names` | json | Yes | Parameter names to read, as an array of at most 10 strings | +| `withDecryption` | boolean | No | Return decrypted values for SecureString parameters; ignored for String and StringList parameters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `parameters` | json | Parameters that were found, each with name, type, value, version, arn, dataType, and lastModifiedDate | +| `invalidParameters` | array | Names that could not be read because they do not exist or are malformed | +| `count` | number | Number of parameters returned | + +### SSM Get Parameters By Path + +Read parameters under a Parameter Store hierarchy path + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `path` | string | Yes | Hierarchy path to read, starting with a slash \(e.g., /prod/app\) | +| `recursive` | boolean | No | Include parameters in nested paths below the given path | +| `withDecryption` | boolean | No | Return decrypted values for SecureString parameters; ignored for String and StringList parameters | +| `parameterFilters` | json | No | Filters, as an array of \{Key, Option, Values\} objects. Valid keys here: Type, KeyId, Label | +| `maxResults` | number | No | Maximum number of parameters to return \(1-10\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `parameters` | json | Parameters under the path, each with name, type, value, version, arn, dataType, and lastModifiedDate | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of parameters returned | + +### SSM Put Parameter + +Create or update a parameter in AWS Systems Manager Parameter Store + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `name` | string | Yes | Name of the parameter, optionally using a slash-separated hierarchy | +| `value` | string | Yes | Value to store | +| `type` | string | No | Parameter type \(String, StringList, or SecureString\); required when creating a new parameter | +| `description` | string | No | Description of the parameter | +| `keyId` | string | No | KMS key ID or ARN used to encrypt a SecureString parameter; defaults to the account key | +| `overwrite` | boolean | No | Overwrite the parameter if it already exists | +| `allowedPattern` | string | No | Regular expression the value must match | +| `tier` | string | No | Parameter tier \(Standard, Advanced, or Intelligent-Tiering\) | +| `dataType` | string | No | Data type of the parameter \(text, aws:ec2:image, or aws:ssm:integration\) | +| `policies` | string | No | Parameter policies as a JSON array string; Advanced tier only | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `name` | string | Name of the parameter that was written | +| `version` | number | Version number the write produced | +| `tier` | string | Tier the parameter was stored in | + +### SSM Delete Parameter + +Delete a parameter from AWS Systems Manager Parameter Store + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `name` | string | Yes | Name of the parameter to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `name` | string | Name of the parameter that was deleted | + +### SSM Describe Parameters + +List Parameter Store parameter metadata without reading any values + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `parameterFilters` | json | No | Filters, as an array of \{Key, Option, Values\} objects. Valid keys: Name, Type, KeyId, Path, Tier, DataType, or tag:<key> | +| `shared` | boolean | No | Return parameters shared with this account instead of parameters it owns | +| `maxResults` | number | No | Maximum number of parameters to return \(1-50\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `parameters` | json | Parameter metadata, each with name, arn, type, keyId, description, tier, version, dataType, allowedPattern, lastModifiedDate, lastModifiedUser, and policies. Values are never included | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of parameters returned | + +### SSM Describe Instance Information + +List managed nodes registered with AWS Systems Manager and their agent status + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `filters` | json | No | Filters, as an array of \{Key, Values\} objects. Valid keys: InstanceIds, AgentVersion, PingStatus, PlatformTypes, ActivationIds, IamRole, ResourceType, AssociationStatus, SourceIds, SourceTypes, tag-key, or tag:<key> | +| `maxResults` | number | No | Maximum number of nodes to return \(5-50\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `instances` | json | Managed nodes, each with instanceId, pingStatus, lastPingDateTime, agentVersion, isLatestVersion, platformType, platformName, platformVersion, computerName, ipAddress, iamRole, resourceType, and associationStatus | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of managed nodes returned | + +### SSM Describe Instance Patches + +List the patches reported for one managed node + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `instanceId` | string | Yes | Managed node to report patches for \(e.g., i-0123456789abcdef0\) | +| `filters` | json | No | Filters, as an array of \{Key, Values\} objects. Valid keys: Classification, KBId, Severity, State | +| `maxResults` | number | No | Maximum number of patches to return \(10-100\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `patches` | json | Patches, each with title, kbId, classification, severity, state, installedTime, and cveIds | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of patches returned | + +### SSM Describe Instance Patch States + +Read patch compliance summaries for a set of managed nodes + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `instanceIds` | json | Yes | Managed node IDs to summarize, as an array of at most 50 strings | +| `maxResults` | number | No | Maximum number of patch states to return \(10-100\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `instancePatchStates` | json | Patch states, each with instanceId, patchGroup, baselineId, operation, operationStartTime, operationEndTime, installedCount, missingCount, failedCount, notApplicableCount, criticalNonCompliantCount, and securityNonCompliantCount | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of patch states returned | + +### SSM List Compliance Items + +List individual compliance findings reported to AWS Systems Manager + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `resourceIds` | json | No | Resource to report on, as an array holding a single managed node ID | +| `resourceTypes` | json | No | Resource type to report on, as an array holding a single value; currently only ManagedInstance is supported | +| `filters` | json | No | Filters, as an array of \{Key, Values, Type\} objects. Type is one of EQUAL, NOT_EQUAL, BEGIN_WITH, LESS_THAN, GREATER_THAN | +| `maxResults` | number | No | Maximum number of compliance items to return \(1-50\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `complianceItems` | json | Compliance items, each with complianceType, resourceType, resourceId, id, title, status, severity, executionTime, executionId, executionType, and details | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of compliance items returned | + +### SSM List Compliance Summaries + +Read compliant and non-compliant counts per compliance type + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `filters` | json | No | Filters, as an array of \{Key, Values, Type\} objects. Type is one of EQUAL, NOT_EQUAL, BEGIN_WITH, LESS_THAN, GREATER_THAN | +| `maxResults` | number | No | Maximum number of summaries to return \(1-50\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `complianceSummaryItems` | json | Summaries, each with complianceType, compliantCount, compliantSeveritySummary, nonCompliantCount, and nonCompliantSeveritySummary | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of summaries returned | + +### SSM Start Automation Execution + +Start an AWS Systems Manager Automation runbook execution + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `documentName` | string | Yes | Name of the Automation runbook to run \(e.g., AWS-RestartEC2Instance\) | +| `documentVersion` | string | No | Runbook version to run \($LATEST, $DEFAULT, or a version number\) | +| `parameters` | json | No | Runbook parameters, as an object mapping each parameter name to an array of string values | +| `mode` | string | No | Execution mode, Auto or Interactive | +| `targetParameterName` | string | No | Runbook parameter that receives each resolved target; required when targets is set | +| `targets` | json | No | Rate-control target, as an array holding a single \{Key, Values\} object; requires targetParameterName | +| `maxConcurrency` | string | No | Number or percentage of targets to run against at once \(e.g., 10 or 50%\) | +| `maxErrors` | string | No | Number or percentage of errors allowed before the execution stops \(e.g., 0 or 10%\) | +| `clientToken` | string | No | Idempotency token, exactly 36 characters | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `automationExecutionId` | string | ID of the execution; pass it to ssm_get_automation_execution or ssm_stop_automation_execution | + +### SSM Describe Automation Executions + +List Automation runbook executions in an AWS account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `filters` | json | No | Filters, as an array of \{Key, Values\} objects. Valid keys: DocumentNamePrefix, ExecutionStatus, ExecutionId, ParentExecutionId, CurrentAction, StartTimeBefore, StartTimeAfter, AutomationType, TagKey, TargetResourceGroup, AutomationSubtype, OpsItemId | +| `maxResults` | number | No | Maximum number of executions to return \(1-50\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `automationExecutions` | json | Executions, each with automationExecutionId, documentName, documentVersion, automationExecutionStatus, executionStartTime, executionEndTime, executedBy, currentStepName, currentAction, failureMessage, and outputs | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of executions returned | + +### SSM Get Automation Execution + +Read the status, outputs, and step results of one Automation execution + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `automationExecutionId` | string | Yes | ID of the execution, as returned by ssm_start_automation_execution | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `automationExecutionId` | string | ID of the execution | +| `documentName` | string | Runbook that was run | +| `documentVersion` | string | Runbook version that was run | +| `automationExecutionStatus` | string | Execution status \(Pending, InProgress, Waiting, Success, TimedOut, Cancelling, Cancelled, Failed, and related values\) | +| `executionStartTime` | string | When the execution started | +| `executionEndTime` | string | When the execution finished | +| `executedBy` | string | IAM identity that started the execution | +| `mode` | string | Execution mode, Auto or Interactive | +| `parentAutomationExecutionId` | string | ID of the parent execution, for a child execution | +| `currentStepName` | string | Step the execution is currently running | +| `currentAction` | string | Action the execution is currently running | +| `failureMessage` | string | Reason the execution failed | +| `targetParameterName` | string | Runbook parameter that received each resolved target | +| `target` | string | Resource the execution targeted | +| `maxConcurrency` | string | Concurrency setting the execution ran with | +| `maxErrors` | string | Error threshold the execution ran with | +| `parameters` | json | Parameter values the execution was started with | +| `outputs` | json | Outputs the execution produced | +| `stepExecutions` | json | Steps, each with stepName, action, stepStatus, stepExecutionId, executionStartTime, executionEndTime, failureMessage, response, isEnd, and nextStep | +| `stepExecutionsTruncated` | boolean | Whether the returned step list was truncated | + +### SSM Stop Automation Execution + +Stop a running AWS Systems Manager Automation execution + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `automationExecutionId` | string | Yes | ID of the execution to stop, as returned by ssm_start_automation_execution | +| `stopType` | string | No | How to stop the execution: Cancel to stop it immediately, or Complete to let the current step finish | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `automationExecutionId` | string | ID of the execution that was stopped | + +### SSM List Documents + +List SSM documents and runbooks available to an AWS account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `filters` | json | No | Filters, as an array of \{Key, Values\} objects. Valid keys: Name, Owner, DocumentType, PlatformTypes, TargetType, or tag:<key> | +| `maxResults` | number | No | Maximum number of documents to return \(1-50\) | +| `nextToken` | string | No | Pagination token from a previous request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `documents` | json | Documents, each with name, displayName, owner, documentType, documentFormat, documentVersion, schemaVersion, platformTypes, targetType, createdDate, reviewStatus, author, and tags | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of documents returned | + +### SSM Get Document + +Read the content of an SSM document or Automation runbook + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `name` | string | Yes | Name of the document to read, as returned by ssm_list_documents | +| `documentVersion` | string | No | Document version to read \($LATEST, $DEFAULT, or a version number\) | +| `versionName` | string | No | User-defined version name to read | +| `documentFormat` | string | No | Format to return the content in: JSON, YAML, or TEXT | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the document | +| `displayName` | string | Friendly name of the document | +| `createdDate` | string | When the document was created | +| `versionName` | string | User-defined version name | +| `documentVersion` | string | Document version that was returned | +| `status` | string | Document status \(Creating, Active, Updating, Deleting, Failed\) | +| `statusInformation` | string | Detail about the document status | +| `content` | string | Content of the document in the requested format | +| `documentType` | string | Type of the document \(Command, Automation, Policy, Session, and related values\) | +| `documentFormat` | string | Format the content is returned in | +| `reviewStatus` | string | Review status of the document \(APPROVED, NOT_REVIEWED, PENDING, REJECTED\) | + + diff --git a/apps/docs/content/docs/integrations/tinyfish.mdx b/apps/docs/content/docs/integrations/tinyfish.mdx index 8afda30e85c..e1f2a9b8f6f 100644 --- a/apps/docs/content/docs/integrations/tinyfish.mdx +++ b/apps/docs/content/docs/integrations/tinyfish.mdx @@ -23,7 +23,8 @@ With TinyFish in Sim, you can: - **Automate any site, API or not**: Log into vendor portals, legacy ERPs, and internal tools that never shipped an API, and pull the data out. - **Get typed results, not scraped HTML**: Supply a JSON Schema in **Output Schema** and TinyFish holds the agent to it, re-prompting on mismatch and reporting every field that did not match in `schemaValidation`. -- **Survive bot detection**: Switch **Browser Profile** to `stealth` for anti-detection, and enable the Tetra proxy with a country when the page is geo-restricted. +- **Survive bot detection**: Switch **Browser Engine** to `stealth` for anti-detection, and enable the Tetra proxy with a country when the page is geo-restricted. +- **Stay logged in between runs**: Enable **Use Browser Profile** to start a run from a Browser Context Profile — saved browser state you log into once on TinyFish. **List Browser Profiles** returns the ids to choose from; leaving the id empty uses your default profile. Pair it with the vault so an expired session can be repaired. - **Log in safely**: Connect a password manager to TinyFish's vault, then enable **Use Vault Credentials** and scope a run to specific credential URIs. **List Vault Items** returns those URIs as display-safe metadata — labels, domains, field names — so credentials never travel through the workflow. - **Run work that outlives a step**: **Start Agent Run** queues an automation and returns a run ID immediately; **Get Run**, **Cancel Run**, and **List Runs** track it afterwards, and a webhook URL can notify you on completion. - **Read the live web cheaply**: Pair **Search** and **Fetch URLs** to gather current sources before an agent writes or answers. @@ -74,14 +75,17 @@ Run a TinyFish web agent against a website and wait for it to finish, returning | --------- | ---- | -------- | ----------- | | `url` | string | Yes | Target website URL the agent starts on | | `goal` | string | Yes | Natural-language description of what to accomplish on the website | -| `browserProfile` | string | No | Browser engine: "lite" \(standard\) or "stealth" \(anti-detection\) | +| `browserProfile` | string | No | Browser engine: "lite" \(standard\) or "stealth" \(anti-detection\). Not a Browser Context Profile — use useProfile for saved logins | | `agentMode` | string | No | Agent behavior: "default" or "strict" \(fail fast\) | | `maxSteps` | number | No | Maximum tool-call steps before the agent stops \(1-500, default 150\) | +| `maxDurationSeconds` | number | No | Maximum wall-clock seconds before the agent stops. Unlimited by default, so a run stalled on a slow page is only bounded by the step cap | | `outputSchema` | json | No | JSON Schema draft-07 contract the run result must satisfy | | `proxyEnabled` | boolean | No | Route the run through TinyFish’s Tetra proxy | | `proxyCountryCode` | string | No | Proxy country: US, GB, CA, DE, FR, JP, or AU | | `useVault` | boolean | No | Let the run use credentials from the connected TinyFish vault | | `credentialItemIds` | string | No | Comma-separated vault credential URIs to scope the run to | +| `useProfile` | boolean | No | Start the run from a saved Browser Context Profile, reusing the logged-in session stored in it | +| `profileId` | string | No | Browser Context Profile to start from, such as "prof_abc123". Requires useProfile; omit to use the account default | | `apiKey` | string | Yes | TinyFish API key | #### Output @@ -109,6 +113,10 @@ Run a TinyFish web agent against a website and wait for it to finish, returning | ↳ `retryAfter` | number | Suggested retry delay in seconds, null when not retryable | | ↳ `helpUrl` | string | Troubleshooting documentation URL | | ↳ `helpMessage` | string | Human-readable guidance | +| `profileHint` | object | Present when TinyFish believes a Browser Context Profile would fix this failed run, such as one that stopped at a login wall | +| ↳ `message` | string | Why a Browser Context Profile would help this run | +| ↳ `setupUrl` | string | Path on the TinyFish dashboard that sets up a profile for the blocked domain | +| ↳ `reason` | string | auth_wall \(the run hit a login\) or bot_challenge \(the site blocked automation\) | ### TinyFish Start Agent Run @@ -120,14 +128,17 @@ Queue a TinyFish web agent run and return its run ID immediately, without waitin | --------- | ---- | -------- | ----------- | | `url` | string | Yes | Target website URL the agent starts on | | `goal` | string | Yes | Natural-language description of what to accomplish on the website | -| `browserProfile` | string | No | Browser engine: "lite" \(standard\) or "stealth" \(anti-detection\) | +| `browserProfile` | string | No | Browser engine: "lite" \(standard\) or "stealth" \(anti-detection\). Not a Browser Context Profile — use useProfile for saved logins | | `agentMode` | string | No | Agent behavior: "default" or "strict" \(fail fast\) | | `maxSteps` | number | No | Maximum tool-call steps before the agent stops \(1-500, default 150\) | +| `maxDurationSeconds` | number | No | Maximum wall-clock seconds before the agent stops. Unlimited by default, so a run stalled on a slow page is only bounded by the step cap | | `outputSchema` | json | No | JSON Schema draft-07 contract the run result must satisfy | | `proxyEnabled` | boolean | No | Route the run through TinyFish’s Tetra proxy | | `proxyCountryCode` | string | No | Proxy country: US, GB, CA, DE, FR, JP, or AU | | `useVault` | boolean | No | Let the run use credentials from the connected TinyFish vault | | `credentialItemIds` | string | No | Comma-separated vault credential URIs to scope the run to | +| `useProfile` | boolean | No | Start the run from a saved Browser Context Profile, reusing the logged-in session stored in it | +| `profileId` | string | No | Browser Context Profile to start from, such as "prof_abc123". Requires useProfile; omit to use the account default | | `apiKey` | string | Yes | TinyFish API key | | `webhookUrl` | string | No | HTTPS URL notified when the run completes, fails, or is cancelled | @@ -179,6 +190,12 @@ Get the status, extracted result, and step history of a TinyFish automation run | `browserConfig` | object | Proxy settings the run executed with | | ↳ `proxyEnabled` | boolean | Whether a proxy was used | | ↳ `proxyCountryCode` | string | Proxy country code | +| `profileAttached` | boolean | Whether the run actually started from a Browser Context Profile, null when the API omits it — treat null as unknown rather than as false | +| `profileId` | string | Browser Context Profile the run attached. Null covers both no profile and a payload that omitted the field, so read profileAttached alongside it rather than reading null as proof | +| `profileHint` | object | Present when TinyFish believes a Browser Context Profile would fix this failed run | +| ↳ `message` | string | Why a Browser Context Profile would help this run | +| ↳ `setupUrl` | string | Path on the TinyFish dashboard that sets up a profile for the blocked domain | +| ↳ `reason` | string | auth_wall \(the run hit a login\) or bot_challenge \(the site blocked automation\) | | `videoUrl` | string | Presigned recording URL, expires 15 minutes after it is issued | | `steps` | array | Steps the agent took during the run | | ↳ `id` | string | Step identifier | @@ -256,6 +273,12 @@ List TinyFish automation runs, optionally filtered by status, goal text, or crea | ↳ `browserConfig` | object | Proxy settings the run executed with | | ↳ `proxyEnabled` | boolean | Whether a proxy was used | | ↳ `proxyCountryCode` | string | Proxy country code | +| ↳ `profileAttached` | boolean | Whether the run actually started from a Browser Context Profile, null when the API omits it — treat null as unknown rather than as false | +| ↳ `profileId` | string | Browser Context Profile the run attached. Null covers both no profile and a payload that omitted the field, so read profileAttached alongside it rather than reading null as proof | +| ↳ `profileHint` | object | Present when TinyFish believes a Browser Context Profile would fix this failed run | +| ↳ `message` | string | Why a Browser Context Profile would help this run | +| ↳ `setupUrl` | string | Path on the TinyFish dashboard that sets up a profile for the blocked domain | +| ↳ `reason` | string | auth_wall \(the run hit a login\) or bot_challenge \(the site blocked automation\) | | `total` | number | Total runs matching the filters | | `nextCursor` | string | Cursor for the next page, null when there are no more results | | `hasMore` | boolean | Whether more results follow this page | @@ -347,4 +370,28 @@ List the credentials available from password managers connected to TinyFish, wit | ↳ `type` | string | STRING, CONCEALED, or OTP | | ↳ `hasTotp` | boolean | Whether the credential carries a TOTP secret | +### TinyFish List Browser Profiles + +List the Browser Context Profiles saved on the TinyFish account, with the ids an agent run can start from to reuse a logged-in session + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | TinyFish API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `profiles` | array | Browser Context Profiles an agent run can start from | +| ↳ `profileId` | string | Profile identifier, used as the Browser Profile ID on a run | +| ↳ `name` | string | Profile name, such as "Salesforce Production" | +| ↳ `proxyCountryCode` | string | Country the profile proxies through, null when it has no proxy | +| ↳ `fingerprintSeed` | string | Seed for the browser fingerprint the profile replays, null when the API omits it | +| ↳ `domainCount` | number | How many domains the profile holds saved state for, null when the API omits it. Zero means it was created but never logged into | +| ↳ `createdAt` | string | ISO 8601 timestamp when the profile was created, null when the API omits it | +| ↳ `updatedAt` | string | ISO 8601 timestamp when the profile was last saved, null when the API omits it | +| ↳ `isDefault` | boolean | Whether runs with no Browser Profile ID use this one, null when the API omits it | + diff --git a/apps/docs/content/docs/keyboard-shortcuts/index.mdx b/apps/docs/content/docs/keyboard-shortcuts/index.mdx index e541c05841a..feffd0a6a83 100644 --- a/apps/docs/content/docs/keyboard-shortcuts/index.mdx +++ b/apps/docs/content/docs/keyboard-shortcuts/index.mdx @@ -5,7 +5,7 @@ description: Keyboard and mouse shortcuts for the workflow editor and for tables import { Callout } from 'fumadocs-ui/components/callout' -Sim has keyboard shortcuts for the workflow editor and for tables. Each set works when that surface is focused and you are not typing in a field. +Sim has keyboard shortcuts for the workflow editor and for tables. Each set works when that surface is focused and you are not typing in a field. The macOS app adds its own window, tab, and navigation shortcuts on top of these — see [Sim Desktop](/desktop#keyboard-shortcuts). **Mod** is `Cmd` on macOS and `Ctrl` on Windows and Linux. diff --git a/apps/docs/content/docs/knowledgebase/index.mdx b/apps/docs/content/docs/knowledgebase/index.mdx index a2d79a0d58a..3b7940516b5 100644 --- a/apps/docs/content/docs/knowledgebase/index.mdx +++ b/apps/docs/content/docs/knowledgebase/index.mdx @@ -23,6 +23,8 @@ When you upload a document, Sim processes it in the background: A document is searchable once its status reads `completed`. Open any document to view, edit, merge, or split its chunks. +Every knowledge base records the embedding model and the vector width it was built with, and keeps them for its lifetime. Two bases built differently cannot be searched in one request — vectors are only comparable when they come from the same model at the same size — so search them separately, or rebuild one to match. On Sim Cloud every base uses `text-embedding-3-small` at 1,536 dimensions; self-hosted deployments choose both with [`KB_EMBEDDING_MODEL` and `EMBEDDING_OUTPUT_DIMS`](/platform/self-hosting/environment-variables), including models on their own Ollama. + ## What you can upload Sim accepts PDF, Word, text, Markdown, HTML, Excel, PowerPoint, CSV, JSON, and YAML files, up to 100 MB each (best under 50 MB). Scanned PDFs work too: with Azure or [Mistral OCR](https://docs.mistral.ai/ocr/) configured, Sim extracts text from image-based pages. diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 826e392a3ff..fd86838d469 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -4,6 +4,7 @@ "---Get Started---", "./introduction/index", "./getting-started/index", + "./desktop/index", "---Build---", "chat", "workflows", diff --git a/apps/docs/content/docs/platform/self-hosting/desktop.mdx b/apps/docs/content/docs/platform/self-hosting/desktop.mdx index 8452a43628b..43c153a16a3 100644 --- a/apps/docs/content/docs/platform/self-hosting/desktop.mdx +++ b/apps/docs/content/docs/platform/self-hosting/desktop.mdx @@ -1,11 +1,13 @@ --- -title: Desktop App +title: Desktop App on Your Deployment description: Point the macOS desktop app at your own Sim deployment --- import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' +This page is about pointing the app at a deployment you run. For installing and using it, see [Sim Desktop](/desktop). + The Sim desktop app is a native macOS shell around a Sim deployment. It is **not** tied to sim.ai — the build bakes in only a *default* server, and every runtime boundary (navigation, content security policy, cookie storage, the update feed) is derived from the server you point it at. So self-hosting the desktop app takes no build of your own: install the same signed, notarized app everyone else installs, then point it at your deployment. @@ -23,7 +25,7 @@ Every Sim deployment exposes two public endpoints: | `/api/desktop/update/download` | Redirects (302) to the newest installer for this deployment's release channel | | `/api/desktop/update/latest-mac.yml` | The update manifest installed apps poll | -Both resolve against Sim's public GitHub releases, and the installers themselves are downloaded from GitHub. Nothing is built, signed, or hosted by you: your deployment decides *which* release its clients are offered and serves the manifest, so installed apps poll your server instead of sim.ai — but they cannot be served artifacts of your own from this path. To ship your own build, see [Building your own shell](#building-your-own-shell). +Both resolve against Sim's public GitHub releases, and the installers themselves are downloaded from GitHub. Nothing is built, signed, or hosted by you: your deployment decides *which* release its clients are offered and serves the manifest, so installed apps poll your server instead of sim.ai — but they cannot be served artifacts of your own from this path, and a self-updating stable build falls back to Sim's own feed if yours stops answering. To ship your own build, see [Building your own shell](#building-your-own-shell). Both endpoints cache their GitHub lookups for **5 minutes**, and both respond the same way when they cannot answer: @@ -35,6 +37,12 @@ Both endpoints cache their GitHub lookups for **5 minutes**, and both respond th The Sim server needs outbound access to `api.github.com` and `github.com` for these to resolve. Unauthenticated GitHub API requests are capped at 60/hour per IP; set `GITHUB_TOKEN` on the Sim server to raise that to 5000/hour. +### What an installed app does when the feed fails + +A `404` carries a header the shell reads as *no update available*, so the app reports it is up to date. Anything else — `502`, a timeout, a connection failure — means the feed is unavailable, and only a self-updating build (Developer ID, installed in `/Applications`) has anywhere else to go: on the stable channel it falls back to Sim's packaged GitHub feed, and on `dev` or `staging` it skips the check, since that fallback carries only stable artifacts their bundle identity cannot install. Every other build simply stops being offered updates until the feed answers again. + +Release selection is therefore yours only while the feed answers. With `APPCONFIG_ENVIRONMENT` unset both paths resolve the same release, so the fallback is invisible; it matters when your deployment serves a channel that disagrees with stable. + ### Which channel your deployment serves The channel is chosen by `APPCONFIG_ENVIRONMENT`, not by whether you are self-hosting: diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index 53a241d7d41..8dbcf1ba31a 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -55,7 +55,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `VERTEX_PROJECT` / `VERTEX_LOCATION` | Google Vertex AI — GCP project ID and region (region defaults to `us-central1`) | - **Knowledge bases require a hosted embedding provider.** Three are supported, selected with `KB_EMBEDDING_MODEL`: `text-embedding-3-small` (default) and `text-embedding-3-large` on OpenAI or Azure OpenAI, and `gemini-embedding-001` on Gemini. There is no local embedding backend — configuring Ollama or vLLM does not substitute, because embeddings do not route through the configured chat model. + **Knowledge bases need an embedding model, selected with `KB_EMBEDDING_MODEL`.** Hosted: `text-embedding-3-small` (default) and `text-embedding-3-large` on OpenAI or Azure OpenAI, and `gemini-embedding-001` on Gemini. Local: any embedding model on your own Ollama, named `ollama/` (for example `ollama/nomic-embed-text`) with `OLLAMA_URL` set. Ollama serves embeddings only through this variable — embeddings never route through the configured chat model, so vLLM and LM Studio do not substitute. @@ -190,10 +190,43 @@ See [Observability](/platform/self-hosting/observability). | Variable | Description | |----------|-------------| -| `KB_EMBEDDING_MODEL` | Embedding model for new knowledge bases. Defaults to `text-embedding-3-small`; an unsupported value falls back to the default | +| `KB_EMBEDDING_MODEL` | Embedding model for new knowledge bases. Defaults to `text-embedding-3-small`; use `ollama/` for a model on your own Ollama. An unrecognised hosted model id falls back to the default, but an `ollama/` id is taken at face value — if that model is not on the server, knowledge-base creation fails rather than falling back | +| `EMBEDDING_OUTPUT_DIMS` | Vector width new knowledge bases are stored at: `384`, `768`, `1024`, `1536` (default), or `3072`. It must be a width the chosen model can emit; anything else falls back to `1536` with a warning | | `OPENROUTER_API_KEY` | Fallback route for the OpenAI embedding models — used when it is set and `OPENAI_API_KEY` is not the chosen path | | `COHERE_API_KEY` | Enables the Knowledge block reranker | +Both variables apply at creation time and are recorded on the knowledge base, so changing either +affects new knowledge bases only. Existing ones keep the model and width they were built with, and +knowledge bases with different settings cannot be searched together. + +Matching the width to the model is yours to get right for a hosted provider: Sim knows only the +widths each one documents, so a width that model cannot emit falls back to `1536` with a warning. +For Ollama it is checked — leave `EMBEDDING_OUTPUT_DIMS` unset and Sim reads the model's width from +your server, refusing to create the knowledge base if it cannot; set it explicitly and a mismatch +surfaces as a failed document instead. Common pairings: + +| `EMBEDDING_OUTPUT_DIMS` | Works with | +|----------|-------------| +| `384` | `ollama/all-minilm` | +| `768` | `ollama/nomic-embed-text`, `ollama/embeddinggemma`, `text-embedding-3-small`, `gemini-embedding-001` | +| `1024` | `ollama/mxbai-embed-large`, `ollama/bge-m3`, `text-embedding-3-small`, `text-embedding-3-large` | +| `1536` | `text-embedding-3-small`, `text-embedding-3-large`, `gemini-embedding-001` | +| `3072` | `text-embedding-3-large`, `gemini-embedding-001` | + +On a Compose install or source checkout, `sim-setup add knowledge-embeddings` walks through all of +this — pick OpenAI, Azure OpenAI, OpenRouter, Gemini, or Ollama and it writes the variables that +family needs. The three OpenAI-family transports share the same model and width prompts. +`sim-setup config` then reports the one family your `KB_EMBEDDING_MODEL` actually selects, rather +than every provider you happen to hold a key for. Helm releases set these values through your own +chart values instead, and an Ollama server is yours to run either way — `sim-setup` configures Sim +to reach one, never installs it. + +The Embeddings block reads the same `OLLAMA_URL`. It lists the models on that server that report an +embedding capability, with the width each one emits where Ollama reports it, so a workflow can embed +locally without an API key. Ollama only began reporting capabilities in 0.5 — against an older +server nothing can be filtered, so the list includes chat models and labels none of them with a +width. Check what you pick there, or upgrade Ollama. + ## Chat & PII | Variable | Description | diff --git a/apps/docs/content/docs/platform/self-hosting/index.mdx b/apps/docs/content/docs/platform/self-hosting/index.mdx index ce75aa68058..7cbfc77249b 100644 --- a/apps/docs/content/docs/platform/self-hosting/index.mdx +++ b/apps/docs/content/docs/platform/self-hosting/index.mdx @@ -109,7 +109,7 @@ Sim is self-contained for the core editor and execution engine. A few features r | Feature | Requires | Notes | |---|---|---| -| **Knowledge bases** | An OpenAI, Azure OpenAI, or Gemini API key | Embeddings are generated by a hosted provider, selected with `KB_EMBEDDING_MODEL` (`text-embedding-3-small` by default). There is no local embedding backend — knowledge bases are unavailable without one of these keys. | +| **Knowledge bases** | An OpenAI, Azure OpenAI, or Gemini API key, or an Ollama server | Embeddings are generated by the model named in `KB_EMBEDDING_MODEL` (`text-embedding-3-small` by default). Set it to `ollama/` with `OLLAMA_URL` to embed locally instead. `EMBEDDING_OUTPUT_DIMS` sets the vector width. | | **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, LM Studio, or LiteLLM. | | **Chat module** | `COPILOT_API_KEY` from sim.ai | Set `NEXT_PUBLIC_CHAT_DISABLED=true` to hide the module instead. | | **Integrations** | Your own OAuth app per service | See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). | diff --git a/apps/docs/content/docs/platform/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/platform/self-hosting/troubleshooting.mdx index 0e07620595a..1bfa5e25130 100644 --- a/apps/docs/content/docs/platform/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/platform/self-hosting/troubleshooting.mdx @@ -250,7 +250,9 @@ The load balancer's backend timeout is closing them. On GKE, attach a `BackendCo ## Knowledge base upload fails -Embeddings need a hosted provider — set `OPENAI_API_KEY`, configure Azure OpenAI, or set `KB_EMBEDDING_MODEL=gemini-embedding-001` with a Gemini key. There is no local embedding backend, so Ollama or vLLM does not substitute. If a key is set, verify pgvector is installed on the database. +Embeddings need a provider — set `OPENAI_API_KEY`, configure Azure OpenAI, set `KB_EMBEDDING_MODEL=gemini-embedding-001` with a Gemini key, or set `KB_EMBEDDING_MODEL=ollama/` with `OLLAMA_URL` to embed on your own Ollama. On a self-hosted deployment `OPENROUTER_API_KEY` also serves the OpenAI-family models on its own, as a fallback behind any OpenAI or Azure credentials you have. If one is configured, verify pgvector is installed on the database. + +A document that fails with `vector 0 has N unexpected dimensions` means `EMBEDDING_OUTPUT_DIMS` does not match what the model actually emits. The message names both widths. If the width the model returned is one of `384`, `768`, `1024`, `1536`, or `3072`, set the variable to it and recreate the knowledge base. If it is anything else, no column can store it — choose a model that emits one of those five instead, since setting an unstorable width silently falls back to `1536` and the next document fails the same way. Existing knowledge bases keep the width they were created with. ## Credentials Unreadable After a Restore diff --git a/apps/docs/content/docs/platform/self-hosting/verify.mdx b/apps/docs/content/docs/platform/self-hosting/verify.mdx index 49d6c020b68..f902981ac2e 100644 --- a/apps/docs/content/docs/platform/self-hosting/verify.mdx +++ b/apps/docs/content/docs/platform/self-hosting/verify.mdx @@ -47,7 +47,7 @@ The full command list is in the [Docker guide](/platform/self-hosting/docker#the | 5 | Paste a model API key in settings and run a two-block workflow | Execution engine, credential encryption, outbound network | App logs; check `ENCRYPTION_KEY` is set and outbound egress is allowed | | 6 | Upload a small file in Files | File storage end to end | With object storage configured: presigned URL + bucket CORS. On local disk: the upload proxies through the app | | 7 | Upload a file larger than 50 MB | Multipart upload path (object storage only) | Check app logs for provider part-listing or completion errors | -| 8 | Create a knowledge base and upload a PDF | Document parsing, embeddings, pgvector | Needs a hosted embedding provider — see below | +| 8 | Create a knowledge base and upload a PDF | Document parsing, embeddings, pgvector | Needs an embedding provider — see below | | 9 | Invite a teammate from workspace settings | Email delivery | App logs for the mailer; see [Email](/platform/self-hosting/email) | | 10 | Connect an integration account | OAuth configuration | Redirect URI mismatch → see [Integrations & OAuth](/platform/self-hosting/integrations-oauth) | | 11 | Create a workflow with a Schedule trigger set to every minute, deploy it, wait 2 minutes | **Background jobs** | Check the scheduler's logs — see [Background Jobs](/platform/self-hosting/background-jobs) | @@ -102,7 +102,7 @@ All six should be present on Compose: `simstudio`, `realtime`, `db`, `redis`, `c **Step 6 or 7 fails.** With object storage configured, a CORS error in the browser console means the bucket policy does not allow your Sim origin or the signed upload headers. If step 7 fails only during completion, check the app logs and verify the server identity can list multipart parts (for S3, `s3:ListMultipartUploadParts`). On local-disk storage there is no CORS involved — uploads proxy through the app, so look at the app logs and the proxy body-size limit instead. -**Step 8 fails — knowledge base upload errors.** Knowledge bases need a hosted embedding provider — OpenAI, Azure OpenAI, or Gemini. There is no local embedding backend. If a key is set, check pgvector is installed on the database. +**Step 8 fails — knowledge base upload errors.** Knowledge bases need an embedding provider: OpenAI, Azure OpenAI, or Gemini with an API key, or a model on your own Ollama via `KB_EMBEDDING_MODEL=ollama/` and `OLLAMA_URL`. If one is configured, check pgvector is installed on the database. **Step 9 fails — no email arrives.** With no provider configured the mailer no-ops: it records the recipient, subject, and sender at `info` and reports success, never the message body. Raise `LOG_LEVEL` to `INFO` to see that line — the variable is uppercase-only, and at the production default of `ERROR` nothing is logged at all. diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index 7b29b8e7e00..c0acf1714d8 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -108,8 +108,8 @@ Live tool-call chips stream for **OpenAI, Anthropic, Azure Anthropic, Google, Ve | Provider | Streamed thinking | Models | |----------|-------------------|--------| -| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | -| Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5-1`, `claude-fable-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-opus-4-1`, `claude-sonnet-4-5`, `claude-haiku-4-5` | +| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | +| Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5-1`, `claude-fable-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5` | | Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | | Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | | Google | Summaries only | `gemini-3.8-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index d6556294305..c2ff564af75 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -7498,6 +7498,90 @@ "additionalProperties": false }, "description": "Authorization servers available for this OAuth service." + }, + "fields": { + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact create-body field name." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable field label." + }, + "placeholder": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Suggested input placeholder." + }, + "required": { + "type": "boolean", + "description": "Whether the field is required for the selected flow." + }, + "secret": { + "type": "boolean", + "description": "Whether the submitted field is write-only secret material." + }, + "multiline": { + "type": "boolean", + "description": "Whether the field is intended for multi-line input." + }, + "requiredForAuthMethods": { + "description": "Authentication methods for which this field is required.", + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "options": { + "description": "Fixed values accepted by a selector field.", + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Submitted option value." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable option label." + } + }, + "required": ["value", "label"], + "additionalProperties": false + } + }, + "hint": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false + }, + "description": "Write-only setup fields required before starting this OAuth flow." } }, "required": [ @@ -7508,7 +7592,8 @@ "providerFamily", "available", "supportsReconnect", - "authorizationOptions" + "authorizationOptions", + "fields" ], "additionalProperties": false }, @@ -7710,6 +7795,7 @@ "providerFamily": "salesforce", "available": true, "supportsReconnect": true, + "fields": [], "authorizationOptions": [ { "providerId": "salesforce", @@ -7886,29 +7972,147 @@ "CreateCredentialConnectionBody": { "anyOf": [ { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that will own the credential." - }, - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact OAuth provider ID returned by credential-provider discovery." + "anyOf": [ + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + }, + "providerId": { + "type": "string", + "const": "quickbooks", + "description": "QuickBooks OAuth provider ID returned by credential-provider discovery." + }, + "oauthClientConfig": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Client ID for the caller-managed Intuit OAuth application." + }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Write-only client secret for the caller-managed Intuit OAuth application.", + "writeOnly": true + }, + "environment": { + "type": "string", + "enum": ["sandbox", "production"], + "description": "Intuit company environment used for authorization and API requests." + }, + "webhookVerifierToken": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Write-only verifier token for webhook signatures from the caller-managed app.", + "writeOnly": true + } + }, + "required": ["clientId", "clientSecret", "environment", "webhookVerifierToken"], + "additionalProperties": false, + "description": "Write-only caller-managed Intuit OAuth app configuration." + } + }, + "required": ["workspaceId", "displayName", "providerId", "oauthClientConfig"], + "additionalProperties": false }, - "displayName": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Name shown for the new credential in Sim." + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + }, + "providerId": { + "type": "string", + "enum": [ + "google-email", + "google-drive", + "google-docs", + "google-sheets", + "google-forms", + "google-calendar", + "google-contacts", + "google-ads", + "google-bigquery", + "google-tasks", + "google-vault", + "google-groups", + "google-chat", + "google-meet", + "vertex-ai", + "microsoft-ad", + "microsoft-dataverse", + "microsoft-excel", + "microsoft-planner", + "microsoft-teams", + "microsoft-word", + "outlook", + "onedrive", + "sharepoint", + "x", + "tiktok", + "confluence", + "jira", + "airtable", + "bitbucket", + "notion", + "clickup", + "linear", + "manageengine-sdp", + "monday", + "box", + "dropbox", + "shopify", + "slack", + "reddit", + "wealthbox", + "webflow", + "trello", + "asana", + "attio", + "calcom", + "docusign", + "pipedrive", + "hubspot", + "linkedin", + "instagram", + "salesforce", + "salesforce-sandbox", + "zoho-desk", + "zoom", + "wordpress", + "spotify" + ], + "description": "Exact OAuth provider ID returned by credential-provider discovery." + } + }, + "required": ["workspaceId", "displayName", "providerId"], + "additionalProperties": false } - }, - "required": ["workspaceId", "providerId", "displayName"], - "additionalProperties": false + ] }, { "type": "object", @@ -7923,7 +8127,40 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Existing OAuth credential to reconnect in place." + "description": "Existing OAuth credential to reconnect in place. QuickBooks reconnects also require oauthClientConfig with the Intuit client ID, client secret, environment, and webhook verifier token." + }, + "oauthClientConfig": { + "description": "Write-only Intuit OAuth app configuration. Required when credentialId identifies a QuickBooks credential; omit it for other providers.", + "type": "object", + "properties": { + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Client ID for the caller-managed Intuit OAuth application." + }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Write-only client secret for the caller-managed Intuit OAuth application.", + "writeOnly": true + }, + "environment": { + "type": "string", + "enum": ["sandbox", "production"], + "description": "Intuit company environment used for authorization and API requests." + }, + "webhookVerifierToken": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Write-only verifier token for webhook signatures from the caller-managed app.", + "writeOnly": true + } + }, + "required": ["clientId", "clientSecret", "environment", "webhookVerifierToken"], + "additionalProperties": false } }, "required": ["workspaceId", "credentialId"], diff --git a/apps/docs/public/static/desktop/sim-desktop.png b/apps/docs/public/static/desktop/sim-desktop.png new file mode 100644 index 00000000000..0e09b392b84 Binary files /dev/null and b/apps/docs/public/static/desktop/sim-desktop.png differ diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 78b86958d99..4a90594d62c 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "type": "module", "engines": { - "bun": ">=1.3.14", + "bun": ">=1.4.1", "node": ">=20.0.0" }, "scripts": { diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index 4ba34c83de1..c0878d90129 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -233,12 +233,13 @@ describe('setupWorkspaceFileDocHandlers', () => { ) }) - it('rejects a payload missing the file id or client id before authorizing', async () => { + it('rejects a payload with a missing or out-of-range client id before authorizing', async () => { const { io } = createIo() const { socket, handlers } = setup('socket-1', io) await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: '', clientId: 1 }) await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1' }) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 0x1_0000_0000 }) expect(socket.emit).toHaveBeenCalledWith( FILE_DOC_EVENTS.JOIN_ERROR, @@ -545,6 +546,10 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(socket.join).toHaveBeenCalledWith(ROOM_NAME) expect(joinSuccessFileId(socket)).toBe('file-1') + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_SUCCESS, + expect.objectContaining({ fileId: 'file-1', clientId: 1 }) + ) // A binary sync-step-1 message (type tag 0) is sent to kick off the handshake. const syncMessage = socket.emit.mock.calls.find( @@ -843,7 +848,7 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(2) }) - it('relays a document update to the rest of the room, excluding the sender', async () => { + it('relays a document update to every provider, including siblings on the sender socket', async () => { const { io, sent } = createIo() const a = setup('socket-a', io) const b = setup('socket-b', io) @@ -860,7 +865,7 @@ describe('setupWorkspaceFileDocHandlers', () => { const relayed = sent.find((m) => m.event === FILE_DOC_EVENTS.MESSAGE) expect(relayed?.target).toBe(ROOM_NAME) - expect(relayed?.except).toBe('socket-a') + expect(relayed?.except).toBeUndefined() expect((relayed?.payload as Uint8Array)[0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) }) @@ -959,6 +964,35 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(relayedFor(999)).toBeUndefined() }) + it('accepts concurrent joins from co-mounted providers for the same file', async () => { + let resolveFirstAuth: (value: unknown) => void = () => {} + mockAuthorizeRoom.mockReturnValueOnce( + new Promise((resolve) => { + resolveFirstAuth = resolve + }) + ) + const { io } = createIo() + const { socket, handlers } = setup('socket-a', io) + + const first = handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 500 }) + await Promise.resolve() + const second = handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 501 }) + await second + + resolveFirstAuth({ + allowed: true, + status: 200, + workspaceId: 'ws-1', + workspacePermission: 'write', + }) + await first + + const acceptedClientIds = socket.emit.mock.calls + .filter(([event]) => event === FILE_DOC_EVENTS.JOIN_SUCCESS) + .map(([, payload]) => (payload as { clientId: number }).clientId) + expect(acceptedClientIds).toEqual(expect.arrayContaining([500, 501])) + }) + it('preserves the existing caret when a rebind to a foreign client id is rejected', async () => { const { io, sent } = createIo() const { frame: awFrame } = awarenessFrame(10, 'A') diff --git a/apps/realtime/src/handlers/file-doc.ts b/apps/realtime/src/handlers/file-doc.ts index 1e29f476de9..28ef6686f7a 100644 --- a/apps/realtime/src/handlers/file-doc.ts +++ b/apps/realtime/src/handlers/file-doc.ts @@ -192,15 +192,19 @@ const fileDocRooms = new Map() /** socketId → its current file-doc room name (a socket edits at most one doc). */ const socketToRoomName = new Map() /** - * socketId → a monotonic join generation. A JOIN bumps it on arrival and, after - * the async authorization, proceeds only if the generation is still its own — so - * a newer JOIN (a fast document switch) or a disconnect (which drops the entry in - * cleanup) that occurred during authorization aborts the now-stale JOIN. Without - * this, an out-of-order authorize completion could bind the socket to the wrong - * document, or a disconnect-during-authorize could register a dead socket and - * leak its room. + * socketId → a monotonic file-intent generation. Switching files or leaving the + * intended file advances it; co-mounted providers joining the same file share it. + * After async authorization, a join proceeds only while its generation is current, + * preventing an out-of-order completion from binding the socket to the wrong file. */ const joinGeneration = new Map() +const MAX_YJS_CLIENT_ID = 0xffff_ffff + +function isYjsClientId(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_YJS_CLIENT_ID + ) +} interface AwarenessChange { added: number[] @@ -227,10 +231,8 @@ function originSocketId(origin: unknown): string | null { * The transaction origin stamped on an agent-streamed frame (a {@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST} * apply). A non-string sentinel, so `originSocketId` returns `null` for it and the update never triggers * `edited`/`schedulePersist` (the copilot's final `edit_content` write is the durable persist). Unlike a - * client edit, an agent frame is broadcast to the WHOLE room (its originating socket is NOT excluded), so a - * second {@link FileDocProvider} on the same socket — e.g. the chat preview alongside the Files editor — - * also receives the mid-stream ops. The emitting provider no-ops on its own echo (the ops are already - * applied locally), so broadcasting back to the sender is harmless. + * client edit, it is marked so peers do not treat it as a durable user edit. The emitting provider no-ops + * on its own echo because the operations are already applied locally. */ const AGENT_SYNC_ORIGIN = Symbol('file-doc-agent-sync') @@ -783,7 +785,7 @@ async function mergeMarkdownIntoRoom( /** * Get (or lazily create) the authoritative document for a room, wiring the two * relay handlers exactly once: document updates and awareness changes are - * broadcast to the room, excluding the origin socket (it already applied them). + * broadcast to the room. */ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom { const name = roomName(ref) @@ -821,18 +823,12 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom { const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) syncProtocol.writeUpdate(encoder, update) - // Fan out to THIS task's clients only (excluding the origin socket if local — a user edit OR an - // agent-streamed frame). Cross-task delivery rides the shared stream — every task's tailer applies + - // runs its own local fan-out. - // A client edit excludes its own sender socket (echo suppression). An agent frame broadcasts to the - // WHOLE room — no socket excluded — so a same-socket sibling provider (chat preview + Files editor) - // stays live mid-stream; the emitting provider no-ops on its own echo. - broadcastLocal( - io, - name, - encoding.toUint8Array(encoder), - origin === AGENT_SYNC_ORIGIN ? null : originSocketId(origin) - ) + // Fan out to every client on THIS task, including the origin socket. One shared Socket.IO connection + // can host multiple providers for this file; excluding the whole socket would strand the sibling + // provider's distinct Y.Doc. Yjs updates are idempotent, and the originating provider applies its + // echo with the provider as transaction origin, so it does not send the update again. Cross-task + // delivery rides the shared stream, where every task's tailer runs its own local fan-out. + broadcastLocal(io, name, encoding.toUint8Array(encoder), null) // Share every locally-originated update to the stream so peers converge. Skip updates that already // came FROM the stream (REDIS_ORIGIN / REDIS_SNAPSHOT_ORIGIN / REDIS_AGENT_ORIGIN) and SEED_ORIGIN — // the seed is published EXPLICITLY and AWAITED under the seed lock (so it lands before the lock @@ -898,12 +894,15 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom { function emitJoinError( socket: AuthenticatedSocket, fileId: unknown, + clientId: unknown, error: string, code: string, retryable: boolean ) { + const normalizedClientId = isYjsClientId(clientId) ? clientId : undefined socket.emit(FILE_DOC_EVENTS.JOIN_ERROR, { fileId: typeof fileId === 'string' ? fileId : '', + clientId: normalizedClientId, error, code, retryable, @@ -1113,30 +1112,47 @@ export function setupWorkspaceFileDocHandlers( const userName = socket.userName if (!userId || !userName) { - emitJoinError(socket, fileId, 'Authentication required', 'AUTHENTICATION_REQUIRED', false) + emitJoinError( + socket, + fileId, + clientId, + 'Authentication required', + 'AUTHENTICATION_REQUIRED', + false + ) return } if (!roomManager.isReady()) { - emitJoinError(socket, fileId, 'Realtime unavailable', 'ROOM_MANAGER_UNAVAILABLE', true) + emitJoinError( + socket, + fileId, + clientId, + 'Realtime unavailable', + 'ROOM_MANAGER_UNAVAILABLE', + true + ) return } if ( typeof fileId !== 'string' || fileId.length === 0 || - // A Yjs clientID is a uint32; reject NaN/Infinity/negative/non-integer so a malformed id - // can't become a bogus ownership key. - !Number.isInteger(clientId) || - clientId < 0 + // A Yjs clientID is a uint32; reject malformed values before they can become ownership keys. + !isYjsClientId(clientId) ) { - emitJoinError(socket, fileId, 'Invalid join payload', 'INVALID_PAYLOAD', false) + emitJoinError(socket, fileId, clientId, 'Invalid join payload', 'INVALID_PAYLOAD', false) return } - // Claim this JOIN's generation before the async authorize below, and record the file the - // socket now intends to edit so a leave for it can cancel this join if it's still in-flight. - generation = (joinGeneration.get(socket.id) ?? 0) + 1 - joinGeneration.set(socket.id, generation) - currentFileId = fileId + // A generation represents the socket's intended FILE, not an individual provider. Co-mounted + // providers for the same file must be allowed to join concurrently; switching files advances the + // generation so every in-flight join for the old file is cancelled together. + if (currentFileId !== fileId) { + generation = (joinGeneration.get(socket.id) ?? 0) + 1 + joinGeneration.set(socket.id, generation) + currentFileId = fileId + } else { + generation = joinGeneration.get(socket.id) ?? 0 + } const room = fileDocRoom(fileId) const name = roomName(room) @@ -1153,7 +1169,7 @@ export function setupWorkspaceFileDocHandlers( accessDenied: 'Access denied to file', }, emitError: ({ error, code, retryable }) => - emitJoinError(socket, fileId, error, code, retryable), + emitJoinError(socket, fileId, clientId, error, code, retryable), }) if (!authorized) return @@ -1190,7 +1206,7 @@ export function setupWorkspaceFileDocHandlers( logger.warn( `User ${userId} lost write access to file ${fileId} before the join completed` ) - emitJoinError(socket, fileId, 'Access denied to file', 'ACCESS_DENIED', false) + emitJoinError(socket, fileId, clientId, 'Access denied to file', 'ACCESS_DENIED', false) return } @@ -1219,7 +1235,14 @@ export function setupWorkspaceFileDocHandlers( const owner = clientMap.get(clientId) if (owner === undefined) continue if (owner.userId !== userId) { - emitJoinError(socket, fileId, 'Client id already in use', 'CLIENT_ID_IN_USE', false) + emitJoinError( + socket, + fileId, + clientId, + 'Client id already in use', + 'CLIENT_ID_IN_USE', + false + ) return } // Same user reclaiming its client id on a stale prior socket: evict just THAT clientID's @@ -1268,7 +1291,11 @@ export function setupWorkspaceFileDocHandlers( // Name the document this room holds, so a client that still carries a DIFFERENT one (its room // outlived by a document rebuilt in its place) can refuse to merge instead of unioning two // documents into the file twice over. Read after readiness — before it, the room has no doc yet. - socket.emit(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId, docId: docIdOf(entry.doc) }) + socket.emit(FILE_DOC_EVENTS.JOIN_SUCCESS, { + fileId, + clientId, + docId: docIdOf(entry.doc), + }) // Server-authenticated roster → everyone in the room, including this joiner. broadcastFileDocPresence(io, name, entry) @@ -1321,7 +1348,7 @@ export function setupWorkspaceFileDocHandlers( (generation !== undefined && joinGeneration.get(socket.id) !== generation) ) return - emitJoinError(socket, fileId, 'Failed to join file document', 'JOIN_FAILED', true) + emitJoinError(socket, fileId, clientId, 'Failed to join file document', 'JOIN_FAILED', true) } }) diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx index 2f0711f0091..4416a77ee83 100644 --- a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx +++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx @@ -5,21 +5,30 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCal, mockCalComponent, mockConsent, mockGetCalApi, mockTrackGoogleEvent } = vi.hoisted( - () => ({ - mockCal: vi.fn(), - mockCalComponent: vi.fn(() => null), - mockConsent: { marketing: true, measurement: true }, - mockGetCalApi: vi.fn(), - mockTrackGoogleEvent: vi.fn(), - }) -) +const { + mockCal, + mockCalComponent, + mockConsent, + mockGetCalApi, + mockTrackGoogleAdsConversion, + mockTrackGoogleEvent, +} = vi.hoisted(() => ({ + mockCal: vi.fn(), + mockCalComponent: vi.fn(() => null), + mockConsent: { marketing: true, measurement: true }, + mockGetCalApi: vi.fn(), + mockTrackGoogleAdsConversion: vi.fn(), + mockTrackGoogleEvent: vi.fn(), +})) vi.mock('@calcom/embed-react', () => ({ default: mockCalComponent, getCalApi: mockGetCalApi, })) -vi.mock('@/lib/analytics/google', () => ({ trackGoogleEvent: mockTrackGoogleEvent })) +vi.mock('@/lib/analytics/google', () => ({ + trackGoogleAdsConversion: mockTrackGoogleAdsConversion, + trackGoogleEvent: mockTrackGoogleEvent, +})) vi.mock('@/lib/consent/scripts', () => ({ X_DEMO_BOOKED_EVENT_ID: 'demo-booked' })) vi.mock('@/lib/consent/tracking-consent', () => ({ useTrackingConsent: () => mockConsent, @@ -37,6 +46,18 @@ const LEAD = { notes: 'Company: Analytical Engines\nTopic: Demo', } +interface BookingRegistration { + action: string + callback: () => void +} + +/** The listener the scheduler registered with the Cal.com embed, if any. */ +function bookingRegistration(): BookingRegistration | undefined { + return mockCal.mock.calls.find(([method]) => method === 'on')?.[1] as + | BookingRegistration + | undefined +} + describe('DemoScheduler', () => { let container: HTMLDivElement let root: Root @@ -101,9 +122,7 @@ describe('DemoScheduler', () => { await Promise.resolve() }) - const registration = mockCal.mock.calls.find(([method]) => method === 'on')?.[1] as - | { action: string; callback: () => void } - | undefined + const registration = bookingRegistration() expect(registration?.action).toBe('bookingSuccessfulV2') registration?.callback() @@ -112,6 +131,7 @@ describe('DemoScheduler', () => { form_name: 'sim_demo', booking_status: 'scheduled', }) + expect(mockTrackGoogleAdsConversion).toHaveBeenCalledWith('demo_booked') expect(trackXEvent).toHaveBeenCalledWith('event', 'demo-booked', {}) await act(async () => { @@ -125,6 +145,23 @@ describe('DemoScheduler', () => { root = createRoot(container) }) + it('sends measurement analytics but no ad conversion without marketing consent', async () => { + mockConsent.marketing = false + const trackXEvent = vi.fn() + window.twq = trackXEvent + + await act(async () => { + root.render() + await Promise.resolve() + }) + + bookingRegistration()?.callback() + + expect(mockTrackGoogleEvent).toHaveBeenCalledOnce() + expect(mockTrackGoogleAdsConversion).not.toHaveBeenCalled() + expect(trackXEvent).not.toHaveBeenCalled() + }) + it('does not register booking analytics without measurement or marketing consent', async () => { mockConsent.marketing = false mockConsent.measurement = false @@ -138,7 +175,7 @@ describe('DemoScheduler', () => { hideEventTypeDetails: true, styles: { branding: { brandColor: '#6f3dfa' } }, }) - expect(mockCal.mock.calls.some(([method]) => method === 'on')).toBe(false) + expect(bookingRegistration()).toBeUndefined() }) it('preloads the configured booker only once', async () => { diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx index 682c1990987..5363069745c 100644 --- a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx +++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import Cal, { getCalApi } from '@calcom/embed-react' -import { trackGoogleEvent } from '@/lib/analytics/google' +import { trackGoogleAdsConversion, trackGoogleEvent } from '@/lib/analytics/google' import { X_DEMO_BOOKED_EVENT_ID } from '@/lib/consent/scripts' import { useTrackingConsent } from '@/lib/consent/tracking-consent' import type { DemoLead } from '@/app/(landing)/demo/components/demo-form' @@ -102,7 +102,10 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) { booking_status: 'scheduled', }) } - if (marketing) window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {}) + if (marketing) { + trackGoogleAdsConversion('demo_booked') + window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {}) + } } const api = getCalApi({ namespace: CAL_NAMESPACE, embedJsUrl: CAL_EMBED.embedJsUrl }) api diff --git a/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx b/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx index 3c59189fc90..a44bd563c4e 100644 --- a/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx +++ b/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx @@ -1,3 +1,4 @@ +import { Fragment } from 'react' import { ChipLink } from '@sim/emcn' import type { Metadata } from 'next' import { notFound } from 'next/navigation' @@ -200,6 +201,28 @@ export default async function ModelPage({ } /> + {model.pricing.tiers?.map((tier) => { + const threshold = formatTokenCount(tier.aboveInputTokens) + + return ( + + ${threshold})`} + value={`${formatPrice(tier.input)}/1M`} + /> + ${threshold})`} + value={ + tier.cachedInput !== undefined ? `${formatPrice(tier.cachedInput)}/1M` : 'N/A' + } + /> + ${threshold})`} + value={`${formatPrice(tier.output)}/1M`} + /> + + ) + })} > = (() => { const map: Record> = {} @@ -24,6 +27,14 @@ const PROVIDER_ICON_MAP: Record> = return map })() +function getRoundedRightBarPath(x: number, width: number): string { + const right = x + width + const radiusX = Math.min(CHART_BAR_END_RADIUS_X, width / 2) + const radiusY = CHART_BAR_END_RADIUS_Y + + return `M ${x} 0 H ${right - radiusX} Q ${right} 0 ${right} ${radiusY} V ${CHART_BAR_HEIGHT - radiusY} Q ${right} ${CHART_BAR_HEIGHT} ${right - radiusX} ${CHART_BAR_HEIGHT} H ${x} Z` +} + function selectComparisonModels(models: CatalogModel[]): CatalogModel[] { const seen = new Set() const result: CatalogModel[] = [] @@ -96,7 +107,7 @@ function StackedCostChart({ models }: ChartProps) { Cost - Per 1M tokens + Standard short-context rates per 1M tokens
@@ -104,6 +115,9 @@ function StackedCostChart({ models }: ChartProps) { {data.entries.map(({ model, input, output, total }) => { const totalPct = data.maxTotal > 0 ? (total / data.maxTotal) * 100 : 0 const inputPct = total > 0 ? (input / total) * 100 : 0 + const plottedTotalPct = Math.max(totalPct, 3) + const plottedInputPct = (plottedTotalPct * inputPct) / 100 + const plottedOutputPct = plottedTotalPct - plottedInputPct const color = getProviderColor(model.providerId) return ( @@ -113,29 +127,34 @@ function StackedCostChart({ models }: ChartProps) { className='-mx-2 flex items-center gap-3 rounded-md px-2 transition-colors hover:bg-[var(--surface-hover)]' > -
-
-
-
+
+
+
- + {formatPrice(input)} input / {formatPrice(output)} output
@@ -184,16 +203,22 @@ function ContextWindowChart({ models }: ChartProps) { className='-mx-2 flex items-center gap-3 rounded-md px-2 transition-colors hover:bg-[var(--surface-hover)]' > -
-
- +
+
+ +
+ {formatTokenCount(value)}
@@ -228,11 +253,11 @@ export function ModelComparisonCharts({ models }: ModelComparisonChartsProps) {
-
+
-
+
diff --git a/apps/sim/app/(landing)/models/components/model-directory.tsx b/apps/sim/app/(landing)/models/components/model-directory.tsx index 60ce1eedfde..b80e0bfd530 100644 --- a/apps/sim/app/(landing)/models/components/model-directory.tsx +++ b/apps/sim/app/(landing)/models/components/model-directory.tsx @@ -201,14 +201,9 @@ export function ModelDirectory() { className='size-8 rounded-xl' iconClassName='size-4' /> -
-

- {provider.name} -

-

- {provider.description} -

-
+

+ {provider.name} +

))} diff --git a/apps/sim/app/(landing)/models/utils.test.ts b/apps/sim/app/(landing)/models/utils.test.ts index 05c8d88cca0..235866c97e4 100644 --- a/apps/sim/app/(landing)/models/utils.test.ts +++ b/apps/sim/app/(landing)/models/utils.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { buildModelCapabilityFacts, getEffectiveMaxOutputTokens, getModelBySlug } from './utils' +import { + buildModelCapabilityFacts, + getEffectiveMaxOutputTokens, + getModelBySlug, + getPricingBounds, + getProviderBySlug, +} from '@/app/(landing)/models/utils' describe('model catalog capability facts', () => { it.concurrent( @@ -46,4 +52,26 @@ describe('model catalog capability facts', () => { expect(researchModel?.bestFor).toContain('research workflows') expect(generalModel?.bestFor).toBeUndefined() }) + + it.concurrent('uses explicit catalog features for flagship provider cards', () => { + expect( + ['anthropic', 'openai', 'google'].map((providerId) => { + const model = getProviderBySlug(providerId)?.featuredModels[0] + return { providerId, modelId: model?.id, featured: model?.featured } + }) + ).toEqual([ + { providerId: 'anthropic', modelId: 'claude-fable-5-1', featured: true }, + { providerId: 'openai', modelId: 'gpt-6-astra', featured: true }, + { providerId: 'google', modelId: 'gemini-3.8-flash', featured: true }, + ]) + + expect(getProviderBySlug('anthropic')?.featuredModels[0]?.recommended).toBe(false) + }) + + it.concurrent('includes input-size tiers in structured pricing bounds', () => { + const model = getModelBySlug('openai', 'gpt-6-astra') + + expect(model).not.toBeNull() + expect(getPricingBounds(model!.pricing)).toEqual({ lowPrice: 1, highPrice: 75 }) + }) }) diff --git a/apps/sim/app/(landing)/models/utils.ts b/apps/sim/app/(landing)/models/utils.ts index 276d9519481..4d5ecadb7b5 100644 --- a/apps/sim/app/(landing)/models/utils.ts +++ b/apps/sim/app/(landing)/models/utils.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react' import { slugify } from '@sim/utils/string' import { type ModelCapabilities, PROVIDER_DEFINITIONS } from '@/providers/models' +import type { ModelPricing } from '@/providers/types' const PROVIDER_PREFIXES: Record = { 'azure-openai': ['azure/'], @@ -95,12 +96,7 @@ const UPDATED_AT_DATE_FORMAT = new Intl.DateTimeFormat('en-US', { year: 'numeric', }) -export interface PricingInfo { - input: number - cachedInput?: number - output: number - updatedAt: string -} +export type PricingInfo = ModelPricing export interface CatalogFaq { question: string @@ -124,6 +120,8 @@ export interface CatalogModel { contextWindow: number | null releaseDate: string | null deprecated: boolean + featured: boolean + recommended: boolean pricing: PricingInfo capabilities: ModelCapabilities capabilityTags: string[] @@ -444,6 +442,12 @@ function computeModelRelevanceScore(model: CatalogModel): number { } function compareModelsByRelevance(a: CatalogModel, b: CatalogModel): number { + const featuredDifference = Number(b.featured) - Number(a.featured) + if (featuredDifference !== 0) return featuredDifference + + const recommendationDifference = Number(b.recommended) - Number(a.recommended) + if (recommendationDifference !== 0) return recommendationDifference + return computeModelRelevanceScore(b) - computeModelRelevanceScore(a) } @@ -477,6 +481,8 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => { contextWindow: model.contextWindow ?? null, releaseDate: model.releaseDate ?? null, deprecated: !!model.sunset, + featured: model.featured ?? false, + recommended: model.recommended ?? false, pricing: model.pricing, capabilities: mergedCapabilities, capabilityTags, @@ -587,13 +593,15 @@ export const TOP_MODEL_PROVIDERS = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map ) export function getPricingBounds(pricing: PricingInfo): { lowPrice: number; highPrice: number } { + const prices = [pricing, ...(pricing.tiers ?? [])].flatMap((tokenPricing) => [ + tokenPricing.input, + tokenPricing.output, + ...(tokenPricing.cachedInput !== undefined ? [tokenPricing.cachedInput] : []), + ]) + return { - lowPrice: Math.min( - pricing.input, - pricing.output, - ...(pricing.cachedInput !== undefined ? [pricing.cachedInput] : []) - ), - highPrice: Math.max(pricing.input, pricing.output), + lowPrice: Math.min(...prices), + highPrice: Math.max(...prices), } } @@ -708,6 +716,7 @@ export function buildProviderFaqs(provider: CatalogProvider): CatalogFaq[] { } export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel): CatalogFaq[] { + const pricingTiers = model.pricing.tiers ?? [] const faqs: CatalogFaq[] = [ { question: `What is ${model.displayName}?`, @@ -715,7 +724,12 @@ export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel): }, { question: `How much does ${model.displayName} cost?`, - answer: `${model.displayName} is listed at ${formatPrice(model.pricing.input)}/1M input tokens${model.pricing.cachedInput !== undefined ? `, ${formatPrice(model.pricing.cachedInput)}/1M cached input tokens` : ''}, and ${formatPrice(model.pricing.output)}/1M output tokens.`, + answer: `${model.displayName} starts at ${formatPrice(model.pricing.input)}/1M input tokens${model.pricing.cachedInput !== undefined ? `, ${formatPrice(model.pricing.cachedInput)}/1M cached input tokens` : ''}, and ${formatPrice(model.pricing.output)}/1M output tokens.${pricingTiers + .map( + (tier) => + ` Above ${formatTokenCount(tier.aboveInputTokens)} input tokens, the full request is priced at ${formatPrice(tier.input)}/1M input tokens${tier.cachedInput !== undefined ? `, ${formatPrice(tier.cachedInput)}/1M cached input tokens` : ''}, and ${formatPrice(tier.output)}/1M output tokens.` + ) + .join('')}`, }, { question: `What is the context window for ${model.displayName}?`, diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index f51bf6b2b19..c201d7c417d 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -28,7 +28,7 @@ describe('OAuth Disconnect API Route', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - dbChainMockFns.where.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) mockGetUserOrganization.mockResolvedValue(null) }) @@ -102,7 +102,7 @@ describe('OAuth Disconnect API Route', () => { session: { id: 'session-1' }, }) - dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) + dbChainMockFns.limit.mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('POST', { provider: 'google', diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 78ddb6e9a1e..9c10df2e236 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -48,6 +48,7 @@ vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({ vi.mock('@/tools/metadata', () => ({ getToolMetadata: mockGetToolMetadata })) import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { createQuickBooksAccountId } from '@/lib/oauth/quickbooks' import { GET, POST } from '@/app/api/auth/oauth/token/route' describe('OAuth Token API Routes', () => { @@ -88,12 +89,83 @@ describe('OAuth Token API Routes', () => { expect(response.status).toBe(200) expect(data).toHaveProperty('accessToken', 'fresh-token') + expect(data).not.toHaveProperty('realmId') expect(mockAuthorizeCredentialUse).toHaveBeenCalled() expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled() expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).toHaveBeenCalled() }) + it('returns realmId only for QuickBooks credentials', async () => { + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'session', + requesterUserId: 'test-user-id', + credentialOwnerUserId: 'owner-user-id', + }) + authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({ + id: 'credential-id', + accountId: createQuickBooksAccountId( + '123456789', + 'intuit-subject-01234567-89ab-4def-8abc-0123456789ab', + { + clientId: 'client-id', + environment: 'sandbox', + } + ), + accessToken: 'test-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + providerId: 'quickbooks', + }) + authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({ + accessToken: 'fresh-token', + refreshed: false, + }) + + const response = await POST( + createMockRequest('POST', { + credentialId: 'credential-id', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + accessToken: 'fresh-token', + credentialType: 'oauth', + realmId: '123456789', + quickBooksEnvironment: 'sandbox', + }) + }) + + it('rejects a malformed QuickBooks company identity with reconnect guidance', async () => { + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'session', + requesterUserId: 'test-user-id', + credentialOwnerUserId: 'owner-user-id', + }) + authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({ + id: 'credential-id', + accountId: 'malformed', + accessToken: 'test-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + providerId: 'quickbooks', + }) + + const response = await POST( + createMockRequest('POST', { + credentialId: 'credential-id', + }) + ) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data.error).toMatch(/Reconnect the QuickBooks credential/) + expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).not.toHaveBeenCalled() + }) + it('should handle workflowId for server-side authentication', async () => { mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, @@ -737,6 +809,36 @@ describe('OAuth Token API Routes', () => { expect(data).toHaveProperty('error') }) + it('rejects a malformed QuickBooks identity before reporting a missing token', async () => { + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'session', + requesterUserId: 'test-user-id', + credentialOwnerUserId: 'test-user-id', + }) + authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({ + id: 'credential-id', + accountId: 'malformed', + accessToken: null, + refreshToken: 'refresh-token', + providerId: 'quickbooks', + }) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id' + ) + ) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data.error).toMatch(/Reconnect the QuickBooks credential/) + expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).not.toHaveBeenCalled() + }) + it('should handle token refresh failure', async () => { mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 03b5c1a9f69..353e5a1eed5 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -17,6 +17,7 @@ import { getCredential, getOAuthToken } from '@/lib/oauth/credential-service' import { completeOAuthCredentialToken, resolveCredentialAccessToken, + validateOAuthCredentialContext, } from '@/lib/oauth/token-resolution' import { captureServerEvent } from '@/lib/posthog/server' @@ -196,6 +197,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } + const contextValidation = validateOAuthCredentialContext(credential) + if (!contextValidation.ok) { + return NextResponse.json({ error: contextValidation.error }, { status: 401 }) + } + if (!credential.accessToken) { logger.warn(`[${requestId}] No access token available for credential`) return NextResponse.json({ error: 'No access token available' }, { status: 400 }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index ab527378a22..656b3efe645 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -16,6 +16,9 @@ const mocks = vi.hoisted(() => ({ createConnection: vi.fn(), getPerRequestScopes: vi.fn(), launchConnection: vi.fn(), + decryptQuickBooksClientConfig: vi.fn(), + createQuickBooksState: vi.fn(), + getCanonicalScopes: vi.fn(), })) vi.mock('@/lib/auth/auth', () => ({ @@ -49,6 +52,13 @@ vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ })) vi.mock('@/lib/oauth/utils', () => ({ getPerRequestOAuthLinkScopes: mocks.getPerRequestScopes, + getCanonicalScopesForProvider: mocks.getCanonicalScopes, +})) +vi.mock('@/lib/oauth/quickbooks-client-config', () => ({ + decryptQuickBooksOAuthClientConfig: mocks.decryptQuickBooksClientConfig, +})) +vi.mock('@/lib/oauth/quickbooks-state', () => ({ + createQuickBooksOAuthState: mocks.createQuickBooksState, })) import { GET } from '@/app/api/auth/oauth2/authorize/route' @@ -94,6 +104,19 @@ describe('OAuth2 authorize route', () => { }) mocks.linkAccount.mockResolvedValue(linkResponse()) mocks.getPerRequestScopes.mockReturnValue(undefined) + mocks.getCanonicalScopes.mockReturnValue([ + 'openid', + 'profile', + 'email', + 'com.intuit.quickbooks.accounting', + ]) + mocks.decryptQuickBooksClientConfig.mockResolvedValue({ + clientId: 'intuit-client-id', + clientSecret: 'intuit-client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', + }) + mocks.createQuickBooksState.mockReturnValue('signed-state') }) it('creates a canonical application draft for a legacy connect URL', async () => { @@ -272,4 +295,37 @@ describe('OAuth2 authorize route', () => { expect(location.pathname).toBe('/api/auth/trello/authorize') expect(location.searchParams.get('draftId')).toBe('draft-1') }) + + it('starts QuickBooks with the write-only app configuration bound to its draft', async () => { + mocks.launchConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + providerId: 'quickbooks', + workspaceId: WORKSPACE_ID, + credentialId: null, + oauthConfig: 'encrypted-config', + }, + }) + const callbackURL = `${BASE_URL}/workspace/${WORKSPACE_ID}/integrations` + + const response = await GET(request({ draftId: 'draft-1', callbackURL })) + const location = new URL(response.headers.get('location') ?? '') + + expect(mocks.requireClient).not.toHaveBeenCalled() + expect(mocks.decryptQuickBooksClientConfig).toHaveBeenCalledWith('encrypted-config') + expect(mocks.createQuickBooksState).toHaveBeenCalledWith({ + userId: 'user-1', + draftId: 'draft-1', + returnUrl: callbackURL, + }) + expect(location.origin + location.pathname).toBe('https://appcenter.intuit.com/connect/oauth2') + expect(Object.fromEntries(location.searchParams)).toEqual({ + client_id: 'intuit-client-id', + response_type: 'code', + scope: 'openid profile email com.intuit.quickbooks.accounting', + redirect_uri: `${BASE_URL}/api/auth/oauth2/callback/quickbooks`, + state: 'signed-state', + }) + expect(mocks.linkAccount).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index e15d0ad6074..1a70cc24ad2 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -7,12 +7,16 @@ import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' -import { getPerRequestOAuthLinkScopes } from '@/lib/oauth/utils' +import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config' +import { QUICKBOOKS_AUTHORIZATION_URL } from '@/lib/oauth/quickbooks-constants' +import { createQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state' +import { getCanonicalScopesForProvider, getPerRequestOAuthLinkScopes } from '@/lib/oauth/utils' const logger = createLogger('OAuth2Authorize') @@ -43,6 +47,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { let fromConnectionDraft = false let connectionDraftId: string | undefined + let encryptedQuickBooksClientConfig: string | null | undefined if (draftId) { try { const { draft } = await launchCredentialConnection.execute({ @@ -54,6 +59,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { workspaceId = draft.workspaceId credentialId = draft.credentialId ?? undefined connectionDraftId = draft.id + encryptedQuickBooksClientConfig = draft.oauthConfig fromConnectionDraft = true } catch (error) { if (!(error instanceof OrchestrationError)) throw error @@ -65,13 +71,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!providerId || !workspaceId) { throw new Error('Validated OAuth authorization request is missing its target') } - - requireConfiguredOAuthClient(providerId) + if (providerId !== 'quickbooks') { + requireConfiguredOAuthClient(providerId) + } const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) connectionCompleteUrl.searchParams.set('result', 'connected') const callbackURL = fromConnectionDraft - ? connectionCompleteUrl.toString() + ? requestedCallback && isSameOrigin(requestedCallback) + ? requestedCallback + : connectionCompleteUrl.toString() : requestedCallback?.startsWith(`${baseUrl}/`) ? requestedCallback : `${baseUrl}/workspace` @@ -116,6 +125,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => { throw new Error('OAuth authorization is missing its credential draft id') } + if (providerId === 'quickbooks') { + if (!encryptedQuickBooksClientConfig) { + const { draft } = await launchCredentialConnection.execute({ + principal, + input: { draftId: connectionDraftId }, + request, + }) + encryptedQuickBooksClientConfig = draft.oauthConfig + } + if (!encryptedQuickBooksClientConfig) { + throw new Error('QuickBooks OAuth client configuration is missing') + } + const clientConfig = await decryptQuickBooksOAuthClientConfig(encryptedQuickBooksClientConfig) + const redirectUri = `${baseUrl}/api/auth/oauth2/callback/quickbooks` + const state = createQuickBooksOAuthState({ + userId, + draftId: connectionDraftId, + returnUrl: callbackURL, + }) + const authorizeUrl = new URL(QUICKBOOKS_AUTHORIZATION_URL) + authorizeUrl.searchParams.set('client_id', clientConfig.clientId) + authorizeUrl.searchParams.set('response_type', 'code') + authorizeUrl.searchParams.set('scope', getCanonicalScopesForProvider(providerId).join(' ')) + authorizeUrl.searchParams.set('redirect_uri', redirectUri) + authorizeUrl.searchParams.set('state', state) + return NextResponse.redirect(authorizeUrl) + } + if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') { const authorizeUrl = new URL(`/api/auth/${providerId}/authorize`, baseUrl) authorizeUrl.searchParams.set('returnUrl', callbackURL) diff --git a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts new file mode 100644 index 00000000000..51a36fd3c98 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCompleteQuickBooksConnection, mockGetSession } = vi.hoisted(() => ({ + mockCompleteQuickBooksConnection: vi.fn(), + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) +vi.mock('@/lib/credentials/application/complete-quickbooks-connection', () => ({ + completeQuickBooksConnection: { execute: mockCompleteQuickBooksConnection }, +})) + +import { createQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state' +import { GET } from '@/app/api/auth/oauth2/callback/quickbooks/route' + +function callbackRequest(searchParams: URLSearchParams) { + return createMockRequest( + 'GET', + undefined, + undefined, + `https://sim.test/api/auth/oauth2/callback/quickbooks?${searchParams.toString()}` + ) +} + +describe('QuickBooks OAuth callback', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mockCompleteQuickBooksConnection.mockResolvedValue({ + accountId: 'account-1', + environment: 'sandbox', + realmId: '1234567890', + }) + }) + + it('binds the provider callback to the signed draft and callback-derived realm', async () => { + const state = createQuickBooksOAuthState({ + userId: 'user-1', + draftId: 'draft-from-state', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=quickbooks', + }) + const response = await GET( + callbackRequest( + new URLSearchParams({ + code: 'authorization-code', + state, + realmId: ' 1234567890 ', + locale: 'en-US', + }) + ) + ) + + expect(mockCompleteQuickBooksConnection).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, + input: expect.objectContaining({ + draftId: 'draft-from-state', + code: 'authorization-code', + realmId: '1234567890', + redirectUri: 'https://sim.test/api/auth/oauth2/callback/quickbooks', + }), + }) + ) + expect(response.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?flow=quickbooks&quickbooks_connected=true' + ) + }) + + it('returns a provider-denial result without exchanging a code', async () => { + const state = createQuickBooksOAuthState({ + userId: 'user-1', + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected', + }) + const response = await GET( + callbackRequest( + new URLSearchParams({ + state, + error: 'access_denied', + error_description: 'The user denied access', + }) + ) + ) + + expect(mockCompleteQuickBooksConnection).not.toHaveBeenCalled() + expect(response.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?error=quickbooks_access_denied' + ) + }) + + it('returns completion failures to the signed initiating surface', async () => { + mockCompleteQuickBooksConnection.mockRejectedValueOnce(new Error('Token exchange failed')) + const state = createQuickBooksOAuthState({ + userId: 'user-1', + draftId: 'draft-1', + returnUrl: 'https://sim.test/desktop/connect/complete?state=handoff', + }) + const response = await GET( + callbackRequest( + new URLSearchParams({ + code: 'authorization-code', + state, + realmId: '1234567890', + }) + ) + ) + + expect(response.headers.get('location')).toBe( + 'https://sim.test/desktop/connect/complete?state=handoff&error=quickbooks_callback_error' + ) + }) + + it('rejects signed cross-origin return destinations', async () => { + const state = createQuickBooksOAuthState({ + userId: 'user-1', + draftId: 'draft-1', + returnUrl: 'https://attacker.example/capture', + }) + const response = await GET( + callbackRequest( + new URLSearchParams({ + code: 'authorization-code', + state, + realmId: '1234567890', + }) + ) + ) + + expect(mockCompleteQuickBooksConnection).not.toHaveBeenCalled() + expect(response.headers.get('location')).toBe( + 'https://sim.test/workspace?error=quickbooks_callback_error' + ) + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts new file mode 100644 index 00000000000..675e7e8a076 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { quickBooksCallbackContract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeQuickBooksConnection } from '@/lib/credentials/application/complete-quickbooks-connection' +import { normalizeQuickBooksRealmId } from '@/lib/oauth/quickbooks' +import { parseQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state' + +const logger = createLogger('QuickBooksCallback') + +export const dynamic = 'force-dynamic' + +export const GET = withRouteHandler(async (request: NextRequest) => { + const baseUrl = getBaseUrl() + const fallbackUrl = `${baseUrl}/workspace` + let validatedReturnUrl: URL | null = null + + try { + const session = await getSession() + if (!session?.user?.id || !session.session?.id) { + return NextResponse.redirect(`${fallbackUrl}?error=unauthorized`) + } + const parsed = await parseRequest(quickBooksCallbackContract, request, {}) + if (!parsed.success) return parsed.response + + const state = parseQuickBooksOAuthState({ + state: parsed.data.query.state, + userId: session.user.id, + }) + if (!isSameOrigin(state.returnUrl)) { + throw new Error('QuickBooks OAuth state contains an invalid return URL') + } + validatedReturnUrl = new URL(state.returnUrl) + if (parsed.data.query.error) { + logger.warn('QuickBooks OAuth authorization was not completed', { + error: parsed.data.query.error, + hasDescription: Boolean(parsed.data.query.error_description), + }) + validatedReturnUrl.searchParams.set('error', 'quickbooks_access_denied') + return NextResponse.redirect(validatedReturnUrl) + } + if (!parsed.data.query.code || !parsed.data.query.realmId) { + throw new Error('QuickBooks callback is missing its authorization code or company identity') + } + + await completeQuickBooksConnection.execute({ + principal: { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + }, + input: { + draftId: state.draftId, + code: parsed.data.query.code, + realmId: normalizeQuickBooksRealmId(parsed.data.query.realmId), + redirectUri: `${baseUrl}/api/auth/oauth2/callback/quickbooks`, + signal: request.signal, + }, + request, + }) + + validatedReturnUrl.searchParams.set('quickbooks_connected', 'true') + return NextResponse.redirect(validatedReturnUrl) + } catch (error) { + logger.error('QuickBooks OAuth callback failed', { error }) + const errorUrl = validatedReturnUrl ?? new URL(fallbackUrl) + errorUrl.searchParams.set('error', 'quickbooks_callback_error') + return NextResponse.redirect(errorUrl) + } +}) diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 169aac67c7d..fd09d744f64 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -48,7 +48,14 @@ afterEach(() => { }) import { WORKSPACE_ACCESS_SCOPE } from '@/lib/knowledge/access/scope' -import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' +import { generateSearchEmbedding, type KbEmbeddingTarget } from '@/lib/knowledge/embeddings' + +/** The platform default model and vector width, as a knowledge base records them. */ +const DEFAULT_EMBEDDING_TARGET: KbEmbeddingTarget = { + model: 'text-embedding-3-small', + dimensions: 1536, +} + import { executeKeywordSearch, executeKnowledgeSearch, @@ -596,7 +603,7 @@ describe('Knowledge Search Utils', () => { mockNextEmbeddingResponse() - const result = await generateSearchEmbedding('test query') + const result = await generateSearchEmbedding('test query', DEFAULT_EMBEDDING_TARGET) expect(vi.mocked(fetch)).toHaveBeenCalledWith( 'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview', @@ -621,7 +628,7 @@ describe('Knowledge Search Utils', () => { mockNextEmbeddingResponse() - const result = await generateSearchEmbedding('test query') + const result = await generateSearchEmbedding('test query', DEFAULT_EMBEDDING_TARGET) expect(vi.mocked(fetch)).toHaveBeenCalledWith( 'https://api.openai.com/v1/embeddings', @@ -649,7 +656,7 @@ describe('Knowledge Search Utils', () => { mockNextEmbeddingResponse() - await generateSearchEmbedding('test query') + await generateSearchEmbedding('test query', DEFAULT_EMBEDDING_TARGET) expect(vi.mocked(fetch)).toHaveBeenCalledWith( 'https://api.openai.com/v1/embeddings', @@ -673,7 +680,7 @@ describe('Knowledge Search Utils', () => { mockNextEmbeddingResponse() - await generateSearchEmbedding('test query', 'text-embedding-3-small') + await generateSearchEmbedding('test query', DEFAULT_EMBEDDING_TARGET) expect(vi.mocked(fetch)).toHaveBeenCalledWith( 'https://test.openai.azure.com/openai/deployments/custom-embedding-model/embeddings?api-version=2024-12-01-preview', @@ -695,7 +702,7 @@ describe('Knowledge Search Utils', () => { OPENROUTER_API_KEY: undefined, }) - await expect(generateSearchEmbedding('test query')).rejects.toThrow( + await expect(generateSearchEmbedding('test query', DEFAULT_EMBEDDING_TARGET)).rejects.toThrow( 'OPENAI_API_KEY is not configured' ) }) @@ -717,7 +724,9 @@ describe('Knowledge Search Utils', () => { text: 'Deployment not found', }) - await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed') + await expect(generateSearchEmbedding('test query', DEFAULT_EMBEDDING_TARGET)).rejects.toThrow( + 'Embedding API failed' + ) // Clean up Object.keys(env).forEach((key) => delete (env as any)[key]) @@ -738,7 +747,9 @@ describe('Knowledge Search Utils', () => { text: 'Rate limit exceeded', }) - await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed') + await expect(generateSearchEmbedding('test query', DEFAULT_EMBEDDING_TARGET)).rejects.toThrow( + 'Embedding API failed' + ) // Clean up Object.keys(env).forEach((key) => delete (env as any)[key]) @@ -756,7 +767,7 @@ describe('Knowledge Search Utils', () => { mockNextEmbeddingResponse() - await generateSearchEmbedding('test query') + await generateSearchEmbedding('test query', DEFAULT_EMBEDDING_TARGET) expect(vi.mocked(fetch)).toHaveBeenCalledWith( expect.any(String), @@ -782,7 +793,7 @@ describe('Knowledge Search Utils', () => { mockNextEmbeddingResponse() - await generateSearchEmbedding('test query', 'text-embedding-3-small') + await generateSearchEmbedding('test query', DEFAULT_EMBEDDING_TARGET) expect(vi.mocked(fetch)).toHaveBeenCalledWith( expect.any(String), @@ -811,7 +822,7 @@ describe('Knowledge Search Utils', () => { registry.recordResolved('TOKEN', 'secret-value') await runWithKnowledgeModelInputProvenance(registry, () => - generateSearchEmbedding('prefix secret-value suffix', 'text-embedding-3-small') + generateSearchEmbedding('prefix secret-value suffix', DEFAULT_EMBEDDING_TARGET) ) expect(vi.mocked(fetch)).toHaveBeenCalledWith( diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index d20f5c8b4a2..2fca7aa4ecc 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -133,7 +133,14 @@ function createEmbeddingFetchMock() { vi.stubGlobal('fetch', createEmbeddingFetchMock()) import { processDocumentAsync } from '@/lib/knowledge/documents/service' -import { generateEmbeddings } from '@/lib/knowledge/embeddings' +import { generateEmbeddings, type KbEmbeddingTarget } from '@/lib/knowledge/embeddings' + +/** The platform default model and vector width, as a knowledge base records them. */ +const DEFAULT_EMBEDDING_TARGET: KbEmbeddingTarget = { + model: 'text-embedding-3-small', + dimensions: 1536, +} + import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' describe('Knowledge Utils', () => { @@ -167,6 +174,7 @@ describe('Knowledge Utils', () => { knowledgeBaseUserId: 'user1', chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 }, embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, billedAccountUserId: 'billing-user-1', uploadedBy: null, filename: 'file.txt', @@ -248,7 +256,7 @@ describe('Knowledge Utils', () => { describe('generateEmbeddings', () => { it('should return same length as input', async () => { - const result = await generateEmbeddings(['a', 'b']) + const result = await generateEmbeddings(['a', 'b'], DEFAULT_EMBEDDING_TARGET) expect(result.embeddings.length).toBe(2) }) @@ -267,7 +275,7 @@ describe('Knowledge Utils', () => { const fetchSpy = vi.mocked(fetch) fetchSpy.mockResolvedValueOnce(createEmbeddingResponse([0.1])) - await generateEmbeddings(['test text']) + await generateEmbeddings(['test text'], DEFAULT_EMBEDDING_TARGET) expect(fetchSpy).toHaveBeenCalledWith( 'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview', @@ -291,7 +299,7 @@ describe('Knowledge Utils', () => { const fetchSpy = vi.mocked(fetch) fetchSpy.mockResolvedValueOnce(createEmbeddingResponse([0.1])) - await generateEmbeddings(['test text']) + await generateEmbeddings(['test text'], DEFAULT_EMBEDDING_TARGET) expect(fetchSpy).toHaveBeenCalledWith( 'https://api.openai.com/v1/embeddings', @@ -318,7 +326,7 @@ describe('Knowledge Utils', () => { }) try { - await expect(generateEmbeddings(['test text'])).rejects.toThrow( + await expect(generateEmbeddings(['test text'], DEFAULT_EMBEDDING_TARGET)).rejects.toThrow( 'OPENAI_API_KEY is not configured' ) } finally { diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index 20a00d38cba..3a6a4aa98a2 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -22,7 +22,7 @@ export interface KnowledgeBaseAccessResult { hasAccess: true knowledgeBase: Pick< KnowledgeBaseData, - 'id' | 'userId' | 'workspaceId' | 'name' | 'embeddingModel' + 'id' | 'userId' | 'workspaceId' | 'name' | 'embeddingModel' | 'embeddingDimension' > } @@ -53,6 +53,7 @@ async function resolveKnowledgeBaseAccess( workspaceId: knowledgeBase.workspaceId, name: knowledgeBase.name, embeddingModel: knowledgeBase.embeddingModel, + embeddingDimension: knowledgeBase.embeddingDimension, }) .from(knowledgeBase) .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts index 484fc15ee3a..7cec2121fb2 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts @@ -11,8 +11,8 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { - acquirePermissionGroupOrgLock, authorizeOrgAccessControl, findScopeConflicts, formatScopeConflictError, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts index b958a5791dd..27a95cf0c7f 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts @@ -11,10 +11,10 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { isOrganizationMember } from '@/lib/workspaces/permissions/utils' import { type AllMembersConflict, - acquirePermissionGroupOrgLock, authorizeOrgAccessControl, findAllMembersWorkspaceConflict, findScopeConflicts, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts index 572d129ddfe..fcf5b0ee280 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts @@ -15,9 +15,9 @@ import { type PermissionGroupConfig, parsePermissionGroupConfig, } from '@/lib/permission-groups/fields' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { type AllMembersConflict, - acquirePermissionGroupOrgLock, authorizeOrgAccessControl, findAllMembersWorkspaceConflict, findScopeConflicts, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts index c53dc9ae8d6..dd8e9400fe3 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts @@ -21,9 +21,9 @@ import { type PermissionGroupConfig, parsePermissionGroupConfig, } from '@/lib/permission-groups/fields' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { type AllMembersConflict, - acquirePermissionGroupOrgLock, authorizeOrgAccessControl, findAllMembersWorkspaceConflict, findWorkspacesNotInOrganization, 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 6329cfe4b17..9b26470f470 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts @@ -41,36 +41,6 @@ export async function authorizeOrgAccessControl( return null } -const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000 - -/** - * Serialize all permission-group membership and scope writes for an organization - * via a transaction-scoped Postgres advisory lock. Callers acquire it at the top - * of the transaction that both checks (`findScopeConflicts`) and mutates, so a - * concurrent member add or scope change can't commit in the check-to-write - * window and leave a user governed by two groups on the same workspace. - * - * The invariant (one effective group per user per workspace) spans users and - * groups in ways a unique constraint can't express, and these are low-frequency - * admin writes, so a single org-scoped lock is simpler and more obviously - * correct than fine-grained per-user/per-group locks with acquire-ordering. - * - * `pg_advisory_xact_lock` auto-releases at transaction end (safe on pooled - * connections), and `lock_timeout` bounds the wait (raising SQLSTATE 55P03) - * instead of hanging if a holder is stuck. - */ -export async function acquirePermissionGroupOrgLock( - tx: DbOrTx, - organizationId: string -): Promise { - await tx.execute( - sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)` - ) - await tx.execute( - sql`select pg_advisory_xact_lock(hashtextextended(${`permission_group:${organizationId}`}, 0))` - ) -} - /** Load a permission group only if it belongs to the given organization. */ export async function loadGroupInOrganization( groupId: string, diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts index 40747a907a4..54c23374a28 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -98,12 +98,13 @@ import { POST } from '@/app/api/v1/knowledge/search/route' const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess -const baseKb = (id: string, embeddingModel: string) => ({ +const baseKb = (id: string, embeddingModel: string, embeddingDimension = 1536) => ({ id, userId: 'user-1', name: `KB ${id}`, workspaceId: 'ws-1', embeddingModel, + embeddingDimension, deletedAt: null, }) @@ -150,7 +151,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => { expect(res.status).toBe(200) expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith( 'hello', - 'gemini-embedding-001', + { model: 'gemini-embedding-001', dimensions: 1536 }, 'ws-1' ) expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ @@ -191,6 +192,28 @@ describe('v1 knowledge search route — per-KB embedding model', () => { ) }) + it('rejects cross-KB queries with mixed vector widths, which store in different columns', async () => { + mockCheckKnowledgeBaseAccess + .mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: baseKb('kb-1536', 'text-embedding-3-large', 1536), + }) + .mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: baseKb('kb-3072', 'text-embedding-3-large', 3072), + }) + + const req = createMockRequest('POST', { + workspaceId: 'ws-1', + knowledgeBaseIds: ['kb-1536', 'kb-3072'], + query: 'hello', + }) + const res = await POST(req) + + expect(res.status).toBe(400) + expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() + }) + it('rejects cross-KB queries with mixed embedding models', async () => { mockCheckKnowledgeBaseAccess .mockResolvedValueOnce({ diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 05037fb52f0..44b347c4160 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -8,7 +8,12 @@ import { } from '@/lib/billing/core/billing-attribution' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' -import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { toKbEmbeddingDimensions } from '@/lib/knowledge/embedding-models' +import { + generateSearchEmbedding, + type KbEmbeddingTarget, + recordSearchEmbeddingUsage, +} from '@/lib/knowledge/embeddings' import { resolveKnowledgeSearchDefaults } from '@/lib/knowledge/search/defaults' import { executeKnowledgeSearch, @@ -183,17 +188,43 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const hasQuery = query && query.trim().length > 0 const hasFilters = structuredFilters.length > 0 - const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) - if (hasQuery && embeddingModels.length > 1) { + /** + * One query embedding serves every base in the request, so all of them must + * be indexed the same way — including the vector width, which selects the + * pgvector column each comparison reads. + * + * Built only for a query search. A tag-only request never embeds anything, + * so resolving a width it will not use would let one base recorded at an + * unstorable width fail a request that does not depend on it. + */ + const embeddingTargets = new Map( + accessibleKbs.map((kb) => [ + `${kb.embeddingModel}:${kb.embeddingDimension}`, + { model: kb.embeddingModel, dimensions: kb.embeddingDimension }, + ]) + ) + if (hasQuery && embeddingTargets.size > 1) { return NextResponse.json( { error: - 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.', + 'Selected knowledge bases use different embedding models or vector widths and cannot be searched together. Search them separately.', }, { status: 400 } ) } - const queryEmbeddingModel = embeddingModels[0] + const selectedTarget = [...embeddingTargets.values()][0] + const queryEmbeddingModel = selectedTarget.model + /** + * The width is narrowed to a storable one only for a query search, which is + * the only kind that reads a vector column. A tag-only search must not fail + * on a width it never uses. + */ + const queryEmbeddingTarget: KbEmbeddingTarget | undefined = hasQuery + ? { + model: selectedTarget.model, + dimensions: toKbEmbeddingDimensions(selectedTarget.dimensions), + } + : undefined let results: SearchResult[] let queryEmbeddingIsBYOK: boolean | null = null @@ -219,11 +250,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (hasQuery) { const queryEmbeddingResult = await generateSearchEmbedding( query!, - queryEmbeddingModel, + queryEmbeddingTarget!, workspaceId ) queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - const queryVector = JSON.stringify(queryEmbeddingResult.embedding) results = await executeKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK, @@ -231,7 +261,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { searchMode, boostRecency, query, - queryVector, + queryVector: { + vector: JSON.stringify(queryEmbeddingResult.embedding), + dimensions: queryEmbeddingTarget!.dimensions, + }, structuredFilters: hasFilters ? structuredFilters : undefined, }) } else { diff --git a/apps/sim/app/api/v2/credentials/providers/route.test.ts b/apps/sim/app/api/v2/credentials/providers/route.test.ts index 71972efbf57..8b32463c855 100644 --- a/apps/sim/app/api/v2/credentials/providers/route.test.ts +++ b/apps/sim/app/api/v2/credentials/providers/route.test.ts @@ -44,6 +44,7 @@ const providers = [ providerFamily: 'salesforce', available: true, supportsReconnect: true, + fields: [], authorizationOptions: [ { providerId: 'salesforce', label: 'Production' }, { providerId: 'salesforce-sandbox', label: 'Sandbox' }, diff --git a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts new file mode 100644 index 00000000000..a2be1cb8ae0 --- /dev/null +++ b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts @@ -0,0 +1,136 @@ +/** @vitest-environment node */ + +import crypto from 'node:crypto' +import { requestUtilsMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' + +const { mockVerifierTokens, mockEnqueue, mockRelease } = vi.hoisted(() => ({ + mockVerifierTokens: vi.fn(), + mockEnqueue: vi.fn(), + mockRelease: vi.fn(), +})) + +vi.mock('@/background/quickbooks-webhook-ingress', () => ({ + enqueueQuickBooksWebhookIngress: mockEnqueue, +})) +vi.mock('@/lib/core/admission/gate', () => ({ + admissionRejectedResponse: vi.fn(() => new Response(null, { status: 503 })), + tryAdmit: vi.fn(() => ({ release: mockRelease })), +})) +vi.mock('@/lib/webhooks/quickbooks-credentials', () => ({ + getQuickBooksWebhookVerifierTokensByAppKey: mockVerifierTokens, +})) +vi.mock('@/lib/core/utils/with-route-handler', () => ({ + withRouteHandler: + ( + handler: ( + request: NextRequest, + context: { params: Promise<{ appKey: string }> } + ) => Promise + ) => + (request: NextRequest, context: { params: Promise<{ appKey: string }> }) => + handler(request, context), +})) + +import { POST } from '@/app/api/webhooks/quickbooks/[appKey]/route' + +const APP_KEY = 'a'.repeat(43) +const validEvent = { + specversion: '1.0', + id: 'event-1', + source: 'quickbooks-online', + type: 'qbo.invoice.created.v1', + time: '2026-08-03T12:00:00Z', + intuitentityid: '123', + intuitaccountid: '456', +} + +function request(body: string, signature?: string): NextRequest { + return new NextRequest(`http://localhost/api/webhooks/quickbooks/${APP_KEY}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(signature ? { 'intuit-signature': signature } : {}), + }, + body, + }) +} + +function signedRequest(value: unknown, verifierToken = 'verifier'): NextRequest { + const body = JSON.stringify(value) + const signature = crypto.createHmac('sha256', verifierToken).update(body).digest('base64') + return request(body, signature) +} + +function callPost(webhookRequest: NextRequest, appKey = APP_KEY): Promise { + return POST(webhookRequest, { params: Promise.resolve({ appKey }) }) +} + +describe('QuickBooks webhook ingress route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifierTokens.mockResolvedValue(['verifier']) + requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1') + mockEnqueue.mockResolvedValue('job-1') + }) + + afterAll(() => { + requestUtilsMockFns.mockGenerateRequestId.mockReset() + }) + + it('accepts a signed multi-company batch for the addressed user-owned app', async () => { + const response = await callPost( + signedRequest([validEvent, { ...validEvent, id: 'event-2', intuitaccountid: '789' }]) + ) + expect(response.status).toBe(200) + expect(mockVerifierTokens).toHaveBeenCalledWith(APP_KEY) + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + appKey: APP_KEY, + events: [validEvent, { ...validEvent, id: 'event-2', intuitaccountid: '789' }], + requestId: 'request-1', + }) + ) + expect(mockRelease).toHaveBeenCalledOnce() + }) + + it('accepts any verifier token configured by a connection for the same Intuit app', async () => { + mockVerifierTokens.mockResolvedValue(['stale-verifier', 'current-verifier']) + + expect((await callPost(signedRequest([validEvent], 'current-verifier'))).status).toBe(200) + }) + + it('fails closed for unknown app keys and missing signatures', async () => { + expect((await callPost(signedRequest([validEvent]), 'invalid')).status).toBe(404) + mockVerifierTokens.mockResolvedValueOnce([]) + expect((await callPost(signedRequest([validEvent]))).status).toBe(404) + expect((await callPost(request(JSON.stringify([validEvent])))).status).toBe(401) + expect(mockEnqueue).not.toHaveBeenCalled() + }) + + it('rejects malformed signed payloads and batches over the event bound', async () => { + expect((await callPost(signedRequest({ invalid: true }))).status).toBe(400) + const events = Array.from({ length: 1001 }, (_, index) => ({ + ...validEvent, + id: `event-${index}`, + })) + expect((await callPost(signedRequest(events))).status).toBe(400) + expect(mockEnqueue).not.toHaveBeenCalled() + }) + + it('rejects oversized bodies before loading the credential or enqueueing', async () => { + const oversizedRequest = request('[]', 'irrelevant') + oversizedRequest.headers.set('content-length', String(WEBHOOK_MAX_BODY_BYTES + 1)) + + expect((await callPost(oversizedRequest)).status).toBe(413) + expect(mockVerifierTokens).not.toHaveBeenCalled() + expect(mockEnqueue).not.toHaveBeenCalled() + }) + + it('returns 503 when durable acceptance fails', async () => { + mockEnqueue.mockRejectedValue(new Error('queue unavailable')) + expect((await callPost(signedRequest([validEvent]))).status).toBe(503) + }) +}) diff --git a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts new file mode 100644 index 00000000000..3865dcecfce --- /dev/null +++ b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts @@ -0,0 +1,115 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + quickBooksWebhookEventsSchema, + quickBooksWebhookParamsSchema, +} from '@/lib/api/contracts/webhooks' +import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' +import { generateRequestId } from '@/lib/core/utils/request' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' +import { verifyQuickBooksSignatureAgainstVerifierTokens } from '@/lib/webhooks/providers/quickbooks' +import { getQuickBooksWebhookVerifierTokensByAppKey } from '@/lib/webhooks/quickbooks-credentials' +import { + enqueueQuickBooksWebhookIngress, + type QuickBooksWebhookIngressPayload, +} from '@/background/quickbooks-webhook-ingress' + +const logger = createLogger('QuickBooksWebhookIngress') +const BODY_LABEL = 'QuickBooks webhook body' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 60 + +async function readBody(request: Request): Promise { + assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, BODY_LABEL) + const buffer = await readStreamToBufferWithLimit(request.body, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + label: BODY_LABEL, + }) + return new TextDecoder().decode(buffer) +} + +/** Verifies one user-owned Intuit app before durably accepting its CloudEvents batch. */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ appKey: string }> }) => { + const ticket = tryAdmit() + if (!ticket) return admissionRejectedResponse() + + const requestId = generateRequestId() + const receivedAt = Date.now() + try { + const parsedParams = quickBooksWebhookParamsSchema.safeParse(await context.params) + if (!parsedParams.success) { + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } + const { appKey } = parsedParams.data + + let rawBody: string + try { + rawBody = await readBody(request) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ error: 'Request body too large' }, { status: 413 }) + } + throw error + } + + const verifierTokens = await getQuickBooksWebhookVerifierTokensByAppKey(appKey) + if (verifierTokens.length === 0) { + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } + const authError = verifyQuickBooksSignatureAgainstVerifierTokens( + rawBody, + request.headers.get('intuit-signature'), + verifierTokens, + requestId + ) + if (authError) return authError + + let json: unknown + try { + json = JSON.parse(rawBody) + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + const parsed = quickBooksWebhookEventsSchema.safeParse(json) + if (!parsed.success) { + logger.warn(`[${requestId}] Invalid QuickBooks webhook envelope`, { + issues: parsed.error.issues, + }) + return NextResponse.json({ error: 'Invalid webhook envelope' }, { status: 400 }) + } + + const payload: QuickBooksWebhookIngressPayload = { + appKey, + events: parsed.data, + headers: { + 'content-type': request.headers.get('content-type') ?? 'application/json', + }, + requestId, + receivedAt, + } + const jobId = await enqueueQuickBooksWebhookIngress(payload) + logger.info(`[${requestId}] Accepted QuickBooks webhook delivery`, { + eventCount: parsed.data.length, + jobId, + }) + return NextResponse.json({ ok: true }) + } catch (error) { + logger.error(`[${requestId}] QuickBooks webhook ingress error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 }) + } finally { + ticket.release() + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts index 5be59775eea..9de1752fb51 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts @@ -44,6 +44,7 @@ const RECORD = { folderId: null, uploadedAt: new Date('2026-08-04T00:00:00.000Z'), updatedAt: new Date('2026-08-04T00:00:00.000Z'), + contentUpdatedAt: new Date('2026-09-03T20:00:01.000Z'), } const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) } @@ -135,6 +136,43 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { }) }) + it('passes the content-version precondition to the existing authorized use case', async () => { + const expectedUpdatedAt = '2026-09-03T20:00:00.000Z' + const response = await PUT( + createRequest({ content: 'edited', expectedUpdatedAt }), + routeContext + ) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + file: { contentUpdatedAt: RECORD.contentUpdatedAt.toISOString() }, + }) + expect(mocks.updateContent).toHaveBeenCalledWith( + expect.objectContaining({ + principal: PRINCIPAL, + input: expect.objectContaining({ expectedUpdatedAt: new Date(expectedUpdatedAt) }), + }) + ) + }) + + it('rejects invalid content-version tokens before performing a write', async () => { + const response = await PUT( + createRequest({ content: 'edited', expectedUpdatedAt: 'yesterday' }), + routeContext + ) + expect(response.status).toBe(400) + expect(mocks.updateContent).not.toHaveBeenCalled() + }) + + it('preserves a CAS conflict as 409', async () => { + mocks.updateContent.mockRejectedValueOnce(new OrchestrationError('conflict', 'File changed')) + const response = await PUT( + createRequest({ content: 'edited', expectedUpdatedAt: '2026-09-03T20:00:00.000Z' }), + routeContext + ) + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ error: 'File changed' }) + }) + it('allows a base64 JSON body up to what the proxy forwards intact', async () => { const response = await PUT( createRequest({ content: 'TQ==', encoding: 'base64' }, 10 * 1024 * 1024), diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index fdf8fff3f30..cb37c14088a 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -34,6 +34,7 @@ export const PUT = defineInternalJsonRoute({ assertedWorkspaceId: params.id, content: body.content, encoding: body.encoding === 'base64' ? ('base64' as const) : ('utf-8' as const), + ...(body.expectedUpdatedAt ? { expectedUpdatedAt: new Date(body.expectedUpdatedAt) } : {}), }), useCase: updateWorkspaceFileContent, present: internalFilePresenters.successFile, diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 7c5c1ebecd5..50033c1d51c 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { type WorkspaceMode, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { listWorkspacesQuerySchema } from '@/lib/api/contracts' @@ -157,6 +158,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workspaceMode: creationPolicy.workspaceMode, billedAccountUserId: creationPolicy.billedAccountUserId, observedOrganizationId: creationPolicy.observedOrganizationId, + governingPermissionGroupOrganizationId: creationPolicy.governingPermissionGroupOrganizationId, }) captureServerEvent( @@ -207,6 +209,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => { { status: 409 } ) } + /** + * A lock timeout is contention, not a fault: creation serializes on the + * organization's mutation locks and now also on `permission_group:`, + * so a concurrent create or a permission-group admin write can exhaust the + * `lock_timeout` and abort this transaction. Answer 503 like the + * permission-group routes do, rather than letting it reach the generic 500 + * below — the caller should retry, and a 500 tells them the opposite. + */ + if (getPostgresErrorCode(error) === '55P03') { + return NextResponse.json( + { error: 'This organization is being updated by another request. Please try again.' }, + { status: 503 } + ) + } logger.error('Error creating workspace:', error) return NextResponse.json({ error: 'Failed to create workspace' }, { status: 500 }) } @@ -220,6 +236,7 @@ async function createDefaultWorkspace( workspaceMode: WorkspaceMode billedAccountUserId: string observedOrganizationId: string | null + governingPermissionGroupOrganizationId: string | null } ) { const firstName = userName?.split(' ')[0] || null @@ -231,6 +248,7 @@ async function createDefaultWorkspace( workspaceMode: creationPolicy.workspaceMode, billedAccountUserId: creationPolicy.billedAccountUserId, observedOrganizationId: creationPolicy.observedOrganizationId, + governingPermissionGroupOrganizationId: creationPolicy.governingPermissionGroupOrganizationId, }) } diff --git a/apps/sim/app/desktop/connect/page.test.tsx b/apps/sim/app/desktop/connect/page.test.tsx index b984f1a2649..8026218a4ee 100644 --- a/apps/sim/app/desktop/connect/page.test.tsx +++ b/apps/sim/app/desktop/connect/page.test.tsx @@ -60,7 +60,9 @@ describe('DesktopConnectPage', () => { beforeEach(() => { vi.clearAllMocks() baseUrl.value = 'https://sim.test' - mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'user@example.com' } }) + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', email: 'user@example.com' }, + }) }) it('hands the launcher an absolute complete URL so the callback can read the draft back', async () => { @@ -143,6 +145,27 @@ describe('DesktopConnectPage', () => { ) }) + it('starts a draft-scoped QuickBooks connect through the server authorize route', async () => { + await expect( + DesktopConnectPage( + pageProps({ + provider: 'quickbooks', + state: VALID_STATE, + port: PORT, + draftId: 'draft-1', + }) + ) + ).rejects.toThrow('NEXT_REDIRECT:') + + const authorize = new URL(mockRedirect.mock.calls[0][0]) + expect(authorize.pathname).toBe('/api/auth/oauth2/authorize') + expect(authorize.searchParams.get('draftId')).toBe('draft-1') + expect(authorize.searchParams.get('providerId')).toBeNull() + expect(authorize.searchParams.get('callbackURL')).toBe( + `https://sim.test/desktop/connect/complete?state=${VALID_STATE}&port=${PORT}` + ) + }) + it.each(['trello', 'instagram', 'shopify'])( 'starts %s through its dedicated authorize route in the system browser', async (provider) => { @@ -171,7 +194,12 @@ describe('DesktopConnectPage', () => { { provider: 'Google', state: VALID_STATE, port: PORT }, { provider: 'google-email', state: 'short', port: PORT }, { provider: 'google-email', state: VALID_STATE }, - { provider: 'google-email', state: VALID_STATE, port: PORT, draftId: 'bad draft' }, + { + provider: 'google-email', + state: VALID_STATE, + port: PORT, + draftId: 'bad draft', + }, ] for (const params of invalid) { diff --git a/apps/sim/app/desktop/connect/page.tsx b/apps/sim/app/desktop/connect/page.tsx index e34f16989ba..2a7edb3aa9f 100644 --- a/apps/sim/app/desktop/connect/page.tsx +++ b/apps/sim/app/desktop/connect/page.tsx @@ -77,7 +77,10 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec // Force a DB-backed session read (bypass the cookie cache) so a revoked // browser session goes to login instead of starting a doomed link flow. const hdrs = await headers() - const session = await auth.api.getSession({ headers: hdrs, query: { disableCookieCache: true } }) + const session = await auth.api.getSession({ + headers: hdrs, + query: { disableCookieCache: true }, + }) if (!session?.user) { redirect( `/login?callbackUrl=${encodeURIComponent( @@ -130,6 +133,13 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec redirect(authorize.toString()) } + if (providerId === 'quickbooks' && draftId) { + const authorize = new URL('/api/auth/oauth2/authorize', getBaseUrl()) + authorize.searchParams.set('draftId', draftId) + authorize.searchParams.set('callbackURL', buildConnectCompleteUrl(state, port)) + redirect(authorize.toString()) + } + if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') { const authorize = new URL(`/api/auth/${providerId}/authorize`, getBaseUrl()) authorize.searchParams.set('returnUrl', buildConnectCompleteUrl(state, port)) diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx index e5103ad57a0..aab03e439b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx @@ -8,18 +8,62 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ createDraft: vi.fn(), connectOAuthService: vi.fn(), + getServiceConfigByProviderId: vi.fn(), onConnect: vi.fn(), + clearOAuthReturnContext: vi.fn(), + workspaceCredentials: vi.fn(), + writeOAuthReturnContext: vi.fn(), })) +interface MockChipModalFieldProps { + children?: ReactNode + inputType?: string + onChange?: (value: string) => void + options?: Array<{ label: string; value: string }> + title: string + type?: string + value?: string +} + vi.mock('@sim/emcn', () => ({ Badge: ({ children }: { children?: ReactNode }) => {children}, ChipModal: ({ open, children }: { open: boolean; children?: ReactNode }) => open ?
{children}
: null, ChipModalBody: ({ children }: { children?: ReactNode }) =>
{children}
, ChipModalError: ({ children }: { children?: ReactNode }) =>
{children}
, - ChipModalField: ({ title, children }: { title: string; children?: ReactNode }) => ( + ChipModalField: ({ + title, + children, + inputType, + onChange, + options, + type, + value, + }: MockChipModalFieldProps) => (
{title} + {type === 'input' && ( + onChange?.(event.target.value)} + /> + )} + {type === 'dropdown' && ( + + )} + {type === 'copy' && {value}} {children}
), @@ -49,7 +93,8 @@ vi.mock('@/lib/auth/auth-client', () => ({ vi.mock('@/lib/credentials/client-state', () => ({ ADD_CONNECTOR_SEARCH_PARAM: 'addConnector', - writeOAuthReturnContext: vi.fn(), + clearOAuthReturnContext: mocks.clearOAuthReturnContext, + writeOAuthReturnContext: mocks.writeOAuthReturnContext, })) vi.mock('@/lib/credentials/display-name', () => ({ @@ -70,7 +115,7 @@ vi.mock('@/lib/oauth', () => ({ vi.mock('@/lib/oauth/utils', () => ({ getScopeDescription: (scope: string) => scope, - getServiceConfigByProviderId: () => null, + getServiceConfigByProviderId: mocks.getServiceConfigByProviderId, })) vi.mock('@/blocks/brand-icon', () => ({ @@ -82,10 +127,7 @@ vi.mock('@/hooks/queries/credentials', () => ({ mutateAsync: mocks.createDraft, isPending: false, }), - useWorkspaceCredentials: () => ({ - data: [], - isPending: false, - }), + useWorkspaceCredentials: mocks.workspaceCredentials, })) vi.mock('@/hooks/queries/oauth/oauth-connections', () => ({ @@ -141,12 +183,30 @@ async function clickConnect() { }) } +function setFormControlValue(control: HTMLInputElement | HTMLSelectElement, value: string) { + const prototype = + control instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLSelectElement.prototype + const valueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set + valueSetter?.call(control, value) + control.dispatchEvent( + new Event(control instanceof HTMLSelectElement ? 'change' : 'input', { + bubbles: true, + }) + ) +} + describe('ConnectOAuthModal reauthorization', () => { beforeEach(() => { vi.clearAllMocks() - mocks.createDraft.mockResolvedValue({ success: true, draftId: 'draft-exact' }) + mocks.createDraft.mockResolvedValue({ + success: true, + draftId: 'draft-exact', + }) mocks.connectOAuthService.mockResolvedValue({ success: true }) mocks.onConnect.mockResolvedValue(undefined) + mocks.getServiceConfigByProviderId.mockReturnValue(null) + mocks.workspaceCredentials.mockReturnValue({ data: [], isPending: false }) + window.history.replaceState({}, '', '/') ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) @@ -183,6 +243,15 @@ describe('ConnectOAuthModal reauthorization', () => { expect(mocks.createDraft.mock.invocationCallOrder[0]).toBeLessThan( mocks.connectOAuthService.mock.invocationCallOrder[0] ) + expect(mocks.writeOAuthReturnContext).toHaveBeenCalledWith( + expect.objectContaining({ + origin: 'integrations', + displayName: 'Team Slack', + providerId: 'slack', + workspaceId: 'workspace-1', + reconnect: true, + }) + ) }) it('does not launch OAuth when the reconnect draft cannot be created', async () => { @@ -199,6 +268,24 @@ describe('ConnectOAuthModal reauthorization', () => { expect(mocks.connectOAuthService).not.toHaveBeenCalled() expect(container).toHaveTextContent('Draft creation failed') + expect(mocks.clearOAuthReturnContext).not.toHaveBeenCalled() + }) + + it('clears reconnect context when the provider handoff cannot start', async () => { + mocks.connectOAuthService.mockRejectedValue(new Error('Provider launch failed')) + renderReauthorizeModal({ + reconnectTarget: { + workspaceId: 'workspace-1', + credentialId: 'credential-slack', + displayName: 'Team Slack', + }, + }) + + await clickConnect() + + expect(mocks.writeOAuthReturnContext).toHaveBeenCalledOnce() + expect(mocks.clearOAuthReturnContext).toHaveBeenCalledOnce() + expect(container).toHaveTextContent('Provider launch failed') }) it('preserves provider-only reauthorization without creating a draft', async () => { @@ -214,6 +301,23 @@ describe('ConnectOAuthModal reauthorization', () => { }) }) + it('does not carry a prior OAuth result into a new provider callback URL', async () => { + window.history.replaceState( + {}, + '', + '/?keep=1&error=stale&error_description=stale-detail&quickbooks_connected=true' + ) + renderReauthorizeModal() + + await clickConnect() + + expect(mocks.connectOAuthService).toHaveBeenCalledWith({ + providerId: 'slack', + callbackURL: 'http://localhost:3000/?keep=1', + draftId: undefined, + }) + }) + it('keeps an onConnect override ahead of credential-bound reauthorization', async () => { renderReauthorizeModal({ reconnectTarget: { @@ -230,4 +334,101 @@ describe('ConnectOAuthModal reauthorization', () => { expect(mocks.createDraft).not.toHaveBeenCalled() expect(mocks.connectOAuthService).not.toHaveBeenCalled() }) + + it('collects QuickBooks app credentials inside the standard OAuth connection flow', async () => { + const onOpenChange = vi.fn() + mocks.getServiceConfigByProviderId.mockReturnValue({ + clientConfiguration: { + redirectPath: '/api/auth/oauth2/callback/quickbooks', + fields: [ + { id: 'clientId', label: 'Client ID', type: 'text' }, + { + id: 'clientSecret', + label: 'Client secret', + type: 'secret', + secret: true, + }, + { + id: 'webhookVerifierToken', + label: 'Webhook verifier token', + type: 'secret', + secret: true, + }, + { + id: 'environment', + label: 'Environment', + type: 'select', + options: [ + { label: 'Sandbox', value: 'sandbox' }, + { label: 'Production', value: 'production' }, + ], + }, + ], + }, + }) + + act(() => { + root.render( + + ) + }) + + const clientId = container.querySelector('input[aria-label="Client ID"]') + const clientSecret = container.querySelector( + 'input[aria-label="Client secret"]' + ) + const environment = container.querySelector( + 'select[aria-label="Environment"]' + ) + const webhookVerifierToken = container.querySelector( + 'input[aria-label="Webhook verifier token"]' + ) + expect(clientSecret?.type).toBe('password') + expect(webhookVerifierToken?.type).toBe('password') + expect(container).toHaveTextContent('http://localhost:3000/api/auth/oauth2/callback/quickbooks') + + act(() => { + if (clientId) { + setFormControlValue(clientId, 'client-id') + } + if (clientSecret) { + setFormControlValue(clientSecret, 'client-secret') + } + if (environment) { + setFormControlValue(environment, 'production') + } + if (webhookVerifierToken) { + setFormControlValue(webhookVerifierToken, 'verifier-token') + } + }) + + await clickConnect() + + expect(mocks.createDraft).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + providerId: 'quickbooks', + oauthClientConfig: { + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'production', + webhookVerifierToken: 'verifier-token', + }, + }) + ) + expect(mocks.writeOAuthReturnContext).toHaveBeenCalledOnce() + expect(mocks.clearOAuthReturnContext).not.toHaveBeenCalled() + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mocks.writeOAuthReturnContext.mock.invocationCallOrder[0]).toBeLessThan( + onOpenChange.mock.invocationCallOrder[0] + ) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index 9b76a0425c5..b844023b37a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -17,7 +17,11 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useSession } from '@/lib/auth/auth-client' import type { OAuthReturnContext } from '@/lib/credentials/client-state' -import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state' +import { + ADD_CONNECTOR_SEARCH_PARAM, + clearOAuthReturnContext, + writeOAuthReturnContext, +} from '@/lib/credentials/client-state' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { getProviderIdFromServiceId, @@ -44,6 +48,17 @@ const EMPTY_SCOPES: readonly string[] = [] type ServiceIcon = ComponentType<{ className?: string }> +function initialOAuthClientFields( + fields: readonly { id: string; options?: readonly { value: string }[] }[] | undefined +): Record { + const values: Record = {} + for (const field of fields ?? []) { + const defaultOption = field.options?.[0] + if (defaultOption) values[field.id] = defaultOption.value + } + return values +} + /** Scopes hidden from the permissions list — always present on Google flows. */ function isHiddenScope(scope: string): boolean { return scope.includes('userinfo.email') || scope.includes('userinfo.profile') @@ -110,7 +125,11 @@ type ConnectOAuthModalConnectProps = ConnectOAuthModalBaseProps & { requiredScopes: readonly string[] } & ( | { origin: 'workflow'; workflowId: string } - | { origin: 'kb-connectors'; knowledgeBaseId: string; connectorType?: string } + | { + origin: 'kb-connectors' + knowledgeBaseId: string + connectorType?: string + } | { origin: 'integrations' } ) @@ -178,6 +197,8 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { const [displayName, setDisplayName] = useState('') const [description, setDescription] = useState('') + const [oauthClientFields, setOAuthClientFields] = useState>({}) + const [oauthClientFieldsOpen, setOAuthClientFieldsOpen] = useState(null) const [validationError, setValidationError] = useState(null) const [submitError, setSubmitError] = useState(null) @@ -186,16 +207,24 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { const { providerName, ProviderIcon } = useMemo(() => { if (props.serviceName && props.serviceIcon) { - return { providerName: props.serviceName, ProviderIcon: props.serviceIcon } + return { + providerName: props.serviceName, + ProviderIcon: props.serviceIcon, + } } const provider = (props.provider ?? providerId) as OAuthProvider return resolveService(provider, props.serviceId ?? providerId) }, [props.serviceName, props.serviceIcon, props.provider, props.serviceId, providerId]) - const workspaceId = isConnect ? props.workspaceId : '' + const workspaceId = isConnect ? props.workspaceId : (props.reconnectTarget?.workspaceId ?? '') + const clientConfiguration = getServiceConfigByProviderId(providerId)?.clientConfiguration + const oauthClientRedirectUri = + clientConfiguration?.redirectPath && typeof window !== 'undefined' + ? new URL(clientConfiguration.redirectPath, window.location.origin).toString() + : null const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({ workspaceId, - enabled: isConnect && Boolean(workspaceId) && open, + enabled: Boolean(workspaceId) && open, }) const createDraft = useCreateCredentialDraft() const connectOAuthService = useConnectOAuthService() @@ -208,6 +237,11 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { required: props.requireDataverseEnvironment === true, }) + if (oauthClientFieldsOpen !== open) { + setOAuthClientFieldsOpen(open) + setOAuthClientFields(open ? initialOAuthClientFields(clientConfiguration?.fields) : {}) + } + /** * Lowercased set of OAuth credential names already in the workspace. Drives * both the prefill's auto-numbering and the inline duplicate-name error. @@ -278,6 +312,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { const handleConnect = async () => { setValidationError(null) setSubmitError(null) + let returnContextWritten = false try { const environmentUrl = dataverseEnvironmentForm.validate() if (dataverseEnvironmentForm.enabled && !environmentUrl) return @@ -285,6 +320,23 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { let connectorType: string | undefined let draftId: string | undefined + const quickBooksOAuthClientConfig = + providerId === 'quickbooks' + ? { + clientId: oauthClientFields.clientId?.trim() ?? '', + clientSecret: oauthClientFields.clientSecret?.trim() ?? '', + environment: + oauthClientFields.environment === 'production' + ? ('production' as const) + : ('sandbox' as const), + webhookVerifierToken: oauthClientFields.webhookVerifierToken?.trim() ?? '', + } + : undefined + + if (clientConfiguration?.fields.some((field) => !oauthClientFields[field.id]?.trim())) { + setSubmitError('Complete every OAuth app configuration field.') + return + } if (isConnect) { const trimmed = displayName.trim() @@ -298,6 +350,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { providerId, displayName: trimmed, description: description.trim() || undefined, + oauthClientConfig: quickBooksOAuthClientConfig, }) draftId = draft.draftId @@ -332,12 +385,17 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { connectorType: props.connectorType, } } else if (props.origin === 'workflow') { - returnContext = { ...baseContext, origin: 'workflow', workflowId: props.workflowId } + returnContext = { + ...baseContext, + origin: 'workflow', + workflowId: props.workflowId, + } } else { returnContext = { ...baseContext, origin: 'integrations' } } writeOAuthReturnContext(returnContext) + returnContextWritten = true } else if (props.onConnect) { await props.onConnect() handleClose() @@ -349,8 +407,28 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { providerId, credentialId: props.reconnectTarget.credentialId, displayName: props.reconnectTarget.displayName, + oauthClientConfig: quickBooksOAuthClientConfig, }) draftId = draft.draftId + + const providerCredentials = credentials.filter( + (credential) => credential.type === 'oauth' && credential.providerId === providerId + ) + writeOAuthReturnContext({ + origin: 'integrations', + displayName: props.reconnectTarget.displayName, + providerId, + preCount: providerCredentials.length, + baselineCredentials: providerCredentials.map((credential) => ({ + id: credential.id, + accountId: credential.accountId, + updatedAt: credential.updatedAt, + })), + workspaceId: props.reconnectTarget.workspaceId, + reconnect: true, + requestedAt: Date.now(), + }) + returnContextWritten = true } logger.info('Reauthorizing OAuth2', { @@ -361,6 +439,9 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { } const callbackURL = new URL(window.location.href) + callbackURL.searchParams.delete('error') + callbackURL.searchParams.delete('error_description') + callbackURL.searchParams.delete('quickbooks_connected') if (connectorType) { callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType) } @@ -380,6 +461,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { } handleClose() } catch (err: unknown) { + if (returnContextWritten) clearOAuthReturnContext() const message = getErrorMessage(err, 'Failed to start OAuth connection') setSubmitError(message) logger.error('Failed to connect OAuth service', err) @@ -394,9 +476,12 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { const isDisabled = isConnect ? !displayName.trim() || !dataverseEnvironmentForm.isComplete || + Boolean(clientConfiguration?.fields.some((field) => !oauthClientFields[field.id]?.trim())) || isPending || Boolean(existingCredential) - : !dataverseEnvironmentForm.isComplete || isPending + : !dataverseEnvironmentForm.isComplete || + Boolean(clientConfiguration?.fields.some((field) => !oauthClientFields[field.id]?.trim())) || + isPending const displayNameError = validationError ?? @@ -451,6 +536,55 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { + {oauthClientRedirectUri && ( + + )} + + {clientConfiguration?.fields.map((field) => + field.options ? ( + + setOAuthClientFields((current) => ({ + ...current, + [field.id]: value, + })) + } + options={[...field.options]} + placeholder={field.placeholder} + hint={field.hint} + required + align='start' + /> + ) : ( + + setOAuthClientFields((current) => ({ + ...current, + [field.id]: value, + })) + } + placeholder={field.placeholder} + inputType={field.secret ? 'password' : 'text'} + autoComplete={field.secret ? 'new-password' : 'off'} + required + /> + ) + )} + {isConnect && ( ({ + mockMemberAccessAvailable: vi.fn(() => true), +})) + +vi.mock('@/hooks/use-member-access', () => ({ + useMemberAccessAvailable: () => mockMemberAccessAvailable(), +})) + +import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(active: 'integrations' | 'skills' | 'search' = 'integrations') { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render()) +} + +function tabs(): string[] { + return Array.from(container?.querySelectorAll('a') ?? []).map((node) => node.textContent ?? '') +} + +beforeEach(() => { + mockMemberAccessAvailable.mockReturnValue(true) +}) + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +describe('IntegrationTabsHeader', () => { + it('links every tab to its page in the routed workspace', () => { + mount() + + expect(tabs()).toEqual(['Integrations', 'Skills', 'Search']) + expect( + Array.from(container?.querySelectorAll('a') ?? []).map((node) => node.getAttribute('href')) + ).toEqual([ + '/workspace/workspace-1/integrations', + '/workspace/workspace-1/skills', + '/workspace/workspace-1/search', + ]) + }) + + it('omits Search where per-member access is off, matching the page that 404s', () => { + mockMemberAccessAvailable.mockReturnValue(false) + mount() + + expect(tabs()).toEqual(['Integrations', 'Skills']) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx index 60863ec586f..248d7936669 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx @@ -1,6 +1,9 @@ +'use client' + import type { ReactNode } from 'react' import { ChipLink, cn } from '@sim/emcn' import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { useMemberAccessAvailable } from '@/hooks/use-member-access' interface IntegrationTabsHeaderProps { active: 'integrations' | 'skills' | 'search' @@ -17,6 +20,11 @@ interface IntegrationTabsHeaderProps { * because every page owns it equally; its former home made Skills reach across * into a sibling feature for its own chrome. * + * Search appears only where per-member access is on, matching the page it links + * to, which 404s otherwise. A client component so the three pages and their + * Suspense fallbacks all read that one judgement from the workspace host + * context rather than each resolving it again on the server. + * * The `gap-1` is explicit because chips carry no outer margin — the parent owns the * space between them. */ @@ -25,6 +33,8 @@ export function IntegrationTabsHeader({ workspaceId, rightSlot, }: IntegrationTabsHeaderProps) { + const memberAccessAvailable = useMemberAccessAvailable() + return (
@@ -33,9 +43,11 @@ export function IntegrationTabsHeader({ Skills - - Search - + {memberAccessAvailable && ( + + Search + + )} {rightSlot &&
{rightSlot}
}
) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict.tsx new file mode 100644 index 00000000000..0c0cfc85813 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict.tsx @@ -0,0 +1,42 @@ +'use client' + +import { Chip, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' + +interface FileSaveConflictProps { + isReloading: boolean + reloadLatestContent: () => Promise + downloadDraft: () => void +} + +/** Keeps both versions recoverable until the user explicitly replaces the local draft. */ +export function FileSaveConflict({ + isReloading, + reloadLatestContent, + downloadDraft, +}: FileSaveConflictProps) { + return ( +
+

+ Saving paused: the file changed elsewhere. Your local draft is preserved. Reload replaces it + with the latest version. +

+ Download local draft + { + void reloadLatestContent().catch((error) => + toast.error( + getErrorMessage(error, 'Could not reload the file. Your local draft is unchanged.') + ) + ) + }} + > + {isReloading ? 'Reloading…' : 'Discard draft and reload'} + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.test.ts new file mode 100644 index 00000000000..8bd13ebcf09 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.test.ts @@ -0,0 +1,158 @@ +/** @vitest-environment jsdom */ +import { Editor } from '@tiptap/core' +import { GapCursor } from '@tiptap/pm/gapcursor' +import { NodeSelection, TextSelection } from '@tiptap/pm/state' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' + +let editor: Editor | undefined +afterEach(() => { + editor?.destroy() + editor = undefined +}) + +function mount(content: string): Editor { + editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }), content }) + return editor +} + +function findText(ed: Editor, text: string): number { + let position = -1 + ed.state.doc.descendants((node, pos) => { + if (node.isText && node.text === text) position = pos + }) + expect(position).toBeGreaterThan(-1) + return position +} + +function move(ed: Editor, direction: 'up' | 'down'): boolean { + return direction === 'up' ? ed.commands.moveBlockUp() : ed.commands.moveBlockDown() +} + +describe('block movement across leaf siblings', () => { + it.each(['
', ''])( + 'moves text across %s in both directions', + (leaf) => { + const ed = mount(`${leaf}

abcdef

tail

`) + ed.commands.setTextSelection(findText(ed, 'abcdef') + 3) + const before = ed.getJSON() + + expect(ed.commands.moveBlockUp()).toBe(true) + expect(ed.state.doc.firstChild?.textContent).toBe('abcdef') + expect(ed.state.selection.$from.parentOffset).toBe(3) + expect(ed.commands.moveBlockDown()).toBe(true) + expect(ed.getJSON()).toEqual(before) + expect(ed.state.selection.$from.parentOffset).toBe(3) + } + ) + + it.each(['
', ''])( + 'moves selected %s without replacing node selection with a caret', + (leaf) => { + const ed = mount(`

before

${leaf}

after

`) + const leafPosition = ed.state.doc.firstChild?.nodeSize ?? 0 + ed.commands.setNodeSelection(leafPosition) + const before = ed.getJSON() + const selectedType = (ed.state.selection as NodeSelection).node.type.name + + expect(ed.commands.moveBlockUp()).toBe(true) + expect(ed.state.selection instanceof NodeSelection).toBe(true) + expect(ed.state.selection.from).toBe(0) + expect(ed.state.doc.firstChild?.type.name).toBe(selectedType) + expect(ed.commands.moveBlockDown()).toBe(true) + expect(ed.getJSON()).toEqual(before) + expect(ed.state.selection instanceof NodeSelection).toBe(true) + expect(ed.state.selection.from).toBe(leafPosition) + } + ) + + it('supports the actual keyboard chord on a selected divider', () => { + const ed = mount('

before


after

') + ed.commands.setNodeSelection(8) + ed.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp', + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ) + + expect(ed.state.doc.firstChild?.type.name).toBe('horizontalRule') + expect(ed.state.selection instanceof NodeSelection).toBe(true) + expect(ed.state.selection.from).toBe(0) + }) +}) + +describe('block movement preserves selection intent', () => { + it.each(['up', 'down'] as const)( + 'preserves a backwards selected text range when moving %s', + (direction) => { + const ed = mount('

before

abcdef

after

') + const pos = findText(ed, 'abcdef') + ed.commands.setTextSelection({ from: pos + 5, to: pos + 1 }) + const before = ed.getJSON() + + expect(move(ed, direction)).toBe(true) + expect(ed.state.selection instanceof TextSelection).toBe(true) + expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to)).toBe('bcde') + expect(ed.state.selection.anchor).toBeGreaterThan(ed.state.selection.head) + expect(ed.commands.undo()).toBe(true) + expect(ed.getJSON()).toEqual(before) + expect(ed.state.selection.anchor).toBe(pos + 5) + expect(ed.state.selection.head).toBe(pos + 1) + expect(ed.commands.redo()).toBe(true) + expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to)).toBe('bcde') + } + ) + + it.each(['up', 'down'] as const)( + 'moves all selected blocks together %s, retaining their order', + (direction) => { + const ed = mount('

before

first


second

after

') + const start = findText(ed, 'first') + 2 + const end = findText(ed, 'second') + 4 + ed.commands.setTextSelection({ from: end, to: start }) + const selected = ed.state.doc.textBetween(start, end, '\n') + + expect(move(ed, direction)).toBe(true) + const nodes: string[] = [] + ed.state.doc.forEach((node) => nodes.push(node.textContent || node.type.name)) + expect(nodes).toEqual( + direction === 'up' + ? ['first', 'horizontalRule', 'second', 'before', 'after'] + : ['before', 'after', 'first', 'horizontalRule', 'second'] + ) + expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to, '\n')).toBe( + selected + ) + expect(ed.state.selection.anchor).toBeGreaterThan(ed.state.selection.head) + } + ) + + it('keeps nested selection and descendant marks inside the moved list', () => { + const ed = mount( + '

before

  • parent

    • child

after

' + ) + const pos = findText(ed, 'child') + ed.commands.setTextSelection({ from: pos, to: pos + 5 }) + const listBefore = ed.state.doc.child(1).toJSON() + + expect(ed.commands.moveBlockUp()).toBe(true) + expect(ed.state.doc.firstChild?.toJSON()).toEqual(listBefore) + expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to)).toBe('child') + expect(ed.state.selection.$from.depth).toBe(5) + }) + + it('returns false at the document edge and for a root gap without a selected block', () => { + const ed = mount('

last

') + ed.commands.setNodeSelection(0) + expect(ed.can().moveBlockUp()).toBe(false) + expect(ed.commands.moveBlockUp()).toBe(false) + ed.view.dispatch(ed.state.tr.setSelection(new GapCursor(ed.state.doc.resolve(0)))) + expect(ed.commands.moveBlockDown()).toBe(false) + ed.commands.setTextSelection(findText(ed, 'last')) + expect(ed.commands.moveBlockDown()).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.ts index 9c930173755..cb2d3cd949d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.ts @@ -1,54 +1,58 @@ import { Extension } from '@tiptap/core' +import { Slice } from '@tiptap/pm/model' import type { EditorState, Transaction } from '@tiptap/pm/state' -import { TextSelection } from '@tiptap/pm/state' +import { NodeSelection } from '@tiptap/pm/state' +import { ReplaceAroundStep, StepMap } from '@tiptap/pm/transform' -/** The position range of the depth-1 block containing the cursor, or null at the document root. */ -function currentTopLevelBlock(state: EditorState): { from: number; to: number } | null { - const { $from } = state.selection - if ($from.depth === 0) return null - return { from: $from.before(1), to: $from.after(1) } +/** The contiguous top-level blocks touched by a text range or node selection. */ +function currentTopLevelBlocks(state: EditorState): { from: number; to: number } | null { + const { selection } = state + const { $from, $to } = selection + if ($from.depth === 0 && !(selection instanceof NodeSelection)) return null + return { + from: $from.depth > 0 ? $from.before(1) : $from.pos, + to: $to.depth > 0 ? $to.after(1) : $to.pos, + } } /** - * Swaps the current top-level block with its neighbour in `direction`, keeping the caret on the moved - * block. Adjacent top-level blocks share a boundary position (no separator token between them), so the - * move is a single `replaceWith` of the two-block span with the pair reordered. No-ops (returns false) - * at the matching document edge or when the neighbour isn't a top-level block. `newBefore` is the moved - * block's new `before(1)` position; adding the caret's original offset (`selection.from - from`, also - * measured from `before(1)`) re-anchors the caret at the same spot within the block. + * Swaps the selected block range with its immediate sibling, including one-position leaf nodes. + * The replace-around step maps positions inside the moved content. A translated selection bookmark + * preserves selection kind, both endpoints, and direction instead of collapsing a range to a caret. */ function moveBlock( state: EditorState, dispatch: ((tr: Transaction) => void) | undefined, direction: 'up' | 'down' ): boolean { - const block = currentTopLevelBlock(state) + const block = currentTopLevelBlocks(state) if (!block) return false const { from, to } = block const up = direction === 'up' if (up ? from === 0 : to >= state.doc.content.size) return false - const $neighbour = state.doc.resolve(up ? from - 1 : to + 1) - if ($neighbour.depth === 0) return false + const boundary = state.doc.resolve(up ? from : to) + const sibling = up ? boundary.nodeBefore : boundary.nodeAfter + if (!sibling) return false if (!dispatch) return true - const spanFrom = up ? $neighbour.before(1) : from - const spanTo = up ? to : $neighbour.after(1) - const moving = state.doc.slice(from, to).content + const spanFrom = up ? from - sibling.nodeSize : from + const spanTo = up ? to : to + sibling.nodeSize const neighbour = up ? state.doc.slice(spanFrom, from).content : state.doc.slice(to, spanTo).content - const tr = state.tr.replaceWith( - spanFrom, - spanTo, - up ? moving.append(neighbour) : neighbour.append(moving) - ) - - const newBefore = up ? spanFrom : spanFrom + neighbour.size - const offset = state.selection.from - from - tr.setSelection( - TextSelection.near(tr.doc.resolve(Math.min(newBefore + offset, newBefore + moving.size))) + const tr = state.tr.step( + new ReplaceAroundStep( + spanFrom, + spanTo, + from, + to, + new Slice(neighbour, 0, 0), + up ? 0 : neighbour.size + ) ) + const offset = up ? -sibling.nodeSize : sibling.nodeSize + tr.setSelection(state.selection.getBookmark().map(StepMap.offset(offset)).resolve(tr.doc)) dispatch(tr.scrollIntoView()) return true } @@ -56,18 +60,17 @@ function moveBlock( declare module '@tiptap/core' { interface Commands { blockMover: { - /** Move the current top-level block up one position, carrying the caret. */ + /** Move the selected top-level block range up one position, preserving the selection. */ moveBlockUp: () => ReturnType - /** Move the current top-level block down one position, carrying the caret. */ + /** Move the selected top-level block range down one position, preserving the selection. */ moveBlockDown: () => ReturnType } } } /** - * Reorders the current top-level block with `Mod-Shift-ArrowUp`/`ArrowDown` — the standard - * keyboard block-move affordance (Notion/Obsidian). Pure UI interaction: no schema change, and the - * caret rides along with the block. A no-op (returns false, falling through) at the document edges. + * Reorders the selected top-level blocks with `Mod-Shift-ArrowUp`/`ArrowDown`, keeping their order + * and selection. Returns false at document edges and for root gap cursors with no selected block. */ export const BlockMover = Extension.create({ name: 'blockMover', diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/bullet-list.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/bullet-list.test.ts new file mode 100644 index 00000000000..22c22071a58 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/bullet-list.test.ts @@ -0,0 +1,153 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' + +let editor: Editor | undefined + +afterEach(() => { + editor?.destroy() + editor = undefined +}) + +function mount(content: string): Editor { + editor = new Editor({ extensions: createMarkdownContentExtensions(), content }) + return editor +} + +/** Runs the real text-input rules; insertContent alone does not trigger them. */ +function typeBullet(ed: Editor, marker = '-'): void { + ed.commands.insertContent(marker) + const { from, to } = ed.state.selection + expect( + ed.view.someProp('handleTextInput', (handler) => + handler(ed.view, from, to, ' ', () => ed.state.tr) + ) + ).toBe(true) +} + +describe('typed bullet list joining', () => { + it.each(['-', '+', '*'])('joins %s typed after a heading to the following list', (marker) => { + const ed = mount('

Todos:

  • one

  • two

') + ed.commands.setTextSelection(7) + ed.commands.keyboardShortcut('Enter') + typeBullet(ed, marker) + ed.commands.insertContent('new') + + expect( + ed + .getJSON() + .content?.slice(0, 2) + .map((node) => node.type) + ).toEqual(['heading', 'bulletList']) + expect(ed.getJSON().content?.filter((node) => node.type === 'bulletList')).toHaveLength(1) + expect(ed.getJSON().content?.[1].content).toHaveLength(3) + expect(ed.getMarkdown().trim()).toBe('## Todos:\n\n- new\n- one\n- two') + expect(ed.state.selection.$from.parent.textContent).toBe('new') + expect(ed.view.dom.querySelectorAll(':scope > ul')).toHaveLength(1) + }) + + it('joins the preceding list without merging two existing roots', () => { + const ed = mount('
  • one

  • two

') + ed.commands.setTextSelection(10) + typeBullet(ed) + ed.commands.insertContent('new') + + expect(ed.getJSON().content?.filter((node) => node.type === 'bulletList')).toHaveLength(2) + expect(ed.getMarkdown().trim()).toBe('- one\n- new\n\n- two') + }) + + it('does not join across an intentional blank paragraph', () => { + const ed = mount('

  • one

') + ed.commands.setTextSelection(1) + typeBullet(ed) + ed.commands.insertContent('new') + + expect( + ed + .getJSON() + .content?.slice(0, 3) + .map((node) => node.type) + ).toEqual(['bulletList', 'paragraph', 'bulletList']) + }) + + it('joins a nested list without changing its depth or dropping formatted content', () => { + const ed = mount( + '
  • parent

    • child

' + ) + ed.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.content.size === 0) { + ed.commands.setTextSelection(pos + 1) + } + }) + typeBullet(ed) + ed.commands.insertContent('new child') + + const parent = ed.getJSON().content?.[0].content?.[0] + expect(parent?.content?.map((node) => node.type)).toEqual(['paragraph', 'bulletList']) + expect(parent?.content?.[1].content).toHaveLength(2) + expect(ed.getMarkdown().trim()).toBe('- parent\n - new child\n - **child**') + expect(ed.state.selection.$from.depth).toBe(5) + }) + + it('does not absorb a following numbered list', () => { + const ed = mount('

  1. one

') + ed.commands.setTextSelection(1) + typeBullet(ed) + + expect( + ed + .getJSON() + .content?.slice(0, 2) + .map((node) => node.type) + ).toEqual(['bulletList', 'orderedList']) + expect(ed.getJSON().content?.[1].attrs?.start).toBe(3) + }) + + it('undoes the input rule and its join together without losing the following items', () => { + const ed = mount('

  • one

  • two

') + ed.commands.setTextSelection(1) + typeBullet(ed) + expect(ed.commands.undoInputRule()).toBe(true) + + expect( + ed + .getJSON() + .content?.slice(0, 2) + .map((node) => node.type) + ).toEqual(['paragraph', 'bulletList']) + expect(ed.state.doc.firstChild?.textContent).toBe('- ') + expect(ed.getJSON().content?.[1].content).toHaveLength(2) + }) + + it.each(['-', '+', '*'])( + 'Backspace restores the typed %s marker before a following list', + (marker) => { + editor = new Editor({ + extensions: createMarkdownEditorExtensions({ placeholder: '' }), + content: '

Todos:

  • one

  • two

', + }) + editor.commands.setTextSelection(7) + editor.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ) + typeBullet(editor, marker) + editor.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true }) + ) + + expect( + editor + .getJSON() + .content?.slice(0, 3) + .map((node) => node.type) + ).toEqual(['heading', 'paragraph', 'bulletList']) + expect(editor.state.selection.$from.parent.textContent).toBe(`${marker} `) + expect(editor.state.selection.$from.parentOffset).toBe(2) + expect(editor.state.doc.child(2).textContent).toBe('onetwo') + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/bullet-list.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/bullet-list.ts new file mode 100644 index 00000000000..9568b0bc9d4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/bullet-list.ts @@ -0,0 +1,13 @@ +import { BulletList } from '@tiptap/extension-list' +import { joinListInputRules } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules' + +/** + * Extends the stock input rules, which only join a preceding list, to also join an immediately + * following bullet list. Keeping the join in the input-rule transaction preserves undo and the + * caret, without merging across paragraphs or rewriting unrelated lists on every edit. + */ +export const JoiningBulletList = BulletList.extend({ + addInputRules() { + return joinListInputRules(this.parent?.() ?? [], this.type) + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index baa4d97b592..7d420e6da3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -35,6 +35,8 @@ function createSocket(connected = true) { }, } const fire = (event: string, ...args: unknown[]) => { + if (event === 'connect') socket.connected = true + if (event === 'disconnect') socket.connected = false for (const cb of listeners.get(event) ?? []) cb(...args) } return { socket: socket as unknown as Socket, emit, fire } @@ -48,6 +50,14 @@ function createProvider(connected = true) { return { provider, doc, awareness, emit, fire } } +function acceptJoin( + fire: (event: string, ...args: unknown[]) => void, + clientId: number, + docId?: string +) { + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1', clientId, docId }) +} + /** Messages emitted to the server, decoded to their `{ type, bytes }`. */ function emittedMessages(emit: ReturnType) { return emit.mock.calls @@ -77,10 +87,11 @@ describe('FileDocProvider', () => { }) it('exchanges sync only after JOIN_SUCCESS', () => { - const { emit, fire } = createProvider(true) + const { doc, emit, fire } = createProvider(true) emit.mockClear() + expect(emittedMessages(emit)).toHaveLength(0) - fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + acceptJoin(fire, doc.clientID) // A sync step 1 (type tag 0) is sent to exchange state with the server. const messages = emittedMessages(emit) @@ -88,16 +99,92 @@ describe('FileDocProvider', () => { expect(messages[0][0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) }) + it('ignores inbound sync until the current join is accepted', () => { + const { provider, doc, fire } = createProvider(true) + const serverDoc = new Y.Doc() + serverDoc.getText('default').insert(0, 'server content') + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(encoder, serverDoc) + const frame = encoding.toUint8Array(encoder) + + fire(FILE_DOC_EVENTS.MESSAGE, frame) + + expect(provider.synced).toBe(false) + expect(doc.getText('default').toString()).toBe('') + + acceptJoin(fire, doc.clientID) + fire(FILE_DOC_EVENTS.MESSAGE, frame) + + expect(provider.synced).toBe(true) + expect(doc.getText('default').toString()).toBe('server content') + }) + + it('does not send local updates before the current join is accepted', () => { + const { doc, emit } = createProvider(true) + emit.mockClear() + + doc.getText('default').insert(0, 'retained locally') + + expect(emittedMessages(emit)).toHaveLength(0) + expect(doc.getText('default').toString()).toBe('retained locally') + }) + + it('does not send local awareness before the current join is accepted', () => { + const { awareness, emit } = createProvider(true) + emit.mockClear() + + awareness.setLocalStateField('user', { name: 'Ada' }) + + expect(emittedMessages(emit)).toHaveLength(0) + }) + it('ignores a join ack for a different file', () => { - const { emit, fire } = createProvider(true) + const { doc, emit, fire } = createProvider(true) emit.mockClear() - fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'other-file' }) + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'other-file', clientId: doc.clientID }) // No sync/awareness exchange starts for a file this provider does not own. expect(emittedMessages(emit)).toHaveLength(0) }) + it('scopes join acknowledgements to the matching provider on a shared socket', () => { + const { socket, fire } = createSocket(true) + const firstDoc = new Y.Doc() + const secondDoc = new Y.Doc() + const first = new FileDocProvider( + socket, + 'file-1', + firstDoc, + new awarenessProtocol.Awareness(firstDoc) + ) + const second = new FileDocProvider( + socket, + 'file-1', + secondDoc, + new awarenessProtocol.Awareness(secondDoc) + ) + const serverDoc = new Y.Doc() + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(encoder, serverDoc) + const frame = encoding.toUint8Array(encoder) + + acceptJoin(fire, firstDoc.clientID) + fire(FILE_DOC_EVENTS.MESSAGE, frame) + + expect(first.synced).toBe(true) + expect(second.synced).toBe(false) + + acceptJoin(fire, secondDoc.clientID) + fire(FILE_DOC_EVENTS.MESSAGE, frame) + + expect(second.synced).toBe(true) + first.destroy() + second.destroy() + }) + /** * A tab that outlived its room can be offered a DIFFERENT document for the same file. Yjs would union * the two — the file twice, on both sides, and the relay persists it — and there is no un-merge. So @@ -111,7 +198,7 @@ describe('FileDocProvider', () => { provider.on('join-error', joinError) emit.mockClear() - fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1', docId: 'doc-rebuilt' }) + acceptJoin(fire, doc.clientID, 'doc-rebuilt') expect(emittedMessages(emit)).toHaveLength(0) expect(provider.synced).toBe(false) @@ -124,7 +211,7 @@ describe('FileDocProvider', () => { doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-original') emit.mockClear() - fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1', docId: 'doc-original' }) + acceptJoin(fire, doc.clientID, 'doc-original') expect(emittedMessages(emit).length).toBeGreaterThan(0) }) @@ -132,13 +219,13 @@ describe('FileDocProvider', () => { it('syncs when either side carries no identity (a fresh doc, or a room seeded before identities)', () => { const fresh = createProvider(true) fresh.emit.mockClear() - fresh.fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1', docId: 'doc-rebuilt' }) + acceptJoin(fresh.fire, fresh.doc.clientID, 'doc-rebuilt') expect(emittedMessages(fresh.emit).length).toBeGreaterThan(0) const unnamedRoom = createProvider(true) unnamedRoom.doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-original') unnamedRoom.emit.mockClear() - unnamedRoom.fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + acceptJoin(unnamedRoom.fire, unnamedRoom.doc.clientID) expect(emittedMessages(unnamedRoom.emit).length).toBeGreaterThan(0) }) @@ -146,6 +233,7 @@ describe('FileDocProvider', () => { const { provider, doc, fire } = createProvider(true) const synced = vi.fn() provider.on('synced', synced) + acceptJoin(fire, doc.clientID) const serverDoc = new Y.Doc() serverDoc.getText('default').insert(0, 'hello world') @@ -160,7 +248,8 @@ describe('FileDocProvider', () => { }) it('sends local document edits to the server as sync updates', () => { - const { doc, emit } = createProvider(true) + const { doc, emit, fire } = createProvider(true) + acceptJoin(fire, doc.clientID) emit.mockClear() doc.getText('default').insert(0, 'x') @@ -171,7 +260,8 @@ describe('FileDocProvider', () => { }) it('tags agent-streamed edits as SYNC_NO_PERSIST so the relay skips the durable persist', () => { - const { doc, emit } = createProvider(true) + const { doc, emit, fire } = createProvider(true) + acceptJoin(fire, doc.clientID) emit.mockClear() // An agent-streamed frame is applied under AGENT_STREAM_ORIGIN; it must still reach the server (peers @@ -184,8 +274,8 @@ describe('FileDocProvider', () => { }) it('does not echo updates it applied from the server', () => { - const { provider, emit, fire } = createProvider(true) - fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + const { provider, doc, emit, fire } = createProvider(true) + acceptJoin(fire, doc.clientID) emit.mockClear() const serverDoc = new Y.Doc() @@ -201,7 +291,8 @@ describe('FileDocProvider', () => { }) it('sends local awareness (cursor/selection) changes', () => { - const { awareness, emit } = createProvider(true) + const { awareness, doc, emit, fire } = createProvider(true) + acceptJoin(fire, doc.clientID) emit.mockClear() awareness.setLocalStateField('user', { name: 'Ada', color: '#f783ac' }) @@ -211,7 +302,7 @@ describe('FileDocProvider', () => { }) it('reseeds a cleared awareness so a reused instance can publish again', () => { - const { socket, emit } = createSocket(true) + const { socket, emit, fire } = createSocket(true) const doc = new Y.Doc() const awareness = new awarenessProtocol.Awareness(doc) // Simulate a prior provider teardown having cleared the local state — after @@ -224,6 +315,7 @@ describe('FileDocProvider', () => { new FileDocProvider(socket, 'file-1', doc, awareness) expect(awareness.getLocalState()).not.toBeNull() + acceptJoin(fire, doc.clientID) emit.mockClear() // The caret extension setting the user field must now actually publish. awareness.setLocalStateField('user', { name: 'Ada', color: '#f783ac' }) @@ -231,7 +323,8 @@ describe('FileDocProvider', () => { }) it('does not forward awareness it applied from the server', () => { - const { emit, fire } = createProvider(true) + const { doc, emit, fire } = createProvider(true) + acceptJoin(fire, doc.clientID) emit.mockClear() const remoteDoc = new Y.Doc() @@ -266,26 +359,87 @@ describe('FileDocProvider', () => { expect(provider.joinError).toEqual(error) }) - it('still rejoins on reconnect after a retryable error', () => { - const { emit, fire } = createProvider(true) - fire(FILE_DOC_EVENTS.JOIN_ERROR, { + it('scopes join errors to the matching provider on a shared socket', () => { + const { socket, fire } = createSocket(true) + const firstDoc = new Y.Doc() + const secondDoc = new Y.Doc() + const first = new FileDocProvider( + socket, + 'file-1', + firstDoc, + new awarenessProtocol.Awareness(firstDoc) + ) + const second = new FileDocProvider( + socket, + 'file-1', + secondDoc, + new awarenessProtocol.Awareness(secondDoc) + ) + const error = { fileId: 'file-1', - error: 'Realtime unavailable', - code: 'ROOM_MANAGER_UNAVAILABLE', - retryable: true, - }) - emit.mockClear() + clientId: firstDoc.clientID, + error: 'Access denied', + code: 'ACCESS_DENIED', + retryable: false, + } - fire('connect') + fire(FILE_DOC_EVENTS.JOIN_ERROR, error) - expect(emit).toHaveBeenCalledWith( - FILE_DOC_EVENTS.JOIN, - expect.objectContaining({ fileId: 'file-1' }) - ) + expect(first.joinError).toEqual(error) + expect(second.joinError).toBeNull() + first.destroy() + second.destroy() + }) + + it('retries a retryable join error without waiting for another socket reconnect', () => { + vi.useFakeTimers() + try { + const { emit, fire } = createProvider(true) + emit.mockClear() + + fire(FILE_DOC_EVENTS.JOIN_ERROR, { + fileId: 'file-1', + error: 'Realtime unavailable', + code: 'ROOM_MANAGER_UNAVAILABLE', + retryable: true, + }) + + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) + vi.advanceTimersByTime(1_000) + expect(emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN, + expect.objectContaining({ fileId: 'file-1' }) + ) + } finally { + vi.useRealTimers() + } + }) + + it('cancels a scheduled join retry after a successful join', () => { + vi.useFakeTimers() + try { + const { doc, emit, fire } = createProvider(true) + fire(FILE_DOC_EVENTS.JOIN_ERROR, { + fileId: 'file-1', + error: 'Realtime unavailable', + code: 'ROOM_MANAGER_UNAVAILABLE', + retryable: true, + }) + vi.advanceTimersByTime(1_000) + acceptJoin(fire, doc.clientID) + emit.mockClear() + + vi.advanceTimersByTime(10_000) + + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) + } finally { + vi.useRealTimers() + } }) it('resets synced and rejoins on a reconnect', () => { - const { provider, emit, fire } = createProvider(true) + const { provider, doc, emit, fire } = createProvider(true) + acceptJoin(fire, doc.clientID) // Become synced. const serverDoc = new Y.Doc() serverDoc.getText('default').insert(0, 'hi') @@ -306,6 +460,101 @@ describe('FileDocProvider', () => { ) }) + it('resets synced immediately when the socket disconnects', () => { + const { provider, doc, fire } = createProvider(true) + acceptJoin(fire, doc.clientID) + const synced = vi.fn() + provider.on('synced', synced) + const serverDoc = new Y.Doc() + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(encoder, serverDoc) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + expect(provider.synced).toBe(true) + + const remoteDoc = new Y.Doc() + remoteDoc.clientID = 8888 + const remoteAwareness = new awarenessProtocol.Awareness(remoteDoc) + remoteAwareness.setLocalStateField('user', { name: 'Remote' }) + const awarenessEncoder = encoding.createEncoder() + encoding.writeVarUint(awarenessEncoder, FILE_DOC_MESSAGE_TYPE.AWARENESS) + encoding.writeVarUint8Array( + awarenessEncoder, + awarenessProtocol.encodeAwarenessUpdate(remoteAwareness, [remoteDoc.clientID]) + ) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(awarenessEncoder)) + expect(provider.awareness.getStates().has(remoteDoc.clientID)).toBe(true) + synced.mockClear() + + fire('disconnect', 'transport close') + + expect(provider.synced).toBe(false) + expect(synced).toHaveBeenCalledWith(false) + expect(provider.awareness.getStates().has(remoteDoc.clientID)).toBe(false) + }) + + it('drops offline awareness emissions and republishes the latest state after rejoining', () => { + const { awareness, doc, emit, fire } = createProvider(true) + acceptJoin(fire, doc.clientID) + fire('disconnect', 'transport close') + emit.mockClear() + + awareness.setLocalStateField('user', { name: 'Ada' }) + awareness.setLocalStateField('selection', { anchor: 4, head: 4 }) + + expect(emittedMessages(emit)).toHaveLength(0) + + fire('connect') + expect(emittedMessages(emit)).toHaveLength(0) + acceptJoin(fire, doc.clientID) + + expect( + emittedMessages(emit).some((message) => message[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS) + ).toBe(true) + }) + + it('cancels a scheduled join retry on disconnect', () => { + vi.useFakeTimers() + try { + const { emit, fire } = createProvider(true) + fire(FILE_DOC_EVENTS.JOIN_ERROR, { + fileId: 'file-1', + error: 'Realtime unavailable', + code: 'ROOM_MANAGER_UNAVAILABLE', + retryable: true, + }) + fire('disconnect', 'transport close') + emit.mockClear() + + vi.advanceTimersByTime(10_000) + + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) + } finally { + vi.useRealTimers() + } + }) + + it('cancels a scheduled join retry when destroyed', () => { + vi.useFakeTimers() + try { + const { provider, emit, fire } = createProvider(true) + fire(FILE_DOC_EVENTS.JOIN_ERROR, { + fileId: 'file-1', + error: 'Realtime unavailable', + code: 'ROOM_MANAGER_UNAVAILABLE', + retryable: true, + }) + provider.destroy() + emit.mockClear() + + vi.advanceTimersByTime(10_000) + + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) + } finally { + vi.useRealTimers() + } + }) + it('leaves the room and detaches on destroy', () => { const { provider, doc, emit } = createProvider(true) emit.mockClear() @@ -323,7 +572,7 @@ describe('FileDocProvider', () => { // Two surfaces in one tab (Files editor + embedded chat panel) share one socket and both open the // same file. Tearing the first down must NOT strand the second — the server drops the socket from // the room on any LEAVE, so LEAVE may fire only when the last provider goes away. - const { socket, emit } = createSocket(true) + const { socket, emit, fire } = createSocket(true) const docA = new Y.Doc() const docB = new Y.Doc() const first = new FileDocProvider( @@ -338,9 +587,20 @@ describe('FileDocProvider', () => { docB, new awarenessProtocol.Awareness(docB) ) + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + fileId: 'shared-file', + clientId: docA.clientID, + }) + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + fileId: 'shared-file', + clientId: docB.clientID, + }) emit.mockClear() first.destroy() + expect( + emittedMessages(emit).some((message) => message[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS) + ).toBe(true) expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, expect.anything()) second.destroy() @@ -394,12 +654,12 @@ describe('FileDocProvider', () => { it('does not fire the fallback once the doc is synced AND seeded', () => { vi.useFakeTimers() try { - const { provider, fire } = createProvider(true) + const { provider, doc, fire } = createProvider(true) const onError = vi.fn() provider.on('join-error', onError) // The initial sync brings BOTH content and the server seed flag before the deadline. - fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + acceptJoin(fire, doc.clientID) const remote = new Y.Doc() remote.getText('default').insert(0, 'hi') remote.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) @@ -419,13 +679,13 @@ describe('FileDocProvider', () => { it('fires the fallback when the doc synced but the server seed never landed', () => { vi.useFakeTimers() try { - const { provider, fire } = createProvider(true) + const { provider, doc, fire } = createProvider(true) const onError = vi.fn() provider.on('join-error', onError) // The socket syncs an empty doc, but the server-side seed never arrives (its build persistently // failed) — `synced` is true yet `initialContentLoaded` is never set. - fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + acceptJoin(fire, doc.clientID) const remote = new Y.Doc() const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) @@ -450,7 +710,7 @@ describe('FileDocProvider', () => { vi.useFakeTimers() try { const { provider, doc, fire } = createProvider(true) - fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + acceptJoin(fire, doc.clientID) // Deadline lapses with no first sync → fatal fallback (editor falls back to a read-only seed). vi.advanceTimersByTime(12_000) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index 79884c1e81c..b3a04d50b96 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -12,6 +12,7 @@ import { toFileDocBytes, } from '@sim/realtime-protocol/file-doc' import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { backoffWithJitter } from '@sim/utils/retry' import * as decoding from 'lib0/decoding' import * as encoding from 'lib0/encoding' import { ObservableV2 } from 'lib0/observable' @@ -45,6 +46,8 @@ interface FileDocProviderEvents { * relay's seed-fetch timeout — see `FILE_DOC_TIMEOUTS` and its ordering test. */ const READINESS_DEADLINE_MS = FILE_DOC_TIMEOUTS.readinessDeadlineMs +const JOIN_RETRY_BASE_MS = 500 +const JOIN_RETRY_MAX_MS = 5_000 /** * Live-provider counts per file, per shared socket. Two surfaces in one tab (the Files editor and the @@ -112,6 +115,10 @@ export class FileDocProvider extends ObservableV2 { private fatal = false /** Deadline for reaching readiness (synced + seeded); fires the fallback if it is never reached. */ private readinessTimer: ReturnType | null = null + private joinAccepted = false + private joinPending = false + private joinRetryAttempt = 0 + private joinRetryTimer: ReturnType | null = null constructor( private readonly socket: Socket, @@ -138,6 +145,7 @@ export class FileDocProvider extends ObservableV2 { socket.on(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError) socket.on(ROOM_ACCESS_REVOKED_EVENT, this.handleAccessRevoked) socket.on('connect', this.handleConnect) + socket.on('disconnect', this.handleDisconnect) doc.on('update', this.handleDocUpdate) awareness.on('update', this.handleAwarenessUpdate) // Watch the seed flag so reaching "seeded" (server seed applied) can clear the readiness deadline. @@ -188,22 +196,68 @@ export class FileDocProvider extends ObservableV2 { } } + private clearJoinRetryTimer() { + if (this.joinRetryTimer !== null) { + clearTimeout(this.joinRetryTimer) + this.joinRetryTimer = null + } + } + /** Join the room, binding our client id so the server only accepts awareness we own. */ private join = () => { - if (this.fatal) return + if (this.fatal || this.disposed || !this.socket.connected || this.joinPending) return + this.joinPending = true this.socket.emit(FILE_DOC_EVENTS.JOIN, { fileId: this.fileId, clientId: this.doc.clientID }) } + private scheduleJoinRetry() { + this.clearJoinRetryTimer() + if (this.fatal || this.disposed || !this.socket.connected) return + this.joinRetryAttempt += 1 + this.joinRetryTimer = setTimeout( + () => { + this.joinRetryTimer = null + this.join() + }, + backoffWithJitter(this.joinRetryAttempt, null, { + baseMs: JOIN_RETRY_BASE_MS, + maxMs: JOIN_RETRY_MAX_MS, + }) + ) + } + /** * Re-join after a (re)connect. The server re-registers the room before acking, * so the sync/awareness exchange is deferred to {@link handleJoinSuccess}. */ private handleConnect = () => { if (this.fatal) return + this.clearJoinRetryTimer() + this.joinAccepted = false + this.joinPending = false + this.joinRetryAttempt = 0 this.setSynced(false) this.join() } + private handleDisconnect = () => { + this.clearJoinRetryTimer() + this.joinAccepted = false + this.joinPending = false + this.setSynced(false) + + const remoteClientIds = [...this.awareness.getStates().keys()].filter( + (clientId) => clientId !== this.doc.clientID + ) + if (remoteClientIds.length > 0) { + awarenessProtocol.removeAwarenessStates( + this.awareness, + remoteClientIds, + 'provider-disconnect' + ) + } + } + /** * Handle the join ack. The server registers the room before acking, so an earlier * send could be dropped — the initial sync + local awareness exchange begins here. @@ -217,7 +271,15 @@ export class FileDocProvider extends ObservableV2 { * A reload binds a fresh document and recovers. */ private handleJoinSuccess = (data: JoinFileDocSuccess) => { - if (data.fileId !== this.fileId) return + if ( + data.fileId !== this.fileId || + !this.joinPending || + (data.clientId !== undefined && data.clientId !== this.doc.clientID) + ) + return + this.joinPending = false + this.joinRetryAttempt = 0 + this.clearJoinRetryTimer() const local = this.docId() if (local !== undefined && data.docId !== undefined && data.docId !== local) { this.failFatally( @@ -226,6 +288,7 @@ export class FileDocProvider extends ObservableV2 { ) return } + this.joinAccepted = true this.sendSyncStep1() this.sendLocalAwareness() } @@ -252,6 +315,9 @@ export class FileDocProvider extends ObservableV2 { this.fatal = true this.joinError = error this.clearReadinessTimer() + this.clearJoinRetryTimer() + this.joinAccepted = false + this.joinPending = false this.setSynced(false) this.emit('join-error', [error]) } @@ -262,11 +328,23 @@ export class FileDocProvider extends ObservableV2 { * owner fall back to the non-collaborative view. */ private handleJoinError = (data: JoinFileDocError) => { - if (data.fileId !== this.fileId) return + if ( + data.fileId !== this.fileId || + !this.joinPending || + (data.clientId !== undefined && data.clientId !== this.doc.clientID) + ) + return + this.joinAccepted = false + this.joinPending = false if (data.retryable === false) { this.fatal = true this.joinError = data this.clearReadinessTimer() + this.clearJoinRetryTimer() + this.setSynced(false) + } else { + this.setSynced(false) + this.scheduleJoinRetry() } this.emit('join-error', [data]) } @@ -291,7 +369,7 @@ export class FileDocProvider extends ObservableV2 { // duplicating content — and flip `synced` true, which un-gates autosave and would persist the // duplicate back to the real file. `fatal` guarding (re)join alone is not enough; it must also // stop applying sync here. - if (this.fatal) return + if (this.fatal || !this.joinAccepted) return const bytes = toFileDocBytes(data) if (!bytes) return @@ -327,7 +405,7 @@ export class FileDocProvider extends ObservableV2 { // the stored content into the doc locally as its read-only fallback. Never relay those local // writes — the server never seeded this doc, so echoing them would push unseeded content to peers // (and each fallen-back client would do so, union-duplicating). A fatal client is fully local. - if (this.fatal) return + if (this.fatal || !this.joinAccepted || !this.socket.connected) return // Updates we applied from the server carry `this` as origin — don't echo them. if (origin === this) return // Agent-streamed frames must reach peers (so a collaborator sees the stream live) but must NOT be @@ -352,7 +430,9 @@ export class FileDocProvider extends ObservableV2 { // removals for remote peers — forwarding either would be a frame for a client // id we don't own, which the server (correctly) rejects. Filter to our own id // so honest traffic never trips the ownership guard. - if (origin === this) return + // Socket.IO buffers emits while disconnected and flushes them before the reconnect callback can + // rejoin this room. Suppress those stale frames; the accepted join republishes the latest state. + if (origin === this || this.fatal || !this.joinAccepted || !this.socket.connected) return const localId = this.doc.clientID const changed = [...added, ...updated, ...removed].filter((id) => id === localId) if (changed.length === 0) return @@ -404,8 +484,13 @@ export class FileDocProvider extends ObservableV2 { } this.disposed = true this.clearReadinessTimer() + this.clearJoinRetryTimer() + this.joinPending = false + // Publish our final awareness removal while this provider is still admitted. A co-mounted sibling + // keeps the socket in the room, so LEAVE cannot clear this provider's caret on its behalf. awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'provider-destroy') + this.joinAccepted = false // Only actually leave the room when this was the last provider for the file on the shared socket — // otherwise a sibling surface (e.g. the Files editor vs. the embedded chat panel) would be stranded. @@ -417,6 +502,7 @@ export class FileDocProvider extends ObservableV2 { this.socket.off(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError) this.socket.off(ROOM_ACCESS_REVOKED_EVENT, this.handleAccessRevoked) this.socket.off('connect', this.handleConnect) + this.socket.off('disconnect', this.handleDisconnect) this.doc.off('update', this.handleDocUpdate) this.doc.getMap(FILE_DOC_SEED.configMap).unobserve(this.handleConfigChange) this.awareness.off('update', this.handleAwarenessUpdate) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.test.ts index 667638a78ea..6164e03085c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.test.ts @@ -2,7 +2,10 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { type CollabReadinessInputs, nextCollabReadiness } from './readiness' +import { + type CollabReadinessInputs, + isCollabReady, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness' /** An observation, with the healthy defaults filled in so each case states only what it exercises. */ const at = (input: Partial): CollabReadinessInputs => ({ @@ -13,112 +16,28 @@ const at = (input: Partial): CollabReadinessInputs => ({ ...input, }) -/** Drive a sequence of observations through the latch, returning the readiness at each step. */ -function run(steps: Partial[]): boolean[] { - let syncedOnce = false - return steps.map((step) => { - const next = nextCollabReadiness(syncedOnce, at(step)) - syncedOnce = next.syncedOnce - return next.ready - }) -} - -describe('nextCollabReadiness', () => { +describe('isCollabReady', () => { it('is not ready before syncing or seeding', () => { - const { syncedOnce, ready } = nextCollabReadiness( - false, - at({ - synced: false, - seeded: false, - offlineSeed: false, - }) - ) - expect(syncedOnce).toBe(false) - expect(ready).toBe(false) + expect(isCollabReady(at({ synced: false, seeded: false, offlineSeed: false }))).toBe(false) }) it('is not ready when synced but not yet seeded', () => { - const { syncedOnce, ready } = nextCollabReadiness( - false, - at({ - synced: true, - seeded: false, - offlineSeed: false, - }) - ) - expect(syncedOnce).toBe(true) // latched - expect(ready).toBe(false) // waits for the seed + expect(isCollabReady(at({ synced: true, seeded: false, offlineSeed: false }))).toBe(false) }) - it('opens on the new-file flap sequence: synced true, then seed lands while synced flapped false', () => { - // The exact bug: `synced` and `seeded` are never true in the same observation. The latch must still - // open once BOTH have been seen across observations. - const readiness = run([ - { synced: false, seeded: false, offlineSeed: false }, // joining - { synced: true, seeded: false, offlineSeed: false }, // initial (empty) sync - { synced: false, seeded: false, offlineSeed: false }, // synced flaps false on re-sync - { synced: false, seeded: true, offlineSeed: false }, // server seed lands (synced still false) - ]) - expect(readiness).toEqual([false, false, false, true]) + it('is ready only when the current session is synced and the server seed is present', () => { + expect(isCollabReady(at({ synced: true, seeded: true }))).toBe(true) }) - it('opens even if the seed lands before we ever observed synced (server seed proves a sync)', () => { - // If the flap beat our first observation, the seed flag alone (not the offline fallback) proves a - // completed sync happened. - const { syncedOnce, ready } = nextCollabReadiness( - false, - at({ - synced: false, - seeded: true, - offlineSeed: false, - }) - ) - expect(syncedOnce).toBe(true) - expect(ready).toBe(true) + it('closes readiness as soon as the current session loses sync', () => { + expect(isCollabReady(at({ synced: false, seeded: true }))).toBe(false) }) it('stays read-only for an offline (local) seed that never reached the server', () => { - const readiness = run([ - { synced: false, seeded: false, offlineSeed: false }, - { synced: false, seeded: true, offlineSeed: true }, // offline fallback seeded locally - ]) - expect(readiness).toEqual([false, false]) - }) - - it('never reverts once ready, even if synced later flaps false', () => { - const readiness = run([ - { synced: true, seeded: true, offlineSeed: false }, // ready - { synced: false, seeded: true, offlineSeed: false }, // synced flaps — must stay ready - ]) - expect(readiness).toEqual([true, true]) - }) - /** - * The reported bug. A brand-new file syncs EMPTY (latching `syncedOnce`), its server seed never - * lands, and the readiness deadline fires: the provider goes fatal and drops `synced` precisely so - * this gate closes. The offline fallback then seeds locally — and the sticky latch used to re-open - * the gate on that, handing back an editable editor bound to a document the provider had abandoned. - * Every keystroke was dropped (the provider ignores frames and never rejoins) and client autosave - * stayed off (collaboration is nominally on), so the edits vanished on reload with no error shown. - */ - it('stays read-only after the readiness deadline goes fatal, even though a sync was latched', () => { - const readiness = run([ - { synced: false }, - { synced: true }, // initial EMPTY sync — latches syncedOnce - { synced: false, fatal: true }, // deadline: provider drops synced and gives up - { seeded: true, offlineSeed: true, fatal: true }, // fallback seeds locally - ]) - expect(readiness).toEqual([false, false, false, false]) + expect(isCollabReady(at({ synced: true, seeded: true, offlineSeed: true }))).toBe(false) }) - /** - * The same revocation on an ALREADY-ready doc: access is withdrawn mid-session, the provider goes - * fatal, and readiness must be taken back rather than left latched open. - */ it('revokes readiness when a live document turns fatal', () => { - const readiness = run([ - { synced: true, seeded: true }, // ready - { synced: false, seeded: true, fatal: true }, // access revoked mid-session - ]) - expect(readiness).toEqual([true, false]) + expect(isCollabReady(at({ synced: true, seeded: true, fatal: true }))).toBe(false) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.ts index 9974de6ed43..38d59aee26f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.ts @@ -1,22 +1,7 @@ -/** - * Collaborative-readiness latch for the file editor. - * - * A file becomes "ready" (editable + agent streaming enabled) once its shared doc has both SYNCED and - * SEEDED. The subtlety this latch solves: a brand-new file's provider reports `synced: true` on the - * initial (empty) sync, then the SERVER pushes the seed and receiving that update flips `synced` back to - * `false` — so `synced` and `seeded` are never `true` in the same observation. An un-latched - * `synced && seeded` gate would therefore never open, and agent streaming would be dropped for the whole - * run (the file only fills in on reload). - * - * The latch: `syncedOnce` is sticky — set the first time a completed sync is observed, and it never - * reverts. A completed sync is proven by EITHER a live `synced`, OR the seed flag being present without - * the offline fallback having set it (`offlineSeed`) — because a SERVER seed can only arrive after a - * sync, whereas the offline fallback seeds locally without ever reaching the server and must stay - * read-only. Once `syncedOnce` is set, a later `synced` flap can no longer re-gate the doc. - */ +/** Inputs that determine whether the collaborative editor can currently accept writes. */ export interface CollabReadinessInputs { - /** The provider's current `synced` flag (may flap false after the seed update). */ + /** The provider's current-session synchronization state. */ synced: boolean /** Whether the shared doc carries the seed flag. */ seeded: boolean @@ -30,24 +15,7 @@ export interface CollabReadinessInputs { fatal: boolean } -/** - * Pure transition for the readiness latch. `syncedOnce` is the sticky prior state — pass the returned - * `syncedOnce` back in on the next call. `ready` is whether the doc is synced-and-seeded. - * - * `fatal` overrides the latch, and that override is the whole reason it is an input. The latch is - * sticky on purpose, but stickiness must not outlive the document: a doc that syncs empty and never - * receives its server seed trips the readiness deadline, and the provider answers by dropping `synced` - * so this gate closes. The latch ignored that — `syncedOnce` was already set by the empty sync — so the - * offline fallback's seed flag re-opened the gate and handed back an EDITABLE editor on a document the - * provider had already abandoned. Nothing typed into it could persist: the provider drops every frame - * and never rejoins, and the client's own autosave stays gated off because collaboration is nominally - * on. The user types, sees no error, and loses the edits on reload. Revoking readiness on `fatal` is - * what makes the fallback what it is documented to be — a READ-ONLY view of the stored content. - */ -export function nextCollabReadiness( - syncedOnce: boolean, - input: CollabReadinessInputs -): { syncedOnce: boolean; ready: boolean } { - const next = syncedOnce || input.synced || (input.seeded && !input.offlineSeed) - return { syncedOnce: next, ready: next && input.seeded && !input.fatal } +/** A document is writable only while this connection has synced the server-seeded Yjs document. */ +export function isCollabReady(input: CollabReadinessInputs): boolean { + return input.synced && input.seeded && !input.offlineSeed && !input.fatal } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts index 5afd9470a21..7dc9eaaa530 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts @@ -141,9 +141,12 @@ export function useFileDocCollaboration({ } reportOthersRef.current([...byUser.values()]) } + const handleDisconnect = () => reportOthersRef.current([]) socket.on(FILE_DOC_EVENTS.PRESENCE, handlePresence) + socket.on('disconnect', handleDisconnect) return () => { socket.off(FILE_DOC_EVENTS.PRESENCE, handlePresence) + socket.off('disconnect', handleDisconnect) reportOthersRef.current([]) } }, [enabled, socket, fileId]) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 771a242ab2e..102e6cb9551 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -5,28 +5,32 @@ import Placeholder from '@tiptap/extension-placeholder' import type { Awareness } from 'y-protocols/awareness' import type * as Y from 'yjs' import { withAlpha } from '@/lib/workspaces/colors' -import { BlockMover } from './block-mover' -import { CodeBlockWithLanguage } from './code-block' -import { CodeBlockHighlight } from './code-highlight' +import { BlockMover } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover' +import { CodeBlockWithLanguage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block' +import { CodeBlockHighlight } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight' import { createCaretActivityExtension, DEFAULT_CARET_COLOR, renderCaret, -} from './collaboration/caret-presence' -import { LinkEmbed } from './embed/link-embed' -import { createMarkdownContentExtensions } from './extensions' -import { RichMarkdownFind } from './find' -import { ResizableImage } from './image' -import { RichMarkdownKeymap } from './keymap' -import { MarkdownPaste } from './markdown-paste' -import { Mention } from './mention/mention' -import { MentionChip } from './mention/mention-chip' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence' +import { LinkEmbed } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { RichMarkdownFind } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find' +import { ResizableImage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image' +import { ImageUploadPlaceholders } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload' +import { RichMarkdownKeymap } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap' +import { MarkdownPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste' +import { Mention } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention' +import { MentionChip } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip' import { createRichMarkdownPasteAdmission, type RichMarkdownPasteAdmissionOptions, -} from './paste-admission' -import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet' -import { SlashCommand } from './slash-command/slash-command' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission' +import { + FootnoteDefWithView, + RawHtmlBlockWithView, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet' +import { SlashCommand } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command' /** Live collaboration binding for the editor. When present, the editor's history * is Yjs-backed and remote carets/selection render via CollaborationCaret. */ @@ -100,6 +104,7 @@ export function createMarkdownEditorExtensions({ Mention, RichMarkdownKeymap, BlockMover, + ImageUploadPlaceholders, ...(pasteAdmission ? [createRichMarkdownPasteAdmission(pasteAdmission)] : []), MarkdownPaste, Placeholder.configure({ placeholder }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx new file mode 100644 index 00000000000..914a0643d63 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx @@ -0,0 +1,514 @@ +/** @vitest-environment jsdom */ +import { act, Suspense, startTransition } from 'react' +import { toast } from '@sim/emcn' +import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' +import { PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste' +import { type Editor, Extension } from '@tiptap/core' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Awareness } from 'y-protocols/awareness' +import * as Y from 'yjs' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { ImageUploadPlaceholders } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload' +import { + createRichMarkdownPasteAdmission, + type RichMarkdownPasteAdmissionOptions, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission' +import { LoadedRichMarkdownEditor } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor' + +const { collaborationRef, uploadFile } = vi.hoisted(() => ({ + collaborationRef: { current: null as unknown }, + uploadFile: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: null, isPending: false }) })) +vi.mock('@/hooks/queries/workspace-files', () => ({ + useUploadWorkspaceFile: () => ({ mutateAsync: uploadFile }), +})) +vi.mock('@/hooks/use-add-to-chat', () => ({ useAddToChat: () => vi.fn() })) +vi.mock('@/hooks/use-file-content-source', () => ({ + useFileContentSource: () => ({ resolveImageSrc: (src: string) => src }), +})) +vi.mock('@/app/workspace/[workspaceId]/components', () => ({ FindBar: () => null })) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content', + () => ({ useEditableFileContent: vi.fn() }) +) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge', + () => ({ useSelectionCopyBridge: vi.fn() }) +) +vi.mock('@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor', () => ({ + TextEditor: () => null, +})) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions', + () => ({ + createMarkdownEditorExtensions: (options: { + pasteAdmission?: RichMarkdownPasteAdmissionOptions + }) => [ + ...createMarkdownContentExtensions(), + ImageUploadPlaceholders, + ...(options.pasteAdmission ? [createRichMarkdownPasteAdmission(options.pasteAdmission)] : []), + Extension.create({ name: 'slashCommand', addStorage: () => ({ insertImage: null }) }), + ], + }) +) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration', + () => ({ useFileDocCollaboration: () => collaborationRef.current }) +) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find', + () => ({ useMarkdownFind: () => ({ isOpen: false }) }) +) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention', + () => ({ useEditorMentions: vi.fn() }) +) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu', + () => ({ EditorBubbleMenu: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu', + () => ({ TableBubbleMenu: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card', + () => ({ LinkHoverCard: () => null }) +) + +const FILE: WorkspaceFileRecord = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'notes.md', + type: 'text/markdown', + key: 'version-1', + path: '/notes.md', + size: 30, + uploadedBy: 'user-1', + uploadedAt: new Date('2026-09-03T20:00:00Z'), +} +let root: Root +let container: HTMLDivElement +const onChange = vi.fn() +const onEditSource = vi.fn() +const onClientAutosaveChange = vi.fn() +const onSaveShortcut = vi.fn() +const onSuspendedRender = vi.fn() +const pendingRender = new Promise(() => {}) + +interface SuspendAfterEditorProps { + active: boolean +} + +function SuspendAfterEditor({ active }: SuspendAfterEditorProps) { + if (active) { + onSuspendedRender() + throw pendingRender + } + return null +} + +class FakeFileDocProvider { + synced = false + joinError: JoinFileDocError | null = null + private readonly listeners = new Map void>>() + + on(event: string, listener: (value: unknown) => void) { + let eventListeners = this.listeners.get(event) + if (!eventListeners) { + eventListeners = new Set() + this.listeners.set(event, eventListeners) + } + eventListeners.add(listener) + } + + off(event: string, listener: (value: unknown) => void) { + this.listeners.get(event)?.delete(listener) + } + + setSynced(synced: boolean) { + this.synced = synced + for (const listener of this.listeners.get('synced') ?? []) listener(synced) + } + + fail(error: JoinFileDocError) { + this.joinError = error + this.synced = false + for (const listener of this.listeners.get('join-error') ?? []) listener(error) + } +} + +interface RenderOptions { + collaborative?: boolean + onChange?: typeof onChange + onSaveShortcut?: typeof onSaveShortcut + suspend?: boolean +} + +async function render( + content: string, + acceptedBaselineContent = content, + canEdit = true, + options: RenderOptions = {} +) { + await act(async () => { + const update = () => + root.render( + + + + + ) + if (options.suspend) startTransition(update) + else update() + }) +} + +function getEditor() { + const element = container.querySelector('.tiptap') + expect(element).not.toBeNull() + return element!.editor +} + +async function pasteImage(editor: Editor) { + const image = new File(['image'], 'image.png', { type: 'image/png' }) + const event = new Event('paste', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'clipboardData', { + value: { files: [image], items: [], types: ['Files'], getData: () => '' }, + }) + await act(async () => editor.view.dom.dispatchEvent(event)) + expect(event.defaultPrevented).toBe(true) + expect(uploadFile).toHaveBeenCalledExactlyOnceWith({ + workspaceId: FILE.workspaceId, + file: image, + folderId: null, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + collaborationRef.current = null + vi.spyOn(toast, 'warning').mockReturnValue('test-toast') + vi.spyOn(toast, 'info').mockReturnValue('uploading-toast') + vi.spyOn(toast, 'dismiss').mockImplementation(() => {}) + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.restoreAllMocks() +}) + +describe('loaded rich editor lifecycle', () => { + it('pauses editing while reconnecting and resumes after the document resyncs', async () => { + const provider = new FakeFileDocProvider() + const doc = new Y.Doc() + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + collaborationRef.current = { + doc, + awareness: new Awareness(doc), + provider, + user: { name: 'User', color: '#000000', clientId: doc.clientID }, + } + await render('body', 'body', true, { collaborative: true }) + + await act(async () => provider.setSynced(true)) + const editor = getEditor() + expect(editor.isEditable).toBe(true) + + await act(async () => editor.commands.insertContent('local change ')) + await act(async () => provider.setSynced(false)) + + expect(editor.isEditable).toBe(false) + expect(editor.view.dom.getAttribute('aria-readonly')).toBe('true') + expect(editor.getText()).toContain('local change') + expect(container.textContent).toContain('Reconnecting…') + expect(editor.view.dom.closest('.hidden')).toBeNull() + + await act(async () => provider.setSynced(true)) + + expect(editor.isEditable).toBe(true) + expect(editor.view.dom.getAttribute('aria-readonly')).toBe('false') + expect(container.textContent).not.toContain('Reconnecting…') + }) + + it('keeps the live document visible and read-only after a fatal collaboration error', async () => { + const provider = new FakeFileDocProvider() + const doc = new Y.Doc() + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + collaborationRef.current = { + doc, + awareness: new Awareness(doc), + provider, + user: { name: 'User', color: '#000000', clientId: doc.clientID }, + } + await render('stale opening snapshot', 'stale opening snapshot', true, { collaborative: true }) + + await act(async () => provider.setSynced(true)) + const editor = getEditor() + await act(async () => editor.commands.insertContent('live local change')) + + await act(async () => + provider.fail({ + fileId: 'file-1', + error: 'Access denied', + code: 'ACCESS_DENIED', + retryable: false, + }) + ) + + expect(editor.isEditable).toBe(false) + expect(editor.view.dom.getAttribute('aria-readonly')).toBe('true') + expect(editor.getText()).toContain('live local change') + expect(editor.view.dom.closest('.hidden')).toBeNull() + expect(container.textContent).not.toContain('stale opening snapshot') + expect(container.textContent).not.toContain('Reconnecting…') + }) + + it('shows stored content read-only when collaboration fails before the first sync', async () => { + const provider = new FakeFileDocProvider() + const doc = new Y.Doc() + collaborationRef.current = { + doc, + awareness: new Awareness(doc), + provider, + user: { name: 'User', color: '#000000', clientId: doc.clientID }, + } + await render('stored body', 'stored body', true, { collaborative: true }) + + await act(async () => + provider.fail({ + fileId: 'file-1', + error: 'Access denied', + code: 'ACCESS_DENIED', + retryable: false, + }) + ) + + const editor = getEditor() + expect(editor.isEditable).toBe(false) + expect(editor.view.dom.getAttribute('aria-readonly')).toBe('true') + expect(editor.getText()).toContain('stored body') + expect(editor.view.dom.closest('.hidden')).toBeNull() + expect(container.textContent).not.toContain('Reconnecting…') + }) + + it('explains a picker selection whose insertion anchor was invalidated', async () => { + await render('before TARGET after') + const editor = getEditor() + await act(async () => { + editor.commands.setTextSelection({ from: 8, to: 14 }) + editor.storage.slashCommand.insertImage?.(8) + editor.commands.insertContentAt({ from: 7, to: 15 }, 'changed') + }) + const input = container.querySelector('input[type="file"]')! + Object.defineProperty(input, 'files', { + value: [new File(['image'], 'image.png', { type: 'image/png' })], + }) + await act(async () => input.dispatchEvent(new Event('change', { bubbles: true }))) + expect(uploadFile).not.toHaveBeenCalled() + expect(toast.info).toHaveBeenLastCalledWith( + 'The insertion location changed. Choose a new location and select the image again.' + ) + expect(editor.getText()).toContain('changed') + expect(input.value).toBe('') + }) + + it.each(['cancel', 'invalidate'] as const)( + 'explains a completed upload without inserting after its anchor is %s', + async (action) => { + const pending = Promise.withResolvers<{ file: { url: string } }>() + uploadFile.mockReturnValueOnce(pending.promise) + await render('before TARGET after') + const editor = getEditor() + await act(async () => editor.commands.setTextSelection({ from: 8, to: 14 })) + await pasteImage(editor) + expect(editor.getText()).toBe('before TARGET after') + + await act(async () => { + if (action === 'cancel') { + const cancel = editor.view.dom.querySelector('button') + expect(cancel?.textContent).toBe('Cancel insertion') + cancel!.click() + } else { + editor.commands.insertContentAt(10, 'edited') + } + }) + const beforeCompletion = editor.getJSON() + await act(async () => pending.resolve({ file: { url: '/image.png' } })) + + expect(editor.getJSON()).toEqual(beforeCompletion) + expect(editor.view.dom.querySelector('img')).toBeNull() + expect(editor.view.dom.querySelector('button')).toBeNull() + expect(toast.dismiss).toHaveBeenCalledWith('uploading-toast') + expect(toast.info).toHaveBeenLastCalledWith( + 'The image was uploaded to the workspace but was not inserted.' + ) + } + ) + + it('inserts a completed upload without reporting cancellation when its anchor survives', async () => { + const pending = Promise.withResolvers<{ file: { url: string } }>() + uploadFile.mockReturnValueOnce(pending.promise) + await render('before TARGET after') + const editor = getEditor() + await act(async () => editor.commands.setTextSelection({ from: 8, to: 14 })) + await pasteImage(editor) + await act(async () => pending.resolve({ file: { url: '/image.png' } })) + + expect(editor.view.dom.querySelector('img')?.getAttribute('src')).toBe('/image.png') + expect(editor.getText()).not.toContain('TARGET') + expect(editor.view.dom.querySelector('button')).toBeNull() + expect(toast.dismiss).toHaveBeenCalledWith('uploading-toast') + expect(toast.info).toHaveBeenCalledExactlyOnceWith('Uploading "image.png"…', { duration: 0 }) + }) + + it('keeps callbacks and frontmatter tied to the committed editor during a suspended render', async () => { + const committed = '---\ntitle: committed\n---\n\nbody' + await render(committed) + const editor = getEditor() + const abandonedOnChange = vi.fn() + const abandonedSave = vi.fn() + const abandoned = '---\ntitle: abandoned\n---\n\nother body' + await render(abandoned, abandoned, true, { + onChange: abandonedOnChange, + onSaveShortcut: abandonedSave, + suspend: true, + }) + expect(onSuspendedRender).toHaveBeenCalled() + expect(getEditor()).toBe(editor) + expect(editor.getText()).toBe('body') + await act(async () => editor.commands.insertContent('edited ')) + expect(onChange).toHaveBeenLastCalledWith(expect.stringContaining('title: committed')) + expect(onChange.mock.lastCall?.[0]).not.toContain('title: abandoned') + editor.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true, cancelable: true }) + ) + expect(onSaveShortcut).toHaveBeenCalledOnce() + expect(abandonedOnChange).not.toHaveBeenCalled() + expect(abandonedSave).not.toHaveBeenCalled() + }) + + it('budgets paste using the latest accepted frontmatter without recreating the editor', async () => { + await render('---\ntitle: first\n---\n\nbody') + const editor = getEditor() + const baseline = `---\ntitle: ${'x'.repeat(1000)}\n---\n\nbody` + await render(baseline) + expect(getEditor()).toBe(editor) + const before = editor.getJSON() + await act(async () => + editor.view.dispatch( + editor.state.tr + .insertText('x'.repeat(PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS - 500), 1) + .setMeta('uiEvent', 'paste') + ) + ) + expect(editor.getJSON()).toEqual(before) + }) + + it('does not steal formatting or composing chords for Save', async () => { + await render('body') + for (const extra of [ + { shiftKey: true }, + { altKey: true }, + { isComposing: true }, + { keyCode: 229 }, + ]) { + getEditor().view.dom.dispatchEvent( + new KeyboardEvent('keydown', { + key: 's', + ctrlKey: true, + bubbles: true, + cancelable: true, + ...extra, + }) + ) + } + expect(onSaveShortcut).not.toHaveBeenCalled() + getEditor().view.dom.dispatchEvent( + new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true, cancelable: true }) + ) + expect(onSaveShortcut).toHaveBeenCalledOnce() + }) + + it('uses accepted external frontmatter on the next edit, not the original copy', async () => { + await render('---\ntitle: first\n---\n\nbody') + await render('---\ntitle: second\n---\n\nnew body') + await act(async () => getEditor().commands.insertContent('edited ')) + expect(onChange).toHaveBeenLastCalledWith(expect.stringContaining('title: second')) + expect(onChange.mock.lastCall?.[0]).not.toContain('title: first') + }) + + it('does not recompute safety from a local serialization echo or overwrite the caret', async () => { + const baseline = '---\ntitle: first\n---\n\nbody' + await render(baseline) + await act(async () => getEditor().commands.insertContent('edited ')) + const selection = getEditor().state.selection.from + await render(onChange.mock.lastCall![0], baseline) + expect(getEditor().state.selection.from).toBe(selection) + expect(getEditor().isEditable).toBe(true) + }) + + it('exposes named multiline textbox semantics and read-only state', async () => { + await render('body') + const editable = getEditor().view.dom + expect(editable.getAttribute('role')).toBe('textbox') + expect(editable.getAttribute('aria-label')).toBe('notes.md document body') + expect(editable.getAttribute('aria-multiline')).toBe('true') + expect(editable.getAttribute('aria-readonly')).toBe('false') + await render('body', 'body', false) + expect(editable.getAttribute('aria-readonly')).toBe('true') + expect(getEditor().isEditable).toBe(false) + }) + + it.each([ + '[unused]: https://example.com', + '1. [![foo][image]](/dest)\n\n[image]: /url', + '- [ ] [foo][link]\n\n[link]: /dest', + '| header |\n| --- |\n| |', + ])('offers source editing without mutating unsupported content: %s', async (content) => { + await render(content) + expect(getEditor().isEditable).toBe(false) + const button = Array.from(container.querySelectorAll('button')).find( + (node) => node.textContent === 'Edit source' + ) + expect(button).toBeDefined() + await act(async () => button!.click()) + expect(onEditSource).toHaveBeenCalledOnce() + expect(onChange).not.toHaveBeenCalled() + }) + + it('updates editing eligibility and accessibility when an accepted baseline becomes unsupported', async () => { + await render('body') + await render('[unused]: https://example.com') + expect(getEditor().isEditable).toBe(false) + expect(getEditor().view.dom.getAttribute('aria-readonly')).toBe('true') + expect(container.textContent).toContain('Edit source') + await render('restored body') + expect(getEditor().isEditable).toBe(true) + expect(getEditor().view.dom.getAttribute('aria-readonly')).toBe('false') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index 8c7d42342f9..1d7aac21702 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -1,28 +1,32 @@ -import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core' +import { Extension, type Extensions, type JSONContent, type Node } from '@tiptap/core' import { Code } from '@tiptap/extension-code' +import { Document } from '@tiptap/extension-document' +import { HardBreak } from '@tiptap/extension-hard-break' import { TaskItem, TaskList } from '@tiptap/extension-list' import { Paragraph } from '@tiptap/extension-paragraph' -import { - renderTableToMarkdown, - Table, - TableCell, - TableHeader, - TableRow, -} from '@tiptap/extension-table' +import { TableCell, TableHeader, TableRow } from '@tiptap/extension-table' import { Markdown } from '@tiptap/markdown' import StarterKit from '@tiptap/starter-kit' -import { MarkdownCodeBlock } from './code-block-schema' -import { Highlight } from './highlight' -import { MarkdownImage } from './image-schema' -import { MarkdownLinkInputRule } from './link-input-rule' -import { MarkdownMention } from './mention/mention-node' -import { SIM_LINK_SCHEME } from './mention/sim-link' +import { JoiningBulletList } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/bullet-list' +import { MarkdownCodeBlock } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block-schema' +import { Highlight } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/highlight' +import { MarkdownImage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema' +import { MarkdownLinkInputRule } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-input-rule' +import { joinListInputRules } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules' +import { MarkdownMention } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node' +import { SIM_LINK_SCHEME } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link' +import { createJoiningOrderedList } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list' import { FootnoteDef, FootnoteRef, RawHtmlBlock, RawInlineHtml, -} from './raw-markdown-snippet-schema' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-schema' +import { + createMarkdownTable, + excludeTableBlockInputRules, + selectionTouchesTable, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/table' /** * The `@`-mention link scheme, registered on the Link mark — without it the schema strips the @@ -38,30 +42,74 @@ const SIM_LINK_PROTOCOL = { scheme: SIM_LINK_SCHEME, optionalSlashes: true } as */ const InlineCode = Code.extend({ excludes: '' }) +/** GFM's inline HTML keeps consecutive/leading/trailing breaks in one paragraph on reload. */ +const MarkdownHardBreak = HardBreak.extend({ renderMarkdown: () => '
' }) + +const TABLE_BLOCK_PREFIX_NODES = new Set(['heading', 'blockquote', 'horizontalRule']) + +/** Input rules must respect the same table capabilities as toolbar and keyboard commands. */ +const TableAwareStarterKit = StarterKit.extend({ + addExtensions() { + return (this.parent?.() ?? []).map((extension) => + extension.type === 'node' && TABLE_BLOCK_PREFIX_NODES.has(extension.name) + ? extension.extend({ + addCommands() { + const parent = this.parent?.() + if (this.name !== 'horizontalRule') return parent ?? {} + return { + ...parent, + setHorizontalRule: () => (props) => + !selectionTouchesTable(props.state) && + (parent?.setHorizontalRule?.()(props) ?? false), + } + }, + addInputRules() { + return excludeTableBlockInputRules(this.parent?.() ?? []) + }, + }) + : extension + ) + }, +}) + +/** Standard HTML represents a structural empty paragraph where Markdown whitespace is ambiguous. */ +const EmptyParagraphMarkdown = Extension.create({ + name: 'emptyParagraphMarkdown', + markdownTokenName: 'html', + parseMarkdown: (token) => + token.block && /^\s*

[ \t]*<\/p>\s*$/.test(token.raw ?? '') ? { type: 'paragraph' } : [], +}) + /** - * Table that escapes interior `|` characters when serializing cells. The upstream serializer - * joins cells with `|` without escaping, so a cell containing a literal pipe silently splits - * into phantom columns on round-trip (data loss). Escaping must happen on the `table` node — - * `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly. Only - * `|` is escaped — `renderChildren` already escapes backslashes, so escaping them again would - * double-escape and break round-trip idempotency (CodeQL's "missing backslash escape" is a false - * positive here; covered by the table round-trip tests). - * - * The upstream serializer also wraps the table in its own leading/trailing blank lines; left in, - * the block joiner adds another, so an interior table churns its surrounding whitespace to - * `\n\n\n` on the first edit. Trimming the table's own output lets the joiner own the single - * blank-line separator — without touching blank lines inside fenced code (those live in the code - * node's text, not here). + * Blank lines between lists are loose-list spacing in CommonMark, not a paragraph. Serialize an + * explicit empty element for that structural boundary, while retaining ordinary document spacing + * and omitting the editor's final typing placeholder from the persisted content. */ -const PipeSafeTable = Table.extend({ - renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) => - renderTableToMarkdown(node, { - ...h, - renderChildren: (nodes, separator) => - h.renderChildren(nodes, separator).replace(/\|/g, '\\|'), - }) - .replace(/^\n+/, '') - .replace(/\n+$/, ''), +const MarkdownDocument = Document.extend({ + renderMarkdown: (node: JSONContent, h) => { + const content = node.content ?? [] + let lastContentIndex = content.length - 1 + while ( + lastContentIndex >= 0 && + content[lastContentIndex].type === 'paragraph' && + !content[lastContentIndex].content?.length + ) { + lastContentIndex-- + } + return content + .map((child, index, siblings) => { + if ( + child.type === 'paragraph' && + !child.content?.length && + index < lastContentIndex && + ['bulletList', 'orderedList', 'taskList'].includes(siblings[index - 1]?.type ?? '') + ) { + return '

' + } + return h.renderChild?.(child, index) ?? h.renderChildren([child]) + }) + .join('\n\n') + }, }) /** @@ -78,8 +126,9 @@ const PipeSafeTable = Table.extend({ * ProseMirror text never carries it and re-serialization is stable. */ function guardParagraphLeading(text: string): string { + if (!text.trim()) return text const stripped = text.replace(/^[ \t]+/, '') - if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(stripped)) { + if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$|=+[ \t]*$)/.test(stripped)) { return `\\${stripped}` } const ordered = /^(\d{1,9})([.)][ \t])/.exec(stripped) @@ -93,8 +142,23 @@ function guardParagraphLeading(text: string): string { * paragraph renders as just its inline children; this override wraps that with the leading guard. */ const BlockSafeParagraph = Paragraph.extend({ - renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) => - guardParagraphLeading(h.renderChildren(node.content ?? [])), + renderMarkdown: (node: JSONContent, h, context) => { + if (!node.content?.length && context.parentType === 'blockquote') return '

' + const rendered = h.renderChildren(node.content ?? []) + let codeDelimiter = 0 + return rendered + .split('\n') + .map((line) => { + const guarded = codeDelimiter === 0 ? guardParagraphLeading(line) : line + for (const match of line.matchAll(/\\.|`+/g)) { + if (match[0][0] !== '`') continue + if (codeDelimiter === 0) codeDelimiter = match[0].length + else if (codeDelimiter === match[0].length) codeDelimiter = 0 + } + return guarded + }) + .join('\n') + }, }) /** @@ -126,29 +190,55 @@ export function createMarkdownContentExtensions( nodeViews: ContentNodeViews = {}, options: { disableHistory?: boolean } = {} ): Extensions { - const codeBlock = (nodeViews.codeBlock ?? MarkdownCodeBlock).configure({ - HTMLAttributes: { class: 'code-editor-theme' }, - }) + const codeBlock = (nodeViews.codeBlock ?? MarkdownCodeBlock) + .extend({ + addInputRules() { + return excludeTableBlockInputRules(this.parent?.() ?? []) + }, + }) + .configure({ HTMLAttributes: { class: 'code-editor-theme' } }) return [ - StarterKit.configure({ + TableAwareStarterKit.configure({ link: { openOnClick: false, protocols: [SIM_LINK_PROTOCOL] }, underline: false, codeBlock: false, code: false, paragraph: false, - // Collaboration provides its own (Yjs-backed) undo/redo — disabling the - // built-in history avoids the two fighting over the shared document. + bulletList: false, + orderedList: false, + document: false, + hardBreak: false, + /** Collaboration owns undo/redo whenever the document is shared. */ ...(options.disableHistory ? { undoRedo: false as const } : {}), }), + MarkdownDocument, BlockSafeParagraph, + EmptyParagraphMarkdown, + JoiningBulletList.extend({ + addInputRules() { + return excludeTableBlockInputRules(this.parent?.() ?? []) + }, + }), + createJoiningOrderedList(), + MarkdownHardBreak, InlineCode, Highlight, codeBlock, (nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }), nodeViews.mention ?? MarkdownMention, TaskList, - TaskItem.configure({ nested: true }), - PipeSafeTable.configure({ resizable: true }), + TaskItem.extend({ + addInputRules() { + return excludeTableBlockInputRules( + joinListInputRules( + this.parent?.() ?? [], + this.editor.schema.nodes[this.options.taskListTypeName], + { joinBefore: true } + ) + ) + }, + }).configure({ nested: true }), + createMarkdownTable().configure({ resizable: false }), TableRow, TableHeader, TableCell, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.test.ts new file mode 100644 index 00000000000..e931b79706d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.test.ts @@ -0,0 +1,280 @@ +/** @vitest-environment jsdom */ +import { Editor } from '@tiptap/core' +import Collaboration from '@tiptap/extension-collaboration' +import { undoDepth } from '@tiptap/pm/history' +import { afterEach, describe, expect, it } from 'vitest' +import * as Y from 'yjs' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { + beginImageUploads, + findImageUpload, + findImageUploadRange, + finishImageUpload, + ImageUploadPlaceholders, + removeImageUpload, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload' + +let editor: Editor +afterEach(() => editor?.destroy()) + +function mount(content = '

abcd

') { + editor = new Editor({ + extensions: [...createMarkdownContentExtensions(), ImageUploadPlaceholders], + content, + }) + return editor +} + +describe('image upload anchors', () => { + it.each([false, true])( + 'safely cancels an anchor replaced by a peer update (unrelated paragraph=%s)', + (unrelated) => { + const localDoc = new Y.Doc() + const remoteDoc = new Y.Doc() + const extensions = (doc: Y.Doc) => [ + ...createMarkdownContentExtensions({}, { disableHistory: true }), + Collaboration.configure({ document: doc }), + ImageUploadPlaceholders, + ] + editor = new Editor({ extensions: extensions(localDoc) }) + editor.commands.setContent('

before TARGET after

other

') + Y.applyUpdate(remoteDoc, Y.encodeStateAsUpdate(localDoc)) + const peer = new Editor({ extensions: extensions(remoteDoc) }) + try { + const [id] = beginImageUploads(editor, { from: 8, to: 14 }, ['image.png']) + const position = unrelated ? peer.state.doc.content.size - 1 : 10 + peer.commands.insertContentAt(position, 'PEER ') + Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(remoteDoc)) + const updated = editor.getJSON() + expect(updated).toEqual(peer.getJSON()) + expect(editor.getText()).toContain('PEER ') + expect(findImageUploadRange(editor, id)).toBeNull() + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false) + expect(editor.getJSON()).toEqual(updated) + } finally { + peer.destroy() + editor.destroy() + localDoc.destroy() + remoteDoc.destroy() + } + } + ) + + it('replaces selected text only on success and never serializes the placeholder', () => { + const editor = mount('

before REPLACE after

') + const [id] = beginImageUploads(editor, { from: 8, to: 15 }, ['image.png']) + expect(editor.state.doc.textContent).toBe('before REPLACE after') + expect(undoDepth(editor.state)).toBe(0) + expect(editor.getMarkdown()).not.toContain('Uploading') + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(true) + expect(editor.getMarkdown()).toContain('![image](/image.png)') + expect(editor.state.doc.textContent).not.toContain('REPLACE') + expect(findImageUpload(editor, id)).toBeNull() + }) + + it('leaves a selected replacement intact when the upload fails or is cancelled', () => { + const editor = mount('

before REPLACE after

') + const [id] = beginImageUploads(editor, { from: 8, to: 15 }, ['image.png']) + removeImageUpload(editor, id) + expect(editor.state.doc.textContent).toBe('before REPLACE after') + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false) + expect(undoDepth(editor.state)).toBe(0) + }) + + it('cancels replacement when Undo removes the selected content', () => { + const editor = mount('

before after

') + editor.commands.insertContentAt(8, 'REPLACE ') + const [id] = beginImageUploads(editor, { from: 8, to: 15 }, ['image.png']) + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.textContent).toBe('before after') + expect(findImageUpload(editor, id)).toBeNull() + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false) + }) + + it('undoes successful replacement in one step without reviving the pending upload', () => { + const editor = mount('

before REPLACE after

') + const [id] = beginImageUploads(editor, { from: 8, to: 15 }, ['image.png']) + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(true) + expect(editor.commands.undo()).toBe(true) + expect(editor.state.doc.textContent).toBe('before REPLACE after') + expect(editor.getMarkdown()).not.toContain('/image.png') + expect(findImageUpload(editor, id)).toBeNull() + expect(editor.commands.redo()).toBe(true) + expect(editor.getMarkdown()).toContain('/image.png') + }) + + it.each(['text', 'marks', 'deletion'] as const)( + 'cancels the entire replacement batch when selected %s changes', + (change) => { + const editor = mount('

before REPLACE after

') + const ids = beginImageUploads(editor, { from: 8, to: 15 }, ['first', 'second']) + const transaction = editor.state.tr + if (change === 'text') transaction.insertText('new', 10) + else if (change === 'marks') transaction.addMark(9, 11, editor.schema.marks.bold.create()) + else transaction.delete(9, 11) + editor.view.dispatch(transaction) + const edited = editor.getJSON() + for (const id of ids) { + expect(findImageUpload(editor, id)).toBeNull() + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false) + } + expect(editor.getJSON()).toEqual(edited) + } + ) + + it('maps a replacement through edits before it and excludes typing at either boundary', () => { + const editor = mount('

before REPLACE after

') + const [id] = beginImageUploads(editor, { from: 8, to: 15 }, ['image.png']) + editor.view.dispatch(editor.state.tr.insertText('PREFIX ', 1)) + editor.view.dispatch(editor.state.tr.insertText('LEFT ', 15)) + editor.view.dispatch(editor.state.tr.insertText(' RIGHT', 27)) + expect(findImageUpload(editor, id)).toBe(20) + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(true) + expect(editor.state.doc.textContent).toBe('PREFIX before LEFT RIGHT after') + expect(editor.getMarkdown()).not.toContain('REPLACE') + }) + + it('hands the mapped picker range to a new upload without reducing it to a caret', () => { + const editor = mount('

before REPLACE after

') + const [picker] = beginImageUploads(editor, { from: 8, to: 15 }, ['']) + editor.view.dispatch(editor.state.tr.insertText('PREFIX ', 1)) + const range = findImageUploadRange(editor, picker) + expect(range).toEqual({ from: 15, to: 22 }) + removeImageUpload(editor, picker) + if (!range) throw new Error('Expected a surviving picker range') + const [upload] = beginImageUploads(editor, range, ['image.png']) + expect(finishImageUpload(editor, upload, '/image.png', 'image')).toBe(true) + expect(editor.state.doc.textContent).toBe('PREFIX before after') + expect(findImageUploadRange(editor, picker)).toBeNull() + }) + + it('maps the anchor through typing before it and keeps the current caret', () => { + const editor = mount() + const [id] = beginImageUploads(editor, { from: 3, to: 3 }, ['image.png']) + editor.view.dispatch(editor.state.tr.insertText('PREFIX ', 1)) + const position = findImageUpload(editor, id) + expect(position).toBe(10) + editor.commands.setTextSelection(2) + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(true) + expect(editor.state.selection.from).toBe(2) + expect(editor.state.doc.firstChild?.textContent).toBe('PREFIX ab') + expect(editor.state.doc.child(1).type.name).toBe('image') + expect(editor.state.doc.lastChild?.textContent).toBe('cd') + }) + + it('drops uploads whose surrounding content was deleted', () => { + const editor = mount() + const [id] = beginImageUploads(editor, { from: 3, to: 3 }, ['image.png']) + editor.view.dispatch(editor.state.tr.delete(1, 5)) + expect(findImageUpload(editor, id)).toBeNull() + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false) + expect(editor.getMarkdown()).not.toContain('/image.png') + }) + + it('maps every queued upload before any file finishes and preserves their order', () => { + const editor = mount() + const ids = beginImageUploads(editor, { from: 3, to: 3 }, ['first', 'second']) + editor.view.dispatch(editor.state.tr.insertText('prefix', 1)) + for (const [index, id] of ids.entries()) { + expect(finishImageUpload(editor, id, `/image-${index}.png`, String(index))).toBe(true) + } + const sources: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'image') sources.push(node.attrs.src) + }) + expect(sources).toEqual(['/image-0.png', '/image-1.png']) + }) + + it('replaces a batch selection only once, even when the first upload fails', () => { + const editor = mount('

before REPLACE after

') + const ids = beginImageUploads(editor, { from: 8, to: 15 }, ['failed', 'second', 'third']) + removeImageUpload(editor, ids[0]) + expect(editor.state.doc.textContent).toBe('before REPLACE after') + for (const [index, id] of ids.slice(1).entries()) { + expect(finishImageUpload(editor, id, `/image-${index}.png`, String(index))).toBe(true) + } + const sources: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'image') sources.push(node.attrs.src) + }) + expect(sources).toEqual(['/image-0.png', '/image-1.png']) + expect(editor.state.doc.textContent).toBe('before after') + }) + + it('keeps queued images in an empty paragraph after the first image replaces that paragraph', () => { + const editor = mount('

') + const ids = beginImageUploads(editor, editor.state.selection, ['first', 'second']) + for (const [index, id] of ids.entries()) { + expect(finishImageUpload(editor, id, `/image-${index}.png`, String(index))).toBe(true) + } + expect(editor.state.doc.childCount).toBe(3) + expect(editor.state.doc.firstChild?.attrs.src).toBe('/image-0.png') + expect(editor.state.doc.child(1).attrs.src).toBe('/image-1.png') + expect(editor.state.doc.lastChild?.type.name).toBe('paragraph') + }) + + it('does not add a document undo entry for a collapsed pending upload', () => { + const editor = mount() + const [id] = beginImageUploads(editor, { from: 3, to: 3 }, ['image.png']) + expect(undoDepth(editor.state)).toBe(0) + expect(editor.commands.undo()).toBe(false) + expect(findImageUpload(editor, id)).toBe(3) + editor.view.dom.querySelector('button')?.click() + expect(findImageUpload(editor, id)).toBeNull() + expect(editor.state.doc.textContent).toBe('abcd') + }) + + it('cancels failed uploads without modifying document content', () => { + const editor = mount() + const [id] = beginImageUploads(editor, { from: 3, to: 3 }, ['image.png']) + removeImageUpload(editor, id) + expect(findImageUpload(editor, id)).toBeNull() + expect(editor.state.doc.textContent).toBe('abcd') + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false) + }) + + it('cancels insertion without claiming to cancel the workspace upload', () => { + const editor = mount() + const [id] = beginImageUploads(editor, { from: 3, to: 3 }, ['image.png']) + const cancel = editor.view.dom.querySelector('button') + expect(cancel?.textContent).toBe('Cancel insertion') + expect(cancel?.getAttribute('aria-label')).toBe('Cancel insertion of image.png') + expect(cancel?.title).toBe('The file may still finish uploading to the workspace.') + expect(cancel?.classList.contains('focus-visible:outline-2')).toBe(true) + expect(cancel?.classList.contains('focus-visible:outline-[var(--selection)]')).toBe(true) + cancel?.click() + expect(findImageUpload(editor, id)).toBeNull() + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false) + expect(editor.state.doc.textContent).toBe('abcd') + expect(undoDepth(editor.state)).toBe(0) + }) + + it('leaves Enter on the cancel button to native activation instead of editing the document', () => { + const editor = mount('

before REPLACE after

') + document.body.append(editor.view.dom) + const before = editor.getJSON() + const [id] = beginImageUploads(editor, { from: 8, to: 15 }, ['image.png']) + const cancel = editor.view.dom.querySelector('button')! + cancel.focus() + const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + cancel.dispatchEvent(enter) + expect(enter.defaultPrevented).toBe(false) + expect(editor.getJSON()).toEqual(before) + cancel.click() + expect(document.activeElement).toBe(editor.view.dom) + expect(findImageUpload(editor, id)).toBeNull() + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false) + expect(editor.getJSON()).toEqual(before) + editor.view.dom.remove() + }) + + it('does not mutate a destroyed or newly read-only editor', () => { + const editor = mount() + const [id] = beginImageUploads(editor, { from: 3, to: 3 }, ['image.png']) + editor.setEditable(false) + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false) + editor.destroy() + expect(() => removeImageUpload(editor, id)).not.toThrow() + expect(finishImageUpload(editor, id, '/image.png', 'image')).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.ts new file mode 100644 index 00000000000..c85e9b37e1e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.ts @@ -0,0 +1,183 @@ +import { generateShortId } from '@sim/utils/id' +import { type Editor, Extension, type Range } from '@tiptap/core' +import type { Slice } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' + +interface PendingImageUpload extends Range { + id: string + batchId: string + label: string + replacement: Slice | null + side: -1 | 1 +} + +interface ImageUploadState { + uploads: ReadonlyMap + decorations: DecorationSet +} + +interface UploadPlaceholderAction { + add?: PendingImageUpload[] + remove?: string + complete?: string +} + +const uploadPlaceholderKey = new PluginKey('imageUploadPlaceholders') + +function uploadDecoration({ id, from, label, side }: PendingImageUpload): Decoration { + return Decoration.widget( + from, + (view) => { + const placeholder = document.createElement('span') + placeholder.contentEditable = 'false' + if (label) { + placeholder.className = + 'mx-1 rounded border border-[var(--border)] px-2 py-1 text-[var(--text-muted)] text-small' + placeholder.setAttribute('role', 'status') + placeholder.textContent = `Uploading ${label}…` + const cancel = document.createElement('button') + cancel.type = 'button' + cancel.className = + 'ml-2 underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--selection)]' + cancel.textContent = 'Cancel insertion' + cancel.setAttribute('aria-label', `Cancel insertion of ${label}`) + cancel.title = 'The file may still finish uploading to the workspace.' + cancel.addEventListener('mousedown', (event) => event.preventDefault()) + cancel.addEventListener('click', () => { + const restoreFocus = document.activeElement === cancel + view.dispatch(view.state.tr.setMeta(uploadPlaceholderKey, { remove: id })) + if (restoreFocus) view.focus() + }) + placeholder.append(cancel) + } + return placeholder + }, + { id, key: id, side, stopEvent: () => true } + ) +} + +/** Local-only upload anchors follow document transactions without persisting temporary URLs. */ +export const ImageUploadPlaceholders = Extension.create({ + name: 'imageUploadPlaceholders', + addProseMirrorPlugins() { + return [ + new Plugin({ + key: uploadPlaceholderKey, + state: { + init: () => ({ uploads: new Map(), decorations: DecorationSet.empty }), + apply(transaction, previous) { + const action = transaction.getMeta(uploadPlaceholderKey) as + | UploadPlaceholderAction + | undefined + const completed = action?.complete ? previous.uploads.get(action.complete) : undefined + const uploads = new Map() + for (const [id, upload] of previous.uploads) { + if (id === action?.remove || id === action?.complete) continue + if (completed?.batchId === upload.batchId) { + const position = transaction.mapping.map(upload.to, 1) + /** Queued siblings stay before trailing paragraphs appended after this image. */ + uploads.set(id, { + ...upload, + from: position, + to: position, + replacement: null, + side: -1, + }) + continue + } + const from = transaction.mapping.mapResult(upload.from, upload.side) + const to = upload.replacement ? transaction.mapping.mapResult(upload.to, -1) : from + if (from.deleted || to.deleted) continue + if ( + upload.replacement && + (from.pos >= to.pos || + !transaction.doc.slice(from.pos, to.pos).eq(upload.replacement)) + ) + continue + uploads.set(id, { ...upload, from: from.pos, to: to.pos }) + } + for (const upload of action?.add ?? []) uploads.set(upload.id, upload) + return { + uploads, + decorations: DecorationSet.create( + transaction.doc, + Array.from(uploads.values(), uploadDecoration) + ), + } + }, + }, + props: { decorations: (state) => uploadPlaceholderKey.getState(state)?.decorations }, + }), + ] + }, +}) + +/** + * Anchor queued uploads without changing selected content before success. + * Cancel replacement when a transaction deletes or changes the captured range. + */ +export function beginImageUploads(editor: Editor, range: Range, labels: string[]): string[] { + if (editor.isDestroyed || !editor.isEditable || labels.length === 0) return [] + const transaction = editor.state.tr + const batchId = generateShortId() + const replacement = range.from === range.to ? null : transaction.doc.slice(range.from, range.to) + const placeholders = labels.map((label) => ({ + id: generateShortId(), + batchId, + from: range.from, + to: range.to, + label, + replacement, + side: 1 as const, + })) + transaction.setMeta(uploadPlaceholderKey, { add: placeholders } satisfies UploadPlaceholderAction) + editor.view.dispatch(transaction) + return placeholders.map(({ id }) => id) +} + +export function findImageUpload(editor: Editor, id: string): number | null { + return findImageUploadRange(editor, id)?.from ?? null +} + +/** Reads the current replacement range without exposing the plugin's pending state. */ +export function findImageUploadRange(editor: Editor, id: string): Range | null { + if (editor.isDestroyed) return null + const upload = uploadPlaceholderKey.getState(editor.state)?.uploads.get(id) + return upload ? { from: upload.from, to: upload.to } : null +} + +export function removeImageUpload(editor: Editor, id: string): void { + if (editor.isDestroyed) return + editor.view.dispatch( + editor.state.tr.setMeta(uploadPlaceholderKey, { remove: id } satisfies UploadPlaceholderAction) + ) +} + +/** + * Commit one image at its surviving range. The first successful image replaces the captured content; + * its queued siblings become insertion anchors after it. Cancellation and failure never delete text. + */ +export function finishImageUpload(editor: Editor, id: string, src: string, alt: string): boolean { + if (editor.isDestroyed) return false + const upload = uploadPlaceholderKey.getState(editor.state)?.uploads.get(id) + if (!upload) return false + if (!editor.isEditable) { + removeImageUpload(editor, id) + return false + } + const inserted = editor + .chain() + .insertContentAt( + { from: upload.from, to: upload.to }, + { type: 'image', attrs: { src, alt } }, + { updateSelection: false } + ) + .command(({ tr }) => { + tr.setMeta(uploadPlaceholderKey, { complete: id } satisfies UploadPlaceholderAction) + return true + }) + .run() + if (!inserted) removeImageUpload(editor, id) + return inserted +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index 9c2ba8f3b70..359ebe92cfe 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -10,10 +10,13 @@ import { Editor } from '@tiptap/core' import { GapCursor } from '@tiptap/pm/gapcursor' import { AllSelection, NodeSelection } from '@tiptap/pm/state' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createMarkdownEditorExtensions } from './editor-extensions' -import { postProcessSerializedMarkdown } from './markdown-fidelity' -import { MENTION_PLUGIN_KEY } from './mention' -import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' +import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' +import { postProcessSerializedMarkdown } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +import { + MENTION_PLUGIN_KEY, + SLASH_COMMAND_PLUGIN_KEY, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/suggestion-plugin-keys' function editorWith(content: string): Editor { return new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }), content }) @@ -87,6 +90,7 @@ describe('suggestion-aware arrow keymap', () => { editor.commands.insertContent('@gma') expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(true) + expect(MENTION_PLUGIN_KEY.get(editor.state)?.spec.key).toBe(MENTION_PLUGIN_KEY) editor.destroy() }) @@ -96,6 +100,7 @@ describe('suggestion-aware arrow keymap', () => { editor.commands.insertContent('/') expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(true) + expect(SLASH_COMMAND_PLUGIN_KEY.get(editor.state)?.spec.key).toBe(SLASH_COMMAND_PLUGIN_KEY) editor.destroy() }) @@ -351,7 +356,9 @@ describe('list Backspace (clear / outdent)', () => { expect(editor.state.selection.empty).toBe(true) expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') expect(editor.state.selection.$from.parent.textContent).toBe('') - expect(editor.getMarkdown().trim()).toBe('- one') + const saved = postProcessSerializedMarkdown(editor.getMarkdown()) + editor.commands.setContent(parseMarkdownToDoc(saved)) + expect(blockShape(editor)).toEqual(['bulletList', 'paragraph']) editor.destroy() }) @@ -508,20 +515,46 @@ describe('empty list-item Enter', () => { Element.prototype.scrollIntoView = vi.fn() }) - it('removes an empty MIDDLE item instead of splitting the list into a stranded paragraph', () => { + it.each([ + ['bulletList', '- one\n- two\n- three'], + ['orderedList', '1. one\n2. two\n3. three'], + ['taskList', '- [ ] one\n- [ ] two\n- [x] three'], + ])('removes an empty middle %s item without splitting the list', (listType, markdown) => { const editor = editorWith('') - editor.commands.setContent('- one\n- two\n- three', { contentType: 'markdown' }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) editor.commands.focus() emptyItem(editor, 'two') pressKey(editor, 'Enter') - const { md, reparsed } = markdownRoundTrip(editor) - expect(md.trim()).toBe('- one\n- three') - expect(reparsed).toBe(md) + expect(editor.state.doc.child(0).type.name).toBe(listType) + expect(editor.state.doc.child(0).childCount).toBe(2) + expect(editor.state.selection.$from.parent.textContent).toBe('one') + + const saved = postProcessSerializedMarkdown(editor.getMarkdown()) + editor.commands.setContent(parseMarkdownToDoc(saved)) + expect(editor.state.doc.child(0).type.name).toBe(listType) + expect(editor.state.doc.child(0).childCount).toBe(2) + expect(postProcessSerializedMarkdown(editor.getMarkdown())).toBe(saved) editor.destroy() }) - it('leaves an empty TRAILING item to the default (exits the list)', () => { + it('Enter twice after a middle bullet preserves the existing whole-list behavior', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n- two', { contentType: 'markdown' }) + editor.commands.setTextSelection(6) + pressKey(editor, 'Enter') + expect(editor.isActive('listItem')).toBe(true) + expect(editor.state.selection.$from.parent.textContent).toBe('') + pressKey(editor, 'Enter') + + expect(editor.isActive('listItem')).toBe(true) + expect(editor.state.selection.$from.parent.textContent).toBe('one') + expect(editor.state.selection.$from.parentOffset).toBe(3) + expect(editor.getMarkdown().trim()).toBe('- one\n- two') + editor.destroy() + }) + + it('exits an empty trailing item into a paragraph', () => { const editor = editorWith('') editor.commands.setContent('- one\n- two', { contentType: 'markdown' }) editor.commands.focus() @@ -581,6 +614,91 @@ describe('empty list-item Enter', () => { }) }) +describe('list item descendant preservation', () => { + it.each(['Backspace', 'Enter'])( + '%s preserves nested descendants without lifting their parent', + (key) => { + const editor = editorWith( + '
  • one

    • child

      • grandchild

  • three

' + ) + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.content.size === 0) { + editor.commands.setTextSelection(pos + 1) + } + }) + const before = editor.getJSON() + pressKey(editor, key) + + expect(editor.state.selection.$from.parent.textContent).toBe('one') + expect(editor.state.doc.textContent).toBe('onechildgrandchildthree') + expect(editor.getHTML()).toContain('child') + expect(editor.state.doc.child(0).type.name).toBe('bulletList') + expect(editor.state.doc.child(0).childCount).toBe(3) + expect(editor.state.doc.child(0).child(1).textContent).toBe('childgrandchild') + expect(editor.commands.undo()).toBe(true) + expect(editor.getJSON()).toEqual(before) + editor.destroy() + } + ) + + it.each(['Backspace', 'Enter'])( + '%s removes a replaceable leading paragraph while retaining the whole list', + (key) => { + const editor = editorWith( + '
  • one

  • continuation

  • three

' + ) + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.content.size === 0) { + editor.commands.setTextSelection(pos + 1) + } + }) + pressKey(editor, key) + + expect(editor.state.selection.$from.parent.textContent).toBe('one') + expect(editor.state.selection.$from.parentOffset).toBe(3) + expect(editor.state.doc.child(0).child(1).childCount).toBe(1) + expect(editor.state.doc.textContent).toBe('onecontinuationthree') + editor.destroy() + } + ) +}) + +describe('code select-all boundaries', () => { + function selectAllKey(editor: Editor): void { + editor.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'a', + ctrlKey: true, + bubbles: true, + cancelable: true, + }) + ) + } + + it('does not shrink a selection that spans a code block and prose', () => { + const editor = editorWith('
alpha

beta

') + editor.commands.setTextSelection({ from: 2, to: 10 }) + selectAllKey(editor) + + expect(editor.state.selection instanceof AllSelection).toBe(true) + expect(editor.state.selection.from).toBe(0) + expect(editor.state.selection.to).toBe(editor.state.doc.content.size) + editor.destroy() + }) + + it('selects code first and the document on the second press', () => { + const editor = editorWith('
alpha

beta

') + editor.commands.setTextSelection(3) + selectAllKey(editor) + + expect(editor.state.selection.from).toBe(1) + expect(editor.state.selection.to).toBe(6) + selectAllKey(editor) + expect(editor.state.selection instanceof AllSelection).toBe(true) + editor.destroy() + }) +}) + describe('verbatim block boundary (isolating)', () => { beforeEach(() => { Element.prototype.scrollIntoView = vi.fn() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index 524e35eee21..ae7a42bcfd7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -4,8 +4,10 @@ import { GapCursor } from '@tiptap/pm/gapcursor' import type { ResolvedPos } from '@tiptap/pm/model' import { NodeSelection, Plugin, PluginKey, Selection } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' -import { MENTION_PLUGIN_KEY } from './mention' -import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' +import { + MENTION_PLUGIN_KEY, + SLASH_COMMAND_PLUGIN_KEY, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/suggestion-plugin-keys' /** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */ const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image']) @@ -58,7 +60,7 @@ function getListItemContext($from: ResolvedPos): ListItemContext | null { blockEmpty: $from.parent.content.size === 0, hasSiblingBlocks: item.childCount > 1, isTrailing: $from.index(listDepth) === list.childCount - 1, - isFirstBlock: $from.index(depth) === 0, + isFirstBlock: $from.depth === depth + 1 && $from.index(depth) === 0, } } return null @@ -177,42 +179,16 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b } /** - * Editor-specific keyboard behavior layered on top of StarterKit's defaults: + * Keyboard behavior layered over StarterKit. Backspace first undoes autoformatting, then clears + * headings, outdents list items, and handles empty wrappers without selecting unrelated content. + * Enter exits trailing empty items and outdents nested items. Empty middle items retain the existing + * removal behavior: lifting them splits the list and can discard delayed collaborative edits. + * Multi-block items remove only the empty block. Code select-all is scoped only while both + * endpoints remain inside the same code block, then expands to the document on a second press. * - * - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or - * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of a - * *list or task item* it outdents or clears in place via {@link getListItemContext}: a nested item outdents one - * level, a top-level item with text lifts out of the list into a paragraph (keeping the text), and a - * top-level *empty trailing* (or sole) item lifts into an empty paragraph in place — so the blank - * bullet made by pressing Enter can be cleared back to normal text on the same line instead of being - * deleted with the caret jumping to the previous block. The one case lift can't take is a top-level - * *empty, non-trailing* item: lifting it strands an empty paragraph between the two list halves, which - * re-parses to a different markdown document (an empty line between list items is a loose list, not a - * break); that item is removed via {@link removeEmptyWrappedBlock} instead, keeping the list whole. An - * empty block inside a *blockquote* is likewise removed via {@link removeEmptyWrappedBlock}. At the - * start of a block whose previous sibling is a divider or image, where ProseMirror's `joinBackward` - * can't cross the leaf and no-ops: an *empty* block is deleted (clearing the blank line between/below - * dividers without touching the divider itself), while a *non-empty* block selects the leaf — so a - * first Backspace highlights what a second deletes, the same highlight-before-delete affordance as - * clicking it and parity with the arrow-key leaf selection. - * - **Enter** on an empty *nested* list/task item outdents it one level, on an empty - * *non-trailing top-level* item removes it ({@link removeEmptyWrappedBlock}) rather than splitting the - * list around a stranded empty paragraph (which does not round-trip), and on an empty *trailing* item - * falls through to the default, which exits the list — the standard "press Enter on a blank bullet to - * leave the list". - * - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the - * block is already fully selected) falls through to the default whole-document select-all, the - * same scoped behavior as a code editor. - * - **ArrowUp/ArrowDown** select an adjacent divider or image, whether arrowing off a textblock edge - * ({@link selectAdjacentLeaf}) or stepping from one already-selected leaf to the next - * ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately - * in `./block-mover.ts`.) - * - * Plus a plugin that (a) highlights dividers/images falling inside a focused range selection (e.g. - * select-all), which the browser's native text highlight skips because leaves carry no text; hiding - * that custom decoration on blur keeps it in sync with the native text highlight, and (b) flags the - * editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide - * its otherwise-stray caret. + * Leaf arrow navigation and focused-range decorations provide keyboard selection for images and + * dividers. Suggestions retain their own arrow handling. A root gap between leaves is tagged so + * the editor's local styles can suppress a duplicate caret. */ export const RichMarkdownKeymap = Extension.create({ name: 'richMarkdownKeymap', @@ -221,13 +197,11 @@ export const RichMarkdownKeymap = Extension.create({ addKeyboardShortcuts() { return { Backspace: ({ editor }) => { + if (editor.commands.undoInputRule()) return true const { selection, doc } = editor.state if (!selection.empty || selection.$from.parentOffset !== 0) return false const { $from } = selection - // A gap cursor at the start of the doc resolves at the top level (`depth === 0`, offset 0): - // `$from.before(0)` below throws, and falling through instead is no better — TipTap's - // blockquote Backspace handler crashes on the same resolution (`$from.node(-1)` is - // undefined). There is nothing before the gap for Backspace to act on, so consume the key. + /** Root gaps have no parent boundary and no preceding content to remove. */ if ($from.depth === 0) return true if ($from.parent.type.name === 'heading') { return editor.commands.setParagraph() @@ -235,20 +209,6 @@ export const RichMarkdownKeymap = Extension.create({ const listCtx = getListItemContext($from) if (listCtx?.isFirstBlock) { const { itemType, isNested, blockEmpty, hasSiblingBlocks, isTrailing } = listCtx - // Backspace at the start of a bullet outdents or clears it in place rather than - // deleting the row and jumping the caret to the previous block. - // - Nested item → outdent one level (empty or not). - // - Top-level item whose first line has content (text OR an inline image/mention) → lift out of - // the list into a paragraph, keeping that content. - // - Top-level item whose empty first block has *sibling* blocks (a continuation paragraph, a - // block image, a nested list) → remove only that empty first block via {@link - // removeEmptyWrappedBlock}, leaving the rest of the item intact (never lift the whole item). - // - Top-level empty single-block item that is trailing (or the sole item) → lift into an empty - // paragraph in place, so a fresh bullet made with Enter can be cleared to normal text in place. - // A top-level *empty, non-trailing* single-block item is the one case lift can't take: it strands - // an empty paragraph between the two list halves, which re-parses to a different markdown document - // (an empty line between list items is a loose list, not a break). That case removes the row via - // {@link removeEmptyWrappedBlock} instead, which keeps the list whole and round-trips. if (isNested || !blockEmpty) return editor.commands.liftListItem(itemType) if (hasSiblingBlocks) return removeEmptyWrappedBlock(editor, $from) if (isTrailing) return editor.commands.liftListItem(itemType) @@ -280,20 +240,14 @@ export const RichMarkdownKeymap = Extension.create({ if ($from.parent.content.size !== 0) return false const listCtx = getListItemContext($from) if (!listCtx?.isFirstBlock) return false - // Enter on an empty item, mirroring the Backspace cases above: a nested item outdents one level; - // an empty first block that has *sibling* blocks (continuation paragraph, block image, nested - // list) removes only that empty block in place, keeping the rest of the item — never exiting the - // list or splitting it; a trailing single-block item falls through to the default (exits the - // list); and a non-trailing single-block item is removed rather than splitting the list around a - // stranded empty paragraph (which does not round-trip). if (listCtx.isNested) return editor.commands.liftListItem(listCtx.itemType) if (listCtx.hasSiblingBlocks) return removeEmptyWrappedBlock(editor, $from) if (listCtx.isTrailing) return false return removeEmptyWrappedBlock(editor, $from) }, 'Mod-a': ({ editor }) => { - const { $from } = editor.state.selection - if ($from.parent.type.name !== 'codeBlock') return false + const { $from, $to } = editor.state.selection + if ($from.parent.type.name !== 'codeBlock' || !$from.sameParent($to)) return false const from = $from.start($from.depth) const to = $from.end($from.depth) if (editor.state.selection.from === from && editor.state.selection.to === to) return false diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-collaboration.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-collaboration.test.ts new file mode 100644 index 00000000000..8c97ad4502a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-collaboration.test.ts @@ -0,0 +1,217 @@ +/** @vitest-environment jsdom */ +import { Editor } from '@tiptap/core' +import { yUndoPluginKey } from '@tiptap/y-tiptap' +import { afterEach, describe, expect, it } from 'vitest' +import { Awareness } from 'y-protocols/awareness' +import * as Y from 'yjs' +import { markdownToYDoc } from '@/lib/collab-doc/converter' +import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' + +interface Peer { + doc: Y.Doc + editor: Editor + updates: Uint8Array[] +} + +const cleanups: Array<() => void> = [] +afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup())) + +function pair(content = '## Todos\n\n- one\n- two\n- three', html = false): { a: Peer; b: Peer } { + document.elementFromPoint ??= () => null + const seed = markdownToYDoc(html ? '' : content) + const make = (): Peer => { + const doc = new Y.Doc() + Y.applyUpdate(doc, Y.encodeStateAsUpdate(seed)) + const awareness = new Awareness(doc) + const editor = new Editor({ + extensions: createMarkdownEditorExtensions({ + placeholder: '', + collaboration: { doc, awareness, user: { name: 'User', color: '#ffffff' } }, + }), + }) + const updates: Uint8Array[] = [] + doc.on('update', (update: Uint8Array, origin: unknown) => { + if (origin !== 'remote') updates.push(update) + }) + cleanups.push(() => { + editor.destroy() + awareness.destroy() + doc.destroy() + }) + return { doc, editor, updates } + } + const a = make() + if (html) { + a.editor.commands.setContent(content) + Y.applyUpdate(seed, Y.encodeStateAsUpdate(a.doc)) + a.updates.length = 0 + } + const b = make() + seed.destroy() + return { a, b } +} + +function findText(editor: Editor, text: string): number { + let position = -1 + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === text) position = pos + }) + expect(position).toBeGreaterThan(-1) + return position +} + +function key(editor: Editor, name: string): void { + editor.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }) + ) +} + +/** Delivers independently authored changes, including patches arriving before their prerequisites. */ +function reconnect(a: Peer, b: Peer, reversed: boolean): void { + const changesA = a.updates.slice() + const changesB = b.updates.slice() + if (reversed) { + changesA.reverse() + changesB.reverse() + } + for (const update of changesA) Y.applyUpdate(b.doc, update, 'remote') + for (const update of changesB) Y.applyUpdate(a.doc, update, 'remote') + expect(a.editor.getJSON()).toEqual(b.editor.getJSON()) +} + +describe('list editing with delayed peer updates', () => { + describe.each(['bullet', 'ordered', 'task'] as const)('%s list boundaries', (kind) => { + it.each( + [1, 2].flatMap((beforeCount) => + [false, true].flatMap((nested) => + [false, true].map((reversed) => ({ beforeCount, nested, reversed })) + ) + ) + )( + 'retains both existing roots ($beforeCount items, nested=$nested, reversed=$reversed)', + ({ beforeCount, nested, reversed }) => { + const words = [...['one', 'two'].slice(0, beforeCount), 'three', 'four'] + const list = (items: string[], start: number) => { + const tag = kind === 'ordered' ? 'ol' : 'ul' + const attrs = + kind === 'task' + ? ' data-type="taskList"' + : kind === 'ordered' + ? ` start="${start}"` + : '' + const itemAttrs = kind === 'task' ? ' data-type="taskItem" data-checked="false"' : '' + return `<${tag}${attrs}>${items.map((word) => `

${word}

`).join('')}` + } + const marker = kind === 'ordered' ? `${beforeCount + 1}.` : kind === 'task' ? '[ ]' : '-' + const content = `${list(words.slice(0, beforeCount), 1)}

${marker}

${list(words.slice(beforeCount), beforeCount + 2)}` + const { a, b } = pair(nested ? `
  • parent

    ${content}
` : content, true) + for (const word of words) { + b.editor.commands.insertContentAt(findText(b.editor, word), 'PEER ') + } + a.editor.commands.setTextSelection(findText(a.editor, marker) + marker.length) + const { from, to } = a.editor.state.selection + expect( + a.editor.view.someProp('handleTextInput', (handler) => + handler(a.editor.view, from, to, ' ', () => a.editor.state.tr) + ) + ).toBe(true) + a.editor.commands.insertContent('new item') + const container = nested ? a.editor.state.doc.firstChild!.firstChild! : a.editor.state.doc + const listType = + kind === 'task' ? 'taskList' : kind === 'ordered' ? 'orderedList' : 'bulletList' + const lists = Array.from({ length: container.childCount }, (_, index) => + container.child(index) + ).filter((node) => node.type.name === listType) + expect(lists.map((node) => node.childCount)).toEqual([beforeCount + 1, 2]) + expect(a.editor.state.selection.$from.parent.textContent).toBe('new item') + reconnect(a, b, reversed) + for (const word of words) expect(a.editor.state.doc.textContent).toContain(`PEER ${word}`) + expect(a.editor.state.doc.textContent).toContain('new item') + } + ) + }) + + it.each([false, true])( + 'joining above a list retains every peer insertion (reordered=%s)', + (reversed) => { + const { a, b } = pair() + for (const word of ['one', 'two', 'three']) { + b.editor.commands.insertContentAt(findText(b.editor, word), 'PEER ') + } + a.editor.commands.setTextSelection(findText(a.editor, 'Todos') + 5) + key(a.editor, 'Enter') + a.editor.commands.insertContent('-') + const { from, to } = a.editor.state.selection + expect( + a.editor.view.someProp('handleTextInput', (handler) => + handler(a.editor.view, from, to, ' ', () => a.editor.state.tr) + ) + ).toBe(true) + a.editor.commands.insertContent('new item') + reconnect(a, b, reversed) + + for (const word of ['one', 'two', 'three']) { + expect(a.editor.state.doc.textContent).toContain(`PEER ${word}`) + } + expect(a.editor.state.doc.textContent).toContain('new item') + } + ) + + it('retains peer text when an empty middle item is removed without reparenting its siblings', () => { + const { a, b } = pair() + b.editor.commands.insertContentAt(findText(b.editor, 'one'), 'PEER ') + a.editor.commands.setTextSelection(findText(a.editor, 'one') + 3) + key(a.editor, 'Enter') + const { $from } = a.editor.state.selection + a.editor.commands.deleteRange({ from: $from.before(-1), to: $from.after(-1) }) + reconnect(a, b, true) + + expect(a.editor.state.doc.textContent).toContain('PEER one') + }) + + it('undoes a local bullet join without undoing received peer text', () => { + const { a, b } = pair() + const history: { undoManager: Y.UndoManager } = yUndoPluginKey.getState(a.editor.state) + history.undoManager.clear() + a.editor.commands.setTextSelection(findText(a.editor, 'Todos') + 5) + key(a.editor, 'Enter') + a.editor.commands.insertContent('-') + const { from, to } = a.editor.state.selection + a.editor.view.someProp('handleTextInput', (handler) => + handler(a.editor.view, from, to, ' ', () => a.editor.state.tr) + ) + a.editor.commands.insertContent('new item') + b.editor.commands.insertContentAt(findText(b.editor, 'one'), 'PEER ') + reconnect(a, b, true) + history.undoManager.stopCapturing() + + expect(a.editor.commands.undo()).toBe(true) + reconnect(a, b, false) + expect(a.editor.state.doc.textContent).toContain('PEER one') + expect(a.editor.state.doc.textContent).not.toContain('new item') + }) + + /** + * Keep the middle list intact until lifting can preserve delayed edits: the binding copies one + * half of a split list into new CRDT identities. Convergence alone does not prove preservation. + * https://github.com/yjs/y-prosemirror/blob/master/CAVEATS.md#node-splitting-merging-and-lifting + */ + it.each([false, true])( + 'clearing a new middle item retains concurrent text in every sibling (reordered=%s)', + (reversed) => { + const { a, b } = pair() + for (const word of ['one', 'two', 'three']) { + b.editor.commands.insertContentAt(findText(b.editor, word), 'PEER ') + expect(b.editor.state.doc.textContent).toContain(`PEER ${word}`) + } + a.editor.commands.setTextSelection(findText(a.editor, 'one') + 3) + key(a.editor, 'Enter') + key(a.editor, 'Enter') + reconnect(a, b, reversed) + + for (const word of ['one', 'two', 'three']) { + expect(a.editor.state.doc.textContent).toContain(`PEER ${word}`) + } + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules.test.ts new file mode 100644 index 00000000000..89bd7c82c25 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules.test.ts @@ -0,0 +1,266 @@ +/** @vitest-environment jsdom */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' + +let editor: Editor | undefined + +afterEach(() => { + editor?.destroy() + editor = undefined +}) + +function mount(content: string): Editor { + editor = new Editor({ + extensions: createMarkdownEditorExtensions({ placeholder: '' }), + content, + }) + return editor +} + +function selectEmptyParagraph(ed: Editor): void { + let position: number | undefined + ed.state.doc.descendants((node, pos) => { + if (position === undefined && node.type.name === 'paragraph' && !node.content.size) { + position = pos + 1 + } + }) + expect(position).toBeDefined() + ed.commands.setTextSelection(position ?? 1) +} + +/** Runs the real input-rule handler for the final space, as browser typing does. */ +function typeMarker(ed: Editor, marker: string): boolean { + ed.commands.insertContent(marker) + const { from, to } = ed.state.selection + return ( + ed.view.someProp('handleTextInput', (handler) => + handler(ed.view, from, to, ' ', () => ed.state.tr) + ) ?? false + ) +} + +const TASKS = + '
  • done

  • todo

' + +describe('typed numbered list joining', () => { + it('parses different standard decimal delimiters as separate lists', () => { + const ed = mount('

') + ed.commands.setContent('1. first\n\n7) second', { contentType: 'markdown' }) + expect( + ed + .getJSON() + .content?.filter((node) => node.type === 'orderedList') + .map((node) => node.attrs?.start) + ).toEqual([1, 7]) + }) + + it('retains separate restart nodes when reloading the serialized markdown', () => { + const ed = mount('

  1. one

') + selectEmptyParagraph(ed) + expect(typeMarker(ed, '1.')).toBe(true) + ed.commands.insertContent('new') + const original = ed.getJSON().content?.slice(0, 2) + ed.commands.setContent(ed.getMarkdown(), { contentType: 'markdown' }) + expect(ed.getJSON().content?.slice(0, 2)).toEqual(original) + }) + + it.each([0, 1, 4, 9, 99])('joins a following continuation when typing %s.', (start) => { + const ed = mount( + `

  1. one

  2. two

` + ) + selectEmptyParagraph(ed) + expect(typeMarker(ed, `${start}.`)).toBe(true) + ed.commands.insertContent('new') + + expect(ed.getJSON().content?.filter((node) => node.type === 'orderedList')).toHaveLength(1) + expect(ed.state.doc.firstChild?.attrs.start).toBe(start) + expect(ed.state.doc.firstChild?.childCount).toBe(3) + expect(ed.state.doc.firstChild?.child(1).firstChild?.firstChild?.marks[0].type.name).toBe( + 'bold' + ) + expect(ed.state.selection.$from.parent.textContent).toBe('new') + expect(ed.getMarkdown().trim()).toBe(`${start}. new\n${start + 1}. **one**\n${start + 2}. two`) + }) + + it('joins a preceding continuation without merging two existing roots', () => { + const ed = mount( + '
  1. one

  1. three

' + ) + selectEmptyParagraph(ed) + expect(typeMarker(ed, '5.')).toBe(true) + ed.commands.insertContent('two') + + expect(ed.getJSON().content?.filter((node) => node.type === 'orderedList')).toHaveLength(2) + expect(ed.getMarkdown().trim()).toBe('4. one\n5. two\n\n6) three') + }) + + it.each([1, 3, 7])('preserves a following explicit restart at %s', (nextStart) => { + const ed = mount(`

  1. one

`) + selectEmptyParagraph(ed) + expect(typeMarker(ed, '1.')).toBe(true) + + expect(ed.getJSON().content?.filter((node) => node.type === 'orderedList')).toHaveLength(2) + expect(ed.state.doc.child(1).attrs.start).toBe(nextStart) + }) + + it('does not convert the styling of a following alphabetic list', () => { + const ed = mount('

  1. one

') + selectEmptyParagraph(ed) + expect(typeMarker(ed, '1.')).toBe(true) + + expect(ed.getJSON().content?.filter((node) => node.type === 'orderedList')).toHaveLength(2) + expect(ed.state.doc.child(1).attrs.type).toBe('a') + }) + + it('joins a nested continuation at its existing depth', () => { + const ed = mount( + '
  • parent

    1. child

' + ) + selectEmptyParagraph(ed) + expect(typeMarker(ed, '1.')).toBe(true) + ed.commands.insertContent('new') + + expect(ed.state.doc.firstChild?.firstChild?.childCount).toBe(2) + expect(ed.state.doc.firstChild?.firstChild?.child(1).childCount).toBe(2) + expect(ed.state.selection.$from.depth).toBe(5) + }) +}) + +describe('typed task list joining', () => { + it.each([ + ['[ ]', false], + ['[x]', true], + ] as const)( + 'joins %s to the following checklist without changing checked states', + (marker, checked) => { + const ed = mount(`

${TASKS}`) + selectEmptyParagraph(ed) + expect(typeMarker(ed, marker)).toBe(true) + ed.commands.insertContent('new') + + const lists = ed.getJSON().content?.filter((node) => node.type === 'taskList') + expect(lists).toHaveLength(1) + expect(lists?.[0].content?.map((node) => node.attrs?.checked)).toEqual([checked, true, false]) + expect(ed.state.doc.firstChild?.child(1).firstChild?.firstChild?.marks[0].type.name).toBe( + 'bold' + ) + expect(ed.state.selection.$from.parent.textContent).toBe('new') + } + ) + + it('joins the preceding checklist without merging two existing roots', () => { + const ed = mount(`${TASKS}

${TASKS}`) + selectEmptyParagraph(ed) + expect(typeMarker(ed, '[ ]')).toBe(true) + ed.commands.insertContent('new') + + const lists = ed.getJSON().content?.filter((node) => node.type === 'taskList') + expect(lists).toHaveLength(2) + expect(lists?.map((list) => list.content?.map((node) => node.attrs?.checked))).toEqual([ + [true, false, false], + [true, false], + ]) + expect(ed.state.selection.$from.parent.textContent).toBe('new') + }) + + it('joins nested checklists without lifting or absorbing their parent', () => { + const ed = mount(`
  • parent

    ${TASKS}
`) + selectEmptyParagraph(ed) + expect(typeMarker(ed, '[ ]')).toBe(true) + + expect(ed.state.doc.firstChild?.firstChild?.childCount).toBe(2) + expect(ed.state.doc.firstChild?.firstChild?.child(1).childCount).toBe(3) + expect(ed.state.selection.$from.depth).toBe(5) + }) +}) + +describe('list input-rule boundaries and undo', () => { + it.each([ + ['5.', '
  1. one

', '
  1. three

'], + ['[x]', TASKS, TASKS], + ])( + 'undoes the preceding join while retaining the following list for %s', + (marker, before, after) => { + const ed = mount(`${before}

${after}`) + const originalBefore = ed.state.doc.child(0).toJSON() + const originalAfter = ed.state.doc.child(2).toJSON() + selectEmptyParagraph(ed) + expect(typeMarker(ed, marker)).toBe(true) + expect(ed.commands.undoInputRule()).toBe(true) + + expect(ed.state.doc.child(0).toJSON()).toEqual(originalBefore) + expect(ed.state.doc.child(1).textContent).toBe(`${marker} `) + expect(ed.state.doc.child(2).toJSON()).toEqual(originalAfter) + expect(ed.state.selection.$from.parent.textContent).toBe(`${marker} `) + } + ) + + it.each([ + ['1.', '
  1. one

', 'orderedList'], + ['[ ]', TASKS, 'taskList'], + ])('does not join %s across an intentional empty paragraph', (marker, list, type) => { + const ed = mount(`

${list}`) + selectEmptyParagraph(ed) + expect(typeMarker(ed, marker)).toBe(true) + expect( + ed + .getJSON() + .content?.slice(0, 3) + .map((node) => node.type) + ).toEqual([type, 'paragraph', type]) + }) + + it.each([ + ['1.', TASKS, 'orderedList', 'taskList'], + ['[ ]', '
  1. one

', 'taskList', 'orderedList'], + ['[ ]', '
  • one

', 'taskList', 'bulletList'], + ])('does not merge a typed %s into another list type', (marker, list, type, nextType) => { + const ed = mount(`

${list}`) + selectEmptyParagraph(ed) + expect(typeMarker(ed, marker)).toBe(true) + expect( + ed + .getJSON() + .content?.slice(0, 2) + .map((node) => node.type) + ).toEqual([type, nextType]) + }) + + it.each([ + ['1.', '
  1. one

'], + ['[ ]', TASKS], + ['[x]', TASKS], + ])('Backspace restores %s and the untouched following list', (marker, list) => { + const ed = mount(`

${list}`) + const original = ed.state.doc.child(1).toJSON() + selectEmptyParagraph(ed) + expect(typeMarker(ed, marker)).toBe(true) + ed.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true }) + ) + + expect(ed.state.doc.firstChild?.type.name).toBe('paragraph') + expect(ed.state.doc.firstChild?.textContent).toBe(`${marker} `) + expect(ed.state.doc.child(1).toJSON()).toEqual(original) + expect(ed.state.selection.$from.parentOffset).toBe(marker.length + 1) + }) + + it.each(['1.', '[ ]', '[x]'])('leaves %s literal inside a table', (marker) => { + const ed = mount( + '

body

' + ) + selectEmptyParagraph(ed) + expect(typeMarker(ed, marker)).toBe(false) + expect(ed.state.selection.$from.parent.textContent).toBe(marker) + expect(ed.state.selection.$from.depth).toBe(4) + }) + + it.each(['1.', '[ ]', '[x]'])('leaves %s literal inside a code block', (marker) => { + const ed = mount('
') + ed.commands.setTextSelection(1) + expect(typeMarker(ed, marker)).toBe(false) + expect(ed.state.doc.firstChild?.type.name).toBe('codeBlock') + expect(ed.state.doc.firstChild?.textContent).toBe(marker) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules.ts new file mode 100644 index 00000000000..44e0b9517cb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules.ts @@ -0,0 +1,59 @@ +import { findParentNode, InputRule } from '@tiptap/core' +import type { NodeType, Node as ProseMirrorNode } from '@tiptap/pm/model' +import { canJoin } from '@tiptap/pm/transform' + +interface ListJoinOptions { + joinBefore?: boolean + compatible?: (list: ProseMirrorNode, next: ProseMirrorNode) => boolean +} + +/** Mirrors the stock backward join without renumbering a restart or changing list styling. */ +export function orderedListContinues(list: ProseMirrorNode, next: ProseMirrorNode): boolean { + return ( + (!list.attrs.type || list.attrs.type === '1') && + next.attrs.start === list.attrs.start + list.childCount && + list.hasMarkup(next.type, { ...next.attrs, start: list.attrs.start }, next.marks) + ) +} + +/** + * Keeps adjacent-list joins in the original input-rule transaction so undo restores the marker + * and the neighboring lists together. Only the newly wrapped list's immediate siblings qualify; + * paragraphs, incompatible list types, and explicit numbering restarts remain boundaries. + */ +export function joinListInputRules( + rules: InputRule[], + listType: NodeType, + { joinBefore = false, compatible = (list, next) => next.sameMarkup(list) }: ListJoinOptions = {} +): InputRule[] { + return rules.map( + (rule) => + new InputRule({ + find: rule.find, + undoable: rule.undoable, + handler: (props) => { + if (rule.handler(props) === null) return null + + const { tr } = props.state + let list = findParentNode((node) => node.type === listType)(tr.selection) + if (!list) return + + if (joinBefore) { + const previous = tr.doc.resolve(list.pos).nodeBefore + if (previous && compatible(previous, list.node) && canJoin(tr.doc, list.pos)) { + tr.join(list.pos) + list = findParentNode((node) => node.type === listType)(tr.selection) + if (!list) return + } + } + + /** A backward join now includes an existing root; merging another can lose delayed Yjs edits. */ + if (list.node.childCount !== 1) return + + const after = list.pos + list.node.nodeSize + const next = tr.doc.nodeAt(after) + if (next && compatible(list.node, next) && canJoin(tr.doc, after)) tr.join(after) + }, + }) + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts index 92e2e1fafd9..e44402762e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts @@ -3,9 +3,13 @@ */ import { Editor } from '@tiptap/core' import { afterAll, describe, expect, it } from 'vitest' -import { createMarkdownContentExtensions } from './extensions' -import { parseMarkdownToDoc, serializeMarkdownBody, splitMarkdownBlocks } from './markdown-parse' -import { isRoundTripSafe } from './round-trip-safety' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { + parseMarkdownToDoc, + serializeMarkdownBody, + splitMarkdownBlocks, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +import { isRoundTripSafe } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety' /** Mirror of the production `isEmptyParagraph` (not exported): the shape a blank line reconstructs to. */ const isEmptyPara = (n: { type?: string; content?: unknown[] }): boolean => @@ -167,17 +171,15 @@ describe('parseMarkdownToDoc (chunked)', () => { expect(serializeMarkdownBody(once)).toBe(once) }) - // The whole-document path hands blank runs to @tiptap/markdown, which keeps them after a paragraph - // but swallows them after a heading/ordered list/table. Preserving only some would break the fixpoint - // for the same document, so that path keeps none — consistently zero, which IS a fixpoint. + /** Whole-document parsing now preserves authored spacing as well as structural paragraphs. */ it.each([ - ['block HTML', '# H\n\n\n\ntext\n\n
x
', 'heading,paragraph,rawHtmlBlock'], + ['block HTML', '# H\n\n\n\ntext\n\n
x
', 'heading,∅,paragraph,rawHtmlBlock'], [ 'a reference definition', '# H\n\n\n\nsee [y][r]\n\n[r]: https://e.com', - 'heading,paragraph', + 'heading,∅,paragraph', ], - ])('a document that must parse whole keeps no empty paragraphs: %s', (_label, md, expected) => { + ])('preserves empty paragraphs when parsing whole: %s', (_label, md, expected) => { expect(shapeOf(md)).toBe(expected) const once = serializeMarkdownBody(md) expect(serializeMarkdownBody(once)).toBe(once) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index fd8cb706a34..35dd46f8320 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -1,10 +1,10 @@ import { Editor, type JSONContent } from '@tiptap/core' -import { createMarkdownContentExtensions } from './extensions' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' import { applyFrontmatter, postProcessSerializedMarkdown, splitFrontmatter, -} from './markdown-fidelity' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' /** * A single reused editor for chunked markdown parse/serialize, created lazily so importing this @@ -30,17 +30,16 @@ function markdownManager() { /** * Constructs whose meaning spans blank-line boundaries, so the document can't be split into blocks - * without changing how they parse — these documents parse whole (correct, if slower; they're - * uncommon and almost always round-trip-unsafe and read-only anyway): + * without changing how they parse — these documents parse whole: * - A link/image *reference definition* (`[id]: url`) or footnote definition can sit far from its * `[text][id]` / `[^id]` use; splitting them apart would drop the reference. The editor never * *emits* reference-style links, so this only matters on the first open of such a file. * - A block-level HTML element (`
`, `…`) or HTML comment can wrap blank lines; the * splitter would shatter it (matched here by a line that opens an HTML tag/comment, not inline - * `` autolinks). + * `` autolinks). The structural empty `

` element is atomic and remains chunkable. */ const NON_CHUNKABLE = - /^[ ]{0,3}(?:\[(?:\^[^\]]+|[^\]^][^\]]*)\]:\s|<(?:!--|\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]))/m + /^[ ]{0,3}(?:\[(?:\^[^\]]+|[^\]^][^\]]*)\]:\s|<(?!p>[ \t]*<\/p>[ \t]*$)(?:!--|\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]))/m const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/ const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/ @@ -200,13 +199,10 @@ export function splitMarkdownBlocks(body: string): string[] { * the editor renders the spacing that is actually in the file — on the very first paint, with no reflow * once a collaborative doc settles. * - * The whole-document path CANNOT do that. It hands blank runs to `@tiptap/markdown`, whose handling is - * not self-consistent (see {@link emptyBlockCount}), so a blank line there survives after a paragraph but - * is swallowed after a heading, an ordered list, or a table. Preserving it on only some of those would - * make parse stop inverting serialize for the same document — the file would never reach a fixpoint and - * would open read-only. So that path keeps NO empty paragraphs: consistently zero is a fixpoint, and a - * document whose spacing cannot be represented is better rendered the way every other markdown renderer - * shows it than rendered one way and saved another. + * The whole-document path retains the current Markdown parser's empty paragraphs too. Structural + * list boundaries and nested empty paragraphs serialize as standard `

` elements, so they do + * not depend on whether the lexer interprets a blank run as spacing within a list. Both paths apply + * the same document budget and discard trailing typing placeholders. */ export function parseMarkdownToDoc(body: string): JSONContent { const manager = markdownManager() @@ -214,7 +210,9 @@ export function parseMarkdownToDoc(body: string): JSONContent { // the chunker and parser do — a classic `\r`-only body would otherwise slip past the reference-def / // block-HTML guard and be chunked, shattering a construct that must parse whole. const normalized = body.replace(/\r\n?/g, '\n') - if (NON_CHUNKABLE.test(normalized)) return boundEmptyParagraphs(manager.parse(normalized), 0) + if (NON_CHUNKABLE.test(normalized)) { + return boundEmptyParagraphs(manager.parse(normalized), MAX_EMPTY_PARAGRAPHS_PER_DOC) + } try { const content: JSONContent[] = [] for (const block of splitMarkdownBlocks(normalized)) { @@ -229,7 +227,7 @@ export function parseMarkdownToDoc(body: string): JSONContent { } return boundEmptyParagraphs({ type: 'doc', content }, MAX_EMPTY_PARAGRAPHS_PER_DOC) } catch { - return boundEmptyParagraphs(manager.parse(normalized), 0) + return boundEmptyParagraphs(manager.parse(normalized), MAX_EMPTY_PARAGRAPHS_PER_DOC) } } @@ -240,7 +238,7 @@ function isEmptyParagraph(node: JSONContent): boolean { /** * Bound the top-level empty paragraphs of a parsed doc to `budget` in total, and drop trailing ones - * entirely. `budget` is 0 for the whole-document path, which cannot represent them at all. + * entirely. The same budget applies to chunked and whole-document parsing. * * The per-gap ceiling in {@link emptyBlockCount} bounds one run; this bounds the DOCUMENT. Without it the * ceiling buys nothing against the shape a real artifact takes — an export that puts a moderate blank run diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts index ade11c88e36..97c81daf798 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts @@ -5,9 +5,12 @@ * `[text](url)` text — except inside a code block, where it must stay literal. */ import { Editor } from '@tiptap/core' -import { afterEach, describe, expect, it } from 'vitest' -import { createMarkdownContentExtensions } from './extensions' -import { MarkdownPaste } from './markdown-paste' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { + isPlainTextPaste, + MarkdownPaste, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste' let editor: Editor | null = null @@ -17,7 +20,30 @@ afterEach(() => { }) function mount(editable = true): Editor { - return new Editor({ extensions: [...createMarkdownContentExtensions(), MarkdownPaste], editable }) + return new Editor({ + extensions: [...createMarkdownContentExtensions(), MarkdownPaste], + enablePasteRules: false, + editable, + }) +} + +function dispatchPaste( + ed: Editor, + text: string, + html = '', + extra: Record = {}, + files: File[] = [] +) { + const event = new Event('paste', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'clipboardData', { + value: { + getData: (type: string) => + type === 'text/plain' ? text : type === 'text/html' ? html : (extra[type] ?? ''), + files, + items: [], + }, + }) + ed.view.dom.dispatchEvent(event) } /** Run the plugin paste handlers the way ProseMirror would, with a mocked clipboard. */ @@ -47,6 +73,83 @@ function transformHtml(ed: Editor, html: string): string { } describe('markdown paste', () => { + it.each(['ctrlKey', 'metaKey'] as const)( + 'shares %s plain-paste intent with higher-precedence image handlers', + (modifier) => { + const upload = vi.fn() + editor = mount() + const ed = editor + ed.setOptions({ + editorProps: { + handlePaste: (_view, event) => { + if (isPlainTextPaste(ed)) return false + if (event.clipboardData?.files.length) { + upload() + return true + } + return false + }, + }, + }) + ed.commands.setContent('

keep selected text

') + ed.commands.setTextSelection({ from: 1, to: 19 }) + const image = new File(['image'], 'image.png', { type: 'image/png' }) + const plainPasteShortcut = () => { + ed.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'V', + [modifier]: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ) + } + + plainPasteShortcut() + expect(isPlainTextPaste(ed)).toBe(true) + dispatchPaste(ed, '', '', {}, [image]) + expect(upload).not.toHaveBeenCalled() + expect(ed.state.doc.textContent).toBe('keep selected text') + expect(ed.state.doc.childCount).toBe(1) + expect(ed.state.doc.firstChild?.type.name).toBe('paragraph') + expect(isPlainTextPaste(ed)).toBe(false) + + plainPasteShortcut() + dispatchPaste(ed, 'caption', '', {}, [image]) + expect(upload).not.toHaveBeenCalled() + expect(ed.state.doc.textContent).toBe('caption') + dispatchPaste(ed, '', '', {}, [image]) + expect(upload).toHaveBeenCalledOnce() + } + ) + + it('keeps plain-paste intent per editor and clears it on keyup or blur', () => { + editor = mount() + const other = mount() + try { + const startPlainPaste = () => + editor?.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'V', + ctrlKey: true, + shiftKey: true, + bubbles: true, + }) + ) + startPlainPaste() + expect(isPlainTextPaste(editor)).toBe(true) + expect(isPlainTextPaste(other)).toBe(false) + editor.view.dom.dispatchEvent(new KeyboardEvent('keyup', { key: 'V', bubbles: true })) + expect(isPlainTextPaste(editor)).toBe(false) + startPlainPaste() + editor.view.dom.dispatchEvent(new Event('blur')) + expect(isPlainTextPaste(editor)).toBe(false) + } finally { + other.destroy() + } + }) + it('renders a pasted inline link as a link mark', () => { editor = mount() expect(paste(editor, '[inline link](https://example.com)')).toBe(true) @@ -69,18 +172,18 @@ describe('markdown paste', () => { expect(paste(editor, 'just a normal sentence with no syntax')).toBe(false) }) - it("prefers the markdown parser over DOM mapping when the HTML sibling's plain-text side also looks like markdown", () => { + it('preserves the rich HTML sibling even when its plain text resembles Markdown', () => { editor = mount() - expect(paste(editor, '# heading', '

heading

')).toBe(true) + dispatchPaste(editor, '# heading', '

heading

') const json = JSON.stringify(editor.getJSON()) expect(json).toContain('"type":"heading"') }) - it('preserves GFM table alignment on a paste that carries both text/plain and text/html', () => { + it('preserves GFM alignment when VSCode explicitly identifies Markdown source', () => { editor = mount() const table = '| a | b |\n| :-- | --: |\n| 1 | 2 |' const html = '
ab
12
' - expect(paste(editor, table, html)).toBe(true) + expect(paste(editor, table, html, { 'vscode-editor-data': '{"mode":"markdown"}' })).toBe(true) const json = JSON.stringify(editor.getJSON()) expect(json).toContain('"align":"left"') expect(json).toContain('"align":"right"') @@ -173,16 +276,54 @@ describe('markdown paste', () => { expect(editor.getText()).toBe(text) }) - it('parses markdown-shaped plain text even when an HTML sibling is present', () => { + it('pastes rendered document structure from HTML instead of reinterpreting its plain text', () => { editor = mount() const html = '

Title

  • a
  • b
' - expect(paste(editor, '# Title\n\n- a\n- b', html)).toBe(true) + dispatchPaste(editor, '# Title\n\n- a\n- b', html) const json = JSON.stringify(editor.getJSON()) expect(json).toContain('"type":"heading"') expect(json).toContain('"type":"bulletList"') expect(json).not.toContain('# Title') }) + it('does not flatten a rich table containing literal Markdown-shaped cell text', () => { + editor = mount() + dispatchPaste( + editor, + 'Label\tValue\n**literal**\t42', + '
LabelValue
**literal**42
' + ) + expect(editor.state.doc.firstChild?.type.name).toBe('table') + expect(editor.state.doc.textContent).toContain('**literal**') + expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"bold"') + }) + + it.each(['# literal', '**literal**', 'https://example.com'])( + 'honors paste without formatting for %s and replaces the selection', + (text) => { + editor = mount() + editor.commands.setContent('

replace

') + editor.commands.setTextSelection({ from: 1, to: 8 }) + editor.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'V', + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ) + dispatchPaste(editor, text, `

${text}

`, { + 'vscode-editor-data': '{"mode":"typescript"}', + }) + expect(editor.state.doc.firstChild?.type.name).toBe('paragraph') + expect(editor.state.doc.textContent).toBe(text) + expect(editor.state.doc.firstChild?.firstChild?.marks).toEqual([]) + dispatchPaste(editor, '# Heading') + expect(editor.isActive('heading')).toBe(true) + } + ) + it('preserves the structural blocks of a multi-block document, in order, on paste', () => { editor = mount() expect(paste(editor, '# Title\n\nA paragraph.\n\n- a\n- b\n\n> quote')).toBe(true) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts index 74a567f2bed..451440d801f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts @@ -1,7 +1,22 @@ -import { Extension } from '@tiptap/core' +import { type Editor, Extension } from '@tiptap/core' import { Plugin } from '@tiptap/pm/state' -import { normalizeLinkHref } from './markdown-fidelity' -import { parseMarkdownToDoc } from './markdown-parse' +import { normalizeLinkHref } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' + +interface MarkdownPasteStorage { + pasteWithoutFormatting: boolean +} + +declare module '@tiptap/core' { + interface Storage { + markdownPaste: MarkdownPasteStorage + } +} + +/** Lets higher-precedence image handlers defer to the same per-editor clipboard intent. */ +export function isPlainTextPaste(editor: Editor): boolean { + return editor.storage.markdownPaste?.pasteWithoutFormatting ?? false +} /** * A single link the paste can wrap a selection in: an http(s) URL, a `mailto:` to a real address, a bare @@ -27,9 +42,8 @@ function pastedLinkHref(text: string): string | null { /** * Structural markdown — strong signals the plain text is genuinely markdown (a link, image, badge, - * list, heading, blockquote, fenced block, or GFM table). Our parser round-trips these more faithfully - * than generic HTML→DOM mapping (GFM alignment, escaping, the `./raw-markdown-snippet.ts` constructs), - * so they are parsed even when the clipboard also carries an HTML sibling. + * list, heading, blockquote, fenced block, or GFM table). Only used after clipboard provenance has + * established that the source is plain text or explicitly Markdown. */ const STRUCTURAL_MARKDOWN_HINTS: ReadonlyArray = [ /^#{1,6}\s/m, @@ -78,19 +92,15 @@ const VSCODE_LANGUAGE_ALIASES: Readonly> = { } /** - * Extracts the source language from VSCode's `vscode-editor-data` clipboard payload (a JSON blob with a - * `mode` field), mapping the few ids that differ from our code-block values. Returns `''` when the - * payload is absent, unparseable, or a non-code mode (plaintext/markdown). A real code language makes - * the paste handler emit a fenced code block — otherwise VSCode's per-token colored-span HTML would - * fall through to ProseMirror's default parser and flatten into plain paragraphs — while an empty - * result falls through so markdown copied from VSCode still parses as markdown. + * Reads VSCode's raw clipboard mode. The caller distinguishes Markdown provenance from code + * language aliases; absent or malformed metadata returns an empty mode. */ -function parseVscodeLanguage(data: string | undefined): string { +function parseVscodeMode(data: string | undefined): string { if (!data) return '' try { const mode = (JSON.parse(data) as { mode?: unknown }).mode if (typeof mode !== 'string') return '' - return VSCODE_LANGUAGE_ALIASES[mode] ?? mode + return mode } catch { return '' } @@ -146,28 +156,70 @@ function stripNonContentHtml(html: string): string { * untouched (code is meant to stay literal). * * Provenance decides plain-text-vs-HTML: a `text/html` sibling (copied from a browser, Slack, Notion, - * GitHub, or this editor) is the signal the source was rich. Structural markdown is still parsed from - * the plain-text sibling regardless — our parser is more faithful for GFM tables and escaping. But - * inline-only marks are equally expressible in HTML, so when a rich sibling is present we defer to the - * DOM path, which preserves structure the plain text can't encode. A plain-text-only clipboard (a - * terminal, a code editor, a `.md` file) always parses. + * GitHub, or this editor) is the signal the source was rich. Defer to that structure even when literal + * cell text resembles Markdown. VSCode's explicit Markdown mode is the exception: its colored-span + * HTML represents source, not a rendered document. Explicit paste-without-formatting bypasses every + * transformation, including link wrapping and source-language code blocks. * * The strictness of the parse matters: `marked` follows CommonMark flanking rules, so `*text*` becomes * emphasis but a space-flanked `5 * width * height` stays literal. The editor sets `enablePasteRules: * false` so StarterKit's lenient mark paste rules (which would mangle that expression on either path) * never run — emphasis is owned by this parser on the plain path and by real HTML tags on the DOM path. */ -export const MarkdownPaste = Extension.create({ +export const MarkdownPaste = Extension.create, MarkdownPasteStorage>({ name: 'markdownPaste', + /** Clipboard intent must run before Link's selection-wrapping paste handler. */ + priority: 1_100, + + addStorage() { + return { pasteWithoutFormatting: false } + }, addProseMirrorPlugins() { - const { editor } = this + const { editor, storage } = this return [ new Plugin({ props: { + handleDOMEvents: { + keydown: (_view, event) => { + storage.pasteWithoutFormatting = + !event.isComposing && + event.keyCode !== 229 && + event.key.toLowerCase() === 'v' && + (event.metaKey || event.ctrlKey) && + event.shiftKey + return false + }, + keyup: () => { + storage.pasteWithoutFormatting = false + return false + }, + blur: () => { + storage.pasteWithoutFormatting = false + return false + }, + }, transformPastedHTML: (html) => stripNonContentHtml(html), - handlePaste: (view, event) => { + handlePaste: (view, event, slice) => { + const pasteWithoutFormatting = storage.pasteWithoutFormatting + storage.pasteWithoutFormatting = false if (!editor.isEditable) return false + if (pasteWithoutFormatting) { + const text = + event.clipboardData?.getData('text/plain') || + event.clipboardData?.getData('Text') || + event.clipboardData?.getData('text/uri-list') + if (!text) return true + view.dispatch( + view.state.tr + .replaceSelection(slice) + .setMeta('paste', true) + .setMeta('uiEvent', 'paste') + .setMeta('preventAutolink', true) + .scrollIntoView() + ) + return true + } if (editor.isActive('codeBlock') || editor.isActive('code')) return false const text = event.clipboardData?.getData('text/plain') if (!text) return false @@ -181,7 +233,8 @@ export const MarkdownPaste = Extension.create({ const safeHref = href ? normalizeLinkHref(href) : '' if (safeHref) return editor.commands.setLink({ href: safeHref }) } - const language = parseVscodeLanguage(event.clipboardData?.getData('vscode-editor-data')) + const mode = parseVscodeMode(event.clipboardData?.getData('vscode-editor-data')) + const language = VSCODE_LANGUAGE_ALIASES[mode] ?? mode if (language) { return editor.commands.insertContent({ type: 'codeBlock', @@ -189,10 +242,10 @@ export const MarkdownPaste = Extension.create({ content: [{ type: 'text', text }], }) } - if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) { - if (!hasAny(INLINE_MARK_HINTS, text)) return false - if (event.clipboardData?.getData('text/html')) return false - } + const isMarkdownSource = mode === 'markdown' || mode === 'md' || mode === 'mdx' + if (event.clipboardData?.getData('text/html') && !isMarkdownSource) return false + if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text) && !hasAny(INLINE_MARK_HINTS, text)) + return false const doc = parseMarkdownToDoc(text) if (!doc.content?.length) return false return editor.commands.insertContent(doc) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-storage.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-storage.test.ts new file mode 100644 index 00000000000..77738956f5f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-storage.test.ts @@ -0,0 +1,341 @@ +/** + * @vitest-environment jsdom + */ +import { Editor, type JSONContent } from '@tiptap/core' +import { CellSelection } from '@tiptap/pm/tables' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { RichMarkdownKeymap } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap' +import { postProcessSerializedMarkdown } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { editorNormalForm } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +import { isRoundTripSafe } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety' + +const editors: Editor[] = [] + +afterEach(() => { + for (const editor of editors.splice(0)) editor.destroy() +}) + +function mount(content: string | JSONContent): Editor { + const editor = new Editor({ + extensions: [...createMarkdownContentExtensions(), RichMarkdownKeymap], + enablePasteRules: false, + content: typeof content === 'string' ? editorNormalForm(content) : content, + }) + editors.push(editor) + return editor +} + +function press(editor: Editor, key: string, options: KeyboardEventInit = {}): boolean { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options }) + editor.view.dom.dispatchEvent(event) + return event.defaultPrevented +} + +function pasteHtml(editor: Editor, html: string): void { + const event = new Event('paste', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'clipboardData', { + value: { getData: (type: string) => (type === 'text/html' ? html : '') }, + }) + editor.view.dom.dispatchEvent(event) +} + +function selectText(editor: Editor, text: string, offset = 0): void { + let position = -1 + editor.state.doc.descendants((node, pos) => { + const index = node.isText ? (node.text?.indexOf(text) ?? -1) : -1 + if (position < 0 && index >= 0) position = pos + index + offset + }) + expect(position).toBeGreaterThan(-1) + editor.commands.setTextSelection(position) +} + +function expectPreserved(editor: Editor): string { + const before = editor.getJSON() + const markdown = postProcessSerializedMarkdown(editor.getMarkdown()) + const reopened = mount(markdown) + expect(reopened.getJSON()).toEqual(before) + expect(postProcessSerializedMarkdown(reopened.getMarkdown())).toBe(markdown) + expect(isRoundTripSafe(markdown)).toBe(true) + return markdown +} + +describe('structural paragraphs survive storage', () => { + it.each([ + ['plain document', '- one\n- two\n- three\n\nfollowing'], + ['HTML elsewhere', '\n\n- one\n- two\n- three\n\nfollowing'], + ['footnote elsewhere', 'note[^a]\n\n- one\n- two\n- three\n\nfollowing\n\n[^a]: note'], + ['blockquote', '> - one\n> - two\n> - three\n>\n> following'], + ['nested blockquote', '> > - one\n> > - two\n> > - three\n> >\n> > following'], + ])('keeps a trailing list-exit paragraph in %s', (_label, markdown) => { + const editor = mount(markdown) + selectText(editor, 'three') + editor.commands.deleteRange({ + from: editor.state.selection.from, + to: editor.state.selection.from + 5, + }) + expect(press(editor, 'Enter')).toBe(true) + expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') + expect(expectPreserved(editor)).toContain('

') + }) + + it('treats an actual empty HTML paragraph as a paragraph without claiming other raw HTML', () => { + const editor = mount('before\n\n

\n\n
raw
\n\nafter') + expect(editor.getJSON().content?.map((node) => node.type)).toEqual([ + 'paragraph', + 'paragraph', + 'rawHtmlBlock', + 'paragraph', + ]) + expect(editor.getText()).toContain('
raw
') + }) + + it('does not store the trailing typing placeholder after a list as HTML', () => { + const editor = mount('- one') + expect(editor.getMarkdown()).not.toContain('

') + }) +}) + +describe('paragraph hard-break fidelity', () => { + it.each(['# heading', '###### heading', '- item', '+ item', '1. item', '1) item', '---', '==='])( + 'preserves literal %s typed after Shift+Enter', + (literal) => { + const editor = mount('first') + selectText(editor, 'first', 5) + expect(press(editor, 'Enter', { shiftKey: true })).toBe(true) + editor.commands.insertContent({ type: 'text', text: literal }) + expectPreserved(editor) + } + ) + + it('preserves marked text and inline-code literals across hard breaks', () => { + const editor = mount('first') + selectText(editor, 'first', 5) + press(editor, 'Enter', { shiftKey: true }) + editor.commands.insertContent([ + { type: 'text', text: '# heading', marks: [{ type: 'code' }] }, + { type: 'hardBreak' }, + { type: 'text', text: '- item', marks: [{ type: 'bold' }] }, + ]) + expectPreserved(editor) + }) + + it('preserves consecutive hard breaks instead of reparsing them as separate paragraphs', () => { + const editor = mount('first') + selectText(editor, 'first', 5) + press(editor, 'Enter', { shiftKey: true }) + press(editor, 'Enter', { shiftKey: true }) + editor.commands.insertContent('next') + expectPreserved(editor) + }) +}) + +describe('GFM table capabilities', () => { + const markdown = '| heading | value |\n| --- | --- |\n| one | two |' + + it.each([false, true])('cell Enter with shift=%s creates a persistent hard break', (shiftKey) => { + const editor = mount(markdown) + selectText(editor, 'one', 3) + expect(press(editor, 'Enter', { shiftKey })).toBe(true) + editor.commands.insertContent('next') + expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') + expect(expectPreserved(editor)).toContain('one
next') + }) + + it('does not let a heading shortcut or command create a nonpersistent block in a cell', () => { + const editor = mount(markdown) + selectText(editor, 'one') + press(editor, '1', { ctrlKey: true, altKey: true }) + expect(editor.commands.toggleHeading({ level: 1 })).toBe(false) + expect(editor.commands.toggleBulletList()).toBe(false) + expect(editor.commands.toggleOrderedList()).toBe(false) + expect(editor.commands.toggleTaskList()).toBe(false) + expect(editor.commands.toggleBlockquote()).toBe(false) + expect(editor.commands.setCodeBlock()).toBe(false) + expect(editor.commands.setHorizontalRule()).toBe(false) + expect(editor.commands.insertTable()).toBe(false) + expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') + expectPreserved(editor) + }) + + it.each(['#', '-', '+', '*', '1.', '>', '```', '- [ ]'])( + 'leaves a typed %s block prefix as literal text in a cell', + (prefix) => { + const editor = mount(markdown) + selectText(editor, 'one') + editor.commands.deleteRange({ + from: editor.state.selection.from, + to: editor.state.selection.from + 3, + }) + editor.commands.insertContent({ type: 'text', text: prefix }) + const { from, to } = editor.state.selection + const handled = editor.view.someProp('handleTextInput', (handler) => + handler(editor.view, from, to, ' ', () => editor.state.tr) + ) + expect(handled).not.toBe(true) + editor.commands.insertContent({ type: 'text', text: ' literal' }) + expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') + expectPreserved(editor) + } + ) + + it('gates structural header removal, merged cells, and persistent-width commands', () => { + const editor = mount(markdown) + selectText(editor, 'heading') + expect(editor.can().addRowBefore()).toBe(false) + expect(editor.can().deleteRow()).toBe(false) + expect(editor.commands.toggleHeaderRow()).toBe(false) + expect(editor.commands.toggleHeaderColumn()).toBe(false) + expect(editor.commands.toggleHeaderCell()).toBe(false) + expect(editor.commands.setCellAttribute('colwidth', [240])).toBe(false) + const table = editor.state.doc.firstChild! + const firstCell = 2 + const secondCell = firstCell + table.firstChild!.firstChild!.nodeSize + editor.view.dispatch( + editor.state.tr.setSelection(CellSelection.create(editor.state.doc, firstCell, secondCell)) + ) + expect(editor.state.selection).toBeInstanceOf(CellSelection) + expect(editor.can().mergeCells()).toBe(false) + expect(editor.commands.mergeCells()).toBe(false) + expectPreserved(editor) + }) + + it('preserves ordinary row and column editing through save and reopen', () => { + const editor = mount(markdown) + selectText(editor, 'one') + expect(editor.commands.addRowBefore()).toBe(true) + expect(editor.commands.addRowAfter()).toBe(true) + expect(editor.commands.addColumnAfter()).toBe(true) + expectPreserved(editor) + selectText(editor, 'one') + expect(editor.commands.deleteRow()).toBe(true) + expectPreserved(editor) + }) + + it('new rows inherit column alignment so it stays identical after reload', () => { + const editor = mount('| heading | value |\n| :---: | ---: |\n| one | two |') + selectText(editor, 'one') + expect(editor.commands.addRowBefore()).toBe(true) + expect(editor.commands.addRowAfter()).toBe(true) + expectPreserved(editor) + }) + + it('retains legacy rich cell nodes in the collaborative schema', () => { + const editor = mount(markdown) + const content = editor.getJSON() + const cell = content.content?.[0].content?.[1].content?.[0] + expect(cell).toBeDefined() + if (!cell) return + cell.content = [ + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'legacy heading' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'legacy paragraph' }] }, + ] + editor.commands.setContent(content) + expect(editor.getJSON()).toEqual(content) + const saved = editor.getMarkdown() + expect(saved).toContain('

legacy heading

') + expect(saved).toContain('

legacy paragraph

') + const reopened = mount(saved) + expect(reopened.getJSON().content?.[0].type).toBe('rawHtmlBlock') + expect(postProcessSerializedMarkdown(reopened.getMarkdown())).toBe( + postProcessSerializedMarkdown(saved) + ) + }) + + it('preserves significant interior code whitespace in GFM cells', () => { + const editor = mount('| heading |\n| --- |\n| `one two` |') + expect(expectPreserved(editor)).toContain('`one two`') + expect(editor.getMarkdown()).not.toContain(' { + const editor = mount('') + pasteHtml( + editor, + '
heading

bold

italic one two

' + ) + const cell = editor.getJSON().content?.[0].content?.[1].content?.[0] + expect(cell?.content).toEqual([ + { + type: 'paragraph', + content: [ + { type: 'text', marks: [{ type: 'bold' }], text: 'bold' }, + { type: 'hardBreak' }, + { type: 'hardBreak' }, + { type: 'text', marks: [{ type: 'italic' }], text: 'italic' }, + { type: 'text', text: ' ' }, + { type: 'text', marks: [{ type: 'code' }], text: 'one two' }, + ], + }, + ]) + expect(expectPreserved(editor)).not.toContain(' { + const editor = mount(markdown) + selectText(editor, 'one', 1) + pasteHtml(editor, '

bold

italic

') + const cell = editor.getJSON().content?.[0].content?.[1].content?.[0] + expect(cell?.content).toHaveLength(1) + expect(cell?.content?.[0].type).toBe('paragraph') + expect(editor.state.selection.$from.parent.textContent).toBe('obolditalicne') + const saved = expectPreserved(editor) + expect(saved).toContain('o**bold**
*italic*ne') + expect(saved).not.toContain(' { + const editor = mount('') + pasteHtml(editor, '

heading

paragraph

') + expect(editor.getJSON().content?.map((node) => node.type)).toEqual(['heading', 'paragraph']) + expectPreserved(editor) + }) + + it('normalizes native copied cells without rewriting the source document', () => { + const source = mount(markdown) + const sourceContent = source.getJSON() + const sourceCell = sourceContent.content?.[0].content?.[1].content?.[0] + expect(sourceCell).toBeDefined() + if (!sourceCell) return + sourceCell.content = [ + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'heading' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'paragraph' }] }, + ] + source.commands.setContent(sourceContent) + selectText(source, 'paragraph') + const sourceCellPosition = source.state.selection.$from.before(3) + source.view.dispatch( + source.state.tr.setSelection(CellSelection.create(source.state.doc, sourceCellPosition)) + ) + const clipboard = source.view.serializeForClipboard(source.state.selection.content()) + const target = mount(markdown) + selectText(target, 'one') + pasteHtml(target, clipboard.dom.innerHTML) + expect(source.getJSON()).toEqual(sourceContent) + expect(expectPreserved(target)).toContain('heading
paragraph') + }) + + it.each([ + ['nested table', '
nested
', '', '
  • nested item

  • ', ' { + const editor = mount('') + pasteHtml( + editor, + `
    heading

    keep heading

    ${block}

    keep paragraph

    ` + ) + const cell = editor.getJSON().content?.[0].content?.[1].content?.[0] + expect(cell?.content?.[0].type).toBe('heading') + const saved = postProcessSerializedMarkdown(editor.getMarkdown()) + expect(saved).toContain('

    keep heading

    ') + expect(saved).toContain('

    keep paragraph

    ') + expect(saved).toContain(tag) + const reopened = mount(saved) + expect(reopened.getJSON().content?.[0].type).toBe('rawHtmlBlock') + expect(postProcessSerializedMarkdown(reopened.getMarkdown())).toBe(saved) + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts index 1461b83b382..ff2b642297b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -1,7 +1,17 @@ -export { MENTION_PLUGIN_KEY, Mention, type MentionStorage } from './mention' -export { MentionChip } from './mention-chip' -export { MarkdownMention } from './mention-node' -export { SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link' -export type { MentionItem, MentionKind } from './types' -export { useEditorMentions } from './use-editor-mentions' -export { useMarkdownMentions } from './use-markdown-mentions' +export { + Mention, + type MentionStorage, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention' +export { MentionChip } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip' +export { MarkdownMention } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node' +export { + SIM_LINK_SCHEME, + simLinkPath, + toSimHref, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link' +export type { + MentionItem, + MentionKind, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types' +export { useEditorMentions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions' +export { useMarkdownMentions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx index 8ff82847a9d..f0b1f6bbce0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx @@ -11,10 +11,13 @@ import { File } from '@sim/emcn/icons' import { Editor } from '@tiptap/core' import { EditorContent, ReactRenderer } from '@tiptap/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMarkdownEditorExtensions } from '../editor-extensions' -import { MentionList, type MentionListHandle } from './mention-list' -import { createMentionStore } from './mention-store' -import type { MentionItem } from './types' +import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' +import { + MentionList, + type MentionListHandle, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list' +import { createMentionStore } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store' +import type { MentionItem } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types' const items: MentionItem[] = [ { kind: 'file', id: 'a', label: 'Alpha', group: 'Files', icon: File }, @@ -141,6 +144,35 @@ describe('MentionList keyboard nav', () => { expect(command).toHaveBeenCalledWith(items[0]) }) + it.each([ + ['Enter', { shiftKey: true }], + ['Tab', { shiftKey: true }], + ['Enter', { ctrlKey: true }], + ['Tab', { ctrlKey: true }], + ['Enter', { metaKey: true }], + ['Tab', { altKey: true }], + ['ArrowDown', { shiftKey: true }], + ['ArrowUp', { metaKey: true }], + ['Enter', { isComposing: true }], + ['Enter', { keyCode: 229 }], + ])('does not consume %s with reserved modifiers or composition %j', (key, options) => { + const ref = createRef() + const command = vi.fn() + const store = createMentionStore() + store.set(items) + act(() => + root.render( + + ) + ) + + expect( + ref.current?.onKeyDown({ event: new KeyboardEvent('keydown', { key, ...options }) }) + ).toBe(false) + expect(command).not.toHaveBeenCalled() + expect(container.querySelector('[aria-selected="true"]')?.textContent).toBe('Alpha') + }) + it('exposes a working onKeyDown through ReactRenderer (the suggestion plugin path)', async () => { act(() => { root.render() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index 1ab92bd938a..78e91f881ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -1,13 +1,13 @@ import { Extension } from '@tiptap/core' -import { PluginKey } from '@tiptap/pm/state' import Suggestion from '@tiptap/suggestion' -import { createSuggestionPopupRenderer } from '../menus/suggestion-popup' -import { MentionList } from './mention-list' -import { createMentionStore, type MentionStore } from './mention-store' -import type { MentionItem } from './types' - -/** Distinct from the `/` slash command's key — two plugins can't share one key. Exported so the keymap can detect an open menu. */ -export const MENTION_PLUGIN_KEY = new PluginKey('mention') +import { MentionList } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list' +import { + createMentionStore, + type MentionStore, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store' +import type { MentionItem } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types' +import { createSuggestionPopupRenderer } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup' +import { MENTION_PLUGIN_KEY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/suggestion-plugin-keys' /** * Per-editor storage for the `@` mention extension. The host component populates {@link store} with diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx index 1da1a6d7caf..1ba7457cc4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx @@ -16,14 +16,26 @@ import { TextQuote, Unlink, } from '@sim/emcn/icons' -import { PluginKey } from '@tiptap/pm/state' +import { + PluginKey, + type SelectionBookmark, + TextSelection, + type Transaction, +} from '@tiptap/pm/state' import type { Editor } from '@tiptap/react' import { useEditorState } from '@tiptap/react' import { BubbleMenu } from '@tiptap/react/menus' -import { BUBBLE_MENU_CLASS } from './bubble-menu-chrome' -import { applyLink, LinkUrlInput } from './link-editing' -import { ToolbarButton, ToolbarDivider } from './toolbar-button' -import { useBubbleMenuFloating } from './use-bubble-menu-floating' +import { BUBBLE_MENU_CLASS } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu-chrome' +import { + applyLink, + LinkUrlInput, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing' +import { + ToolbarButton, + ToolbarDivider, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button' +import { useBubbleMenuFloating } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating' +import { useEditorToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-editor-toolbar' /** * Whether the formatting toolbar may show for the given range: the editor is editable, the range @@ -67,7 +79,7 @@ export function EditorBubbleMenu({ }: EditorBubbleMenuProps) { const [linkValue, setLinkValue] = useState(null) const linkInputRef = useRef(null) - const linkRangeRef = useRef<{ from: number; to: number } | null>(null) + const linkRangeRef = useRef(null) const isEditingLink = linkValue !== null const [bubbleMenuKey] = useState(() => new PluginKey('markdownBubbleMenu')) @@ -76,6 +88,7 @@ export function EditorBubbleMenu({ const active = useEditorState({ editor, selector: ({ editor: e }) => ({ + editable: e.isEditable, bold: e.isActive('bold'), italic: e.isActive('italic'), strike: e.isActive('strike'), @@ -88,6 +101,12 @@ export function EditorBubbleMenu({ orderedList: e.isActive('orderedList'), taskList: e.isActive('taskList'), blockquote: e.isActive('blockquote'), + canHeading1: e.can().toggleHeading({ level: 1 }), + canHeading2: e.can().toggleHeading({ level: 2 }), + canBulletList: e.can().toggleBulletList(), + canOrderedList: e.can().toggleOrderedList(), + canTaskList: e.can().toggleTaskList(), + canBlockquote: e.can().toggleBlockquote(), }), }) @@ -96,13 +115,31 @@ export function EditorBubbleMenu({ }, [isEditingLink]) useEffect(() => { + const mapLinkRange = ({ + transaction, + appendedTransactions = [], + }: { + transaction: Transaction + appendedTransactions?: Transaction[] + }) => { + let bookmark = linkRangeRef.current + if (!bookmark) return + for (const change of [transaction, ...appendedTransactions]) + bookmark = bookmark.map(change.mapping) + const selection = bookmark.resolve(editor.state.doc) + linkRangeRef.current = + selection instanceof TextSelection && !selection.empty ? bookmark : null + if (!linkRangeRef.current) setLinkValue(null) + } const exitOnCollapse = () => { const { from, to } = editor.state.selection if (from === to) setLinkValue(null) } editor.on('selectionUpdate', exitOnCollapse) + editor.on('transaction', mapLinkRange) return () => { editor.off('selectionUpdate', exitOnCollapse) + editor.off('transaction', mapLinkRange) } }, [editor]) @@ -137,9 +174,8 @@ export function EditorBubbleMenu({ }, [editor, bubbleMenuKey]) const openLinkEditor = () => { - if (editor.isActive('codeBlock') || editor.isActive('code')) return - const { from, to } = editor.state.selection - linkRangeRef.current = { from, to } + if (!editor.isEditable || editor.isActive('codeBlock') || editor.isActive('code')) return + linkRangeRef.current = editor.state.selection.getBookmark() setLinkValue(editor.getAttributes('link').href ?? '') } @@ -147,12 +183,19 @@ export function EditorBubbleMenu({ const dom = editor.view.dom const openLinkOnShortcut = (event: KeyboardEvent) => { if (!editor.isEditable) return - if (!(event.metaKey || event.ctrlKey) || event.isComposing) return + if ( + !(event.metaKey || event.ctrlKey) || + event.isComposing || + event.keyCode === 229 || + event.altKey || + event.shiftKey + ) + return if (event.key?.toLowerCase() !== 'k') return const { from, to } = editor.state.selection if (from === to || editor.isActive('codeBlock') || editor.isActive('code')) return event.preventDefault() - linkRangeRef.current = { from, to } + linkRangeRef.current = editor.state.selection.getBookmark() setLinkValue(editor.getAttributes('link').href ?? '') } dom.addEventListener('keydown', openLinkOnShortcut) @@ -161,26 +204,32 @@ export function EditorBubbleMenu({ } }, [editor]) - // The captured range can outlive a programmatic doc change (image insert, content sync), so - // clamp it to the current document before re-selecting to avoid a "position out of range" throw. - const selectCapturedRange = (chain: ReturnType) => { - const range = linkRangeRef.current - if (!range) return chain - const max = editor.state.doc.content.size - return chain.setTextSelection({ from: Math.min(range.from, max), to: Math.min(range.to, max) }) - } - - const commitLink = () => { - applyLink(selectCapturedRange(editor.chain().focus()), linkValue ?? '') - setLinkValue(null) - } - - const removeLink = () => { - applyLink(selectCapturedRange(editor.chain().focus()), '') + const commitCapturedLink = (href: string) => { + if (editor.isDestroyed || !editor.isEditable) return + const selection = linkRangeRef.current?.resolve(editor.state.doc) + if (selection instanceof TextSelection && !selection.empty) { + applyLink( + editor.chain().focus().setTextSelection({ from: selection.from, to: selection.to }), + href + ) + } + linkRangeRef.current = null setLinkValue(null) } + const commitLink = () => commitCapturedLink(linkValue ?? '') + const removeLink = () => commitCapturedLink('') const { resolveAnchor, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef) + const canFocus = useCallback( + () => hasFormattableSelection(editor, editor.state.selection.from, editor.state.selection.to), + [editor] + ) + const toolbar = useEditorToolbar({ + editor, + pluginKey: bubbleMenuKey, + roving: !isEditingLink, + canFocus, + }) const shouldShow = useCallback( ({ editor: e, from, to }: { editor: Editor; from: number; to: number }) => { @@ -200,132 +249,139 @@ export function EditorBubbleMenu({ pluginKey={bubbleMenuKey} getReferencedVirtualElement={resolveAnchor} appendTo={appendTo} - role='toolbar' - aria-label='Text formatting' updateDelay={0} shouldShow={shouldShow} + hidden={!active.editable} className={BUBBLE_MENU_CLASS} > - {isEditingLink ? ( - <> - setLinkValue(null)} - /> - {active.link && ( +
    + {isEditingLink ? ( + <> + setLinkValue(null)} + /> + {active.link && ( + + )} + + + ) : ( + <> + {onAddToChat && ( + <> + + + + )} + editor.chain().focus().toggleBold().run()} + /> + editor.chain().focus().toggleItalic().run()} + /> + editor.chain().focus().toggleStrike().run()} + /> + editor.chain().focus().toggleMark('highlight').run()} + /> + editor.chain().focus().toggleCode().run()} + /> + + + editor.chain().focus().toggleHeading({ level: 1 }).run()} + /> + editor.chain().focus().toggleHeading({ level: 2 }).run()} + /> + + editor.chain().focus().toggleBulletList().run()} + /> + editor.chain().focus().toggleOrderedList().run()} + /> + editor.chain().focus().toggleTaskList().run()} + /> editor.chain().focus().toggleBlockquote().run()} /> - )} - - - ) : ( - <> - {onAddToChat && ( - <> - - - - )} - editor.chain().focus().toggleBold().run()} - /> - editor.chain().focus().toggleItalic().run()} - /> - editor.chain().focus().toggleStrike().run()} - /> - editor.chain().focus().toggleMark('highlight').run()} - /> - editor.chain().focus().toggleCode().run()} - /> - - - editor.chain().focus().toggleHeading({ level: 1 }).run()} - /> - editor.chain().focus().toggleHeading({ level: 2 }).run()} - /> - - editor.chain().focus().toggleBulletList().run()} - /> - editor.chain().focus().toggleOrderedList().run()} - /> - editor.chain().focus().toggleTaskList().run()} - /> - editor.chain().focus().toggleBlockquote().run()} - /> - - )} + + )} +
    ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/editor-toolbar-integration.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/editor-toolbar-integration.test.tsx new file mode 100644 index 00000000000..e57463a6708 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/editor-toolbar-integration.test.tsx @@ -0,0 +1,358 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { Tooltip } from '@sim/emcn' +import { Editor } from '@tiptap/core' +import { type EditorState, Plugin, type Transaction } from '@tiptap/pm/state' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { editorNormalForm } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +import { EditorBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu' +import { TableBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu' + +let editor: Editor +let root: Root +let viewport: HTMLDivElement +let host: HTMLDivElement + +beforeEach(() => { + vi.useFakeTimers() + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + viewport = document.createElement('div') + const editorHost = document.createElement('div') + host = document.createElement('div') + viewport.append(editorHost, host) + document.body.append(viewport) + editor = new Editor({ + element: editorHost, + extensions: createMarkdownContentExtensions(), + content: editorNormalForm('format this\n\n| heading | value |\n| --- | --- |\n| one | two |'), + editorProps: { handleScrollToSelection: () => true }, + }) + vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ top: 10, bottom: 30, left: 10, right: 50 }) + root = createRoot(host) + act(() => { + root.render( + + + + + ) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + editor.destroy() + viewport.remove() + vi.restoreAllMocks() + vi.useRealTimers() +}) + +function select(text: string, collapsed = false): void { + let from = -1 + editor.state.doc.descendants((node, pos) => { + const index = node.isText ? (node.text?.indexOf(text) ?? -1) : -1 + if (from < 0 && index >= 0) from = pos + index + }) + expect(from).toBeGreaterThan(-1) + act(() => { + editor.commands.setTextSelection({ from, to: collapsed ? from : from + text.length }) + editor.view.focus() + }) +} + +function key( + target: HTMLElement, + keyValue: string, + options: KeyboardEventInit = {} +): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key: keyValue, + bubbles: true, + cancelable: true, + ...options, + }) + act(() => target.dispatchEvent(event)) + return event +} + +async function frame(): Promise { + await act(async () => vi.advanceTimersToNextFrame()) +} + +function toolbar(name: string): HTMLElement { + const element = viewport.querySelector(`[role="toolbar"][aria-label="${name}"]`) + if (!element) throw new Error(`Missing real ${name} BubbleMenu`) + return element +} + +function button(menu: HTMLElement, label: string): HTMLButtonElement { + const element = menu.querySelector(`button[aria-label="${label}"]`) + if (!element) throw new Error(`Missing ${label} button`) + return element +} + +function linkGroup(): HTMLElement { + const element = viewport.querySelector('[role="group"][aria-label="Link editing"]') + if (!element) throw new Error('Missing link-editing group') + return element +} + +async function openLinkEditor(): Promise { + select('format') + key(editor.view.dom, 'F10', { altKey: true }) + await frame() + act(() => button(toolbar('Text formatting'), 'Link').click()) + const input = linkGroup().querySelector('input[aria-label="Link URL"]') + if (!input) throw new Error('Missing link URL field') + return input +} + +function changeUrl(input: HTMLInputElement, value: string): void { + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +describe('real editor BubbleMenu keyboard integration', () => { + it('enters the formatting toolbar and returns with Escape without losing the selected text', async () => { + select('format') + const selection = editor.state.selection.toJSON() + expect(key(editor.view.dom, 'F10', { altKey: true }).defaultPrevented).toBe(true) + await frame() + const menu = toolbar('Text formatting') + expect(document.activeElement).toBe(button(menu, 'Bold')) + expect(editor.state.selection.toJSON()).toEqual(selection) + expect([...menu.querySelectorAll('button')].filter((item) => item.tabIndex === 0)).toHaveLength( + 1 + ) + key(button(menu, 'Bold'), 'ArrowRight') + expect(document.activeElement).toBe(button(menu, 'Italic')) + key(button(menu, 'Italic'), 'Escape') + await frame() + expect(document.activeElement).toBe(editor.view.dom) + expect(editor.state.selection.toJSON()).toEqual(selection) + expect(viewport.contains(menu)).toBe(false) + }) + + it('applies a keyboard-reached formatting action to the original selection', async () => { + select('format') + const selection = editor.state.selection.toJSON() + key(editor.view.dom, 'F10', { altKey: true }) + await frame() + act(() => button(toolbar('Text formatting'), 'Bold').click()) + await frame() + expect(editor.getHTML()).toContain('format this') + expect(editor.state.selection.toJSON()).toEqual(selection) + }) + + it.each([ + { text: 'format', collapsed: false, menuName: 'Text formatting', label: 'Bold' }, + { text: 'one', collapsed: true, menuName: 'Table editing', label: 'Delete table' }, + ])( + 'hides $menuName and blocks a queued $label action when editability changes', + async ({ text, collapsed, menuName, label }) => { + select(text, collapsed) + key(editor.view.dom, 'F10', { altKey: true }) + await frame() + const menu = toolbar(menuName) + const floating = menu.parentElement! + const action = button(menu, label) + const before = editor.getJSON() + expect(floating.hidden).toBe(false) + act(() => { + editor.setEditable(false) + action.click() + }) + expect(editor.getJSON()).toEqual(before) + expect(floating.hidden).toBe(true) + expect(getComputedStyle(floating).display).toBe('none') + act(() => action.click()) + expect(editor.getJSON()).toEqual(before) + + act(() => editor.setEditable(true)) + expect(floating.hidden).toBe(false) + act(() => action.click()) + await frame() + expect(editor.getJSON()).not.toEqual(before) + } + ) + + it('does not focus a toolbar if editability changes before the Alt+F10 frame', async () => { + select('format') + key(editor.view.dom, 'F10', { altKey: true }) + act(() => editor.setEditable(false)) + await frame() + expect(document.activeElement?.closest('[role="toolbar"]')).toBeNull() + }) + + it('enters the table toolbar at a cell caret with non-toggle actions and preserves the caret', async () => { + select('one', true) + const selection = editor.state.selection.toJSON() + key(editor.view.dom, 'F10', { altKey: true }) + await frame() + const menu = toolbar('Table editing') + expect(document.activeElement).toBe(button(menu, 'Insert row above')) + expect(button(menu, 'Insert row above').hasAttribute('aria-pressed')).toBe(false) + expect(editor.state.selection.toJSON()).toEqual(selection) + key(button(menu, 'Insert row above'), 'End') + expect(document.activeElement).toBe(button(menu, 'Delete table')) + key(button(menu, 'Delete table'), 'Escape') + await frame() + expect(document.activeElement).toBe(editor.view.dom) + expect(editor.state.selection.toJSON()).toEqual(selection) + }) + + it('disables unsupported table block controls and skips them during roving navigation', async () => { + select('one') + key(editor.view.dom, 'F10', { altKey: true }) + await frame() + const menu = toolbar('Text formatting') + expect(document.activeElement).toBe(button(menu, 'Bold')) + for (const label of [ + 'Heading 1', + 'Heading 2', + 'Bulleted list', + 'Numbered list', + 'Checklist', + 'Quote', + ]) { + expect(button(menu, label).disabled).toBe(true) + } + expect(button(menu, 'Bold').disabled).toBe(false) + key(button(menu, 'Bold'), 'End') + expect(document.activeElement).toBe(button(menu, 'Link')) + key(button(menu, 'Link'), 'ArrowRight') + expect(document.activeElement).toBe(button(menu, 'Bold')) + }) + + it('keeps native URL-field navigation and cancels the draft on Escape with the range retained', async () => { + select('format') + const selection = editor.state.selection.toJSON() + key(editor.view.dom, 'F10', { altKey: true }) + await frame() + act(() => button(toolbar('Text formatting'), 'Link').click()) + const input = viewport.querySelector('input[aria-label="Link URL"]') + expect(input).not.toBeNull() + if (!input) return + expect(document.activeElement).toBe(input) + expect(input.tabIndex).toBe(0) + expect(button(linkGroup(), 'Apply link').tabIndex).toBe(0) + for (const keyValue of ['ArrowLeft', 'ArrowRight', 'Home', 'End']) { + expect(key(input, keyValue).defaultPrevented).toBe(false) + expect(document.activeElement).toBe(input) + } + expect(key(input, 'Enter', { isComposing: true }).defaultPrevented).toBe(false) + expect(viewport.contains(input)).toBe(true) + key(input, 'Escape') + await frame() + expect(viewport.contains(input)).toBe(false) + expect(document.activeElement).toBe(editor.view.dom) + expect(editor.state.selection.toJSON()).toEqual(selection) + expect(editor.getHTML()).not.toContain(' { + select('format') + act(() => editor.commands.setLink({ href: 'https://example.com/original' })) + const input = await openLinkEditor() + const group = linkGroup() + const remove = button(group, 'Remove link') + const apply = button(group, 'Apply link') + expect([input.tabIndex, remove.tabIndex, apply.tabIndex]).toEqual([0, 0, 0]) + expect(key(input, 'Tab').defaultPrevented).toBe(false) + act(() => remove.focus()) + expect([input.tabIndex, remove.tabIndex, apply.tabIndex]).toEqual([0, 0, 0]) + expect(key(remove, 'ArrowRight').defaultPrevented).toBe(false) + expect(key(remove, 'Tab').defaultPrevented).toBe(false) + }) + + it('maps the captured link target through a prefix edit and an appended transaction', async () => { + const input = await openLinkEditor() + changeUrl(input, 'https://example.com/mapped') + const appendPrefix = vi.fn( + (transactions: readonly Transaction[], _oldState: EditorState, newState: EditorState) => { + if (!transactions.some((transaction) => transaction.getMeta('toolbar-prefix'))) return null + return newState.tr.insertText('appended ', 1) + } + ) + editor.registerPlugin(new Plugin({ appendTransaction: appendPrefix })) + act(() => + editor.view.dispatch(editor.state.tr.insertText('prefix ', 1).setMeta('toolbar-prefix', true)) + ) + expect(appendPrefix).toHaveBeenCalled() + expect(editor.state.doc.firstChild?.textContent).toBe('appended prefix format this') + expect(viewport.contains(input)).toBe(true) + act(() => button(linkGroup(), 'Apply link').click()) + await frame() + const paragraph = editor.view.dom.querySelector('p') + expect(paragraph?.textContent).toBe('appended prefix format this') + const link = paragraph?.querySelector('a') + expect(link?.textContent).toBe('format') + expect(link?.getAttribute('href')).toBe('https://example.com/mapped') + expect(paragraph?.querySelectorAll('a')).toHaveLength(1) + }) + + it('preserves the link draft and maps its target through a temporary read-only interval', async () => { + const input = await openLinkEditor() + changeUrl(input, 'https://example.com/resumed') + const group = linkGroup() + const floating = group.parentElement! + const apply = button(group, 'Apply link') + const before = editor.getJSON() + act(() => { + editor.setEditable(false) + apply.click() + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + expect(editor.getJSON()).toEqual(before) + expect(floating.hidden).toBe(true) + expect(input.value).toBe('https://example.com/resumed') + act(() => editor.view.dispatch(editor.state.tr.insertText('prefix ', 1))) + act(() => editor.setEditable(true)) + expect(floating.hidden).toBe(false) + expect(input.value).toBe('https://example.com/resumed') + act(() => apply.click()) + await frame() + expect(editor.view.dom.querySelector('a')?.textContent).toBe('format') + expect(editor.view.dom.querySelector('a')?.getAttribute('href')).toBe( + 'https://example.com/resumed' + ) + expect(editor.state.doc.firstChild?.textContent).toBe('prefix format this') + }) + + it('cancels a captured link when its target is deleted and ignores a queued apply click', async () => { + const input = await openLinkEditor() + changeUrl(input, 'https://example.com/deleted') + const apply = button(linkGroup(), 'Apply link') + const { from, to } = editor.state.selection + act(() => editor.view.dispatch(editor.state.tr.delete(from, to))) + expect(viewport.contains(input)).toBe(false) + const afterDelete = editor.getJSON() + act(() => apply.click()) + await frame() + expect(editor.getJSON()).toEqual(afterDelete) + expect(editor.view.dom.querySelector('a')).toBeNull() + }) + + it.each(['Apply link', 'Remove link'])( + 'does not run a queued %s action after becoming read-only', + async (label) => { + select('format') + act(() => editor.commands.setLink({ href: 'https://example.com/original' })) + const input = await openLinkEditor() + changeUrl(input, 'https://example.com/replacement') + const action = button(linkGroup(), label) + const before = editor.getJSON() + act(() => editor.setEditable(false)) + act(() => action.click()) + await frame() + expect(editor.getJSON()).toEqual(before) + expect(editor.view.dom.querySelector('a')?.getAttribute('href')).toBe( + 'https://example.com/original' + ) + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts index e66652f8e54..0ce351a1e07 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts @@ -1,6 +1,12 @@ +/** @vitest-environment jsdom */ +import { act, createElement, createRef } from 'react' import type { ChainedCommands } from '@tiptap/core' +import { createRoot } from 'react-dom/client' import { describe, expect, it, vi } from 'vitest' -import { applyLink } from './link-editing' +import { + applyLink, + LinkUrlInput, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing' function chainSpy() { const calls: string[] = [] @@ -44,3 +50,47 @@ describe('applyLink', () => { } }) }) + +describe('LinkUrlInput composition handling', () => { + it.each(['Enter', 'Escape'])('does not act on %s used by an IME', (key) => { + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + const inputRef = createRef() + const onCommit = vi.fn() + const onCancel = vi.fn() + act(() => + root.render( + createElement(LinkUrlInput, { + value: 'https://example.com', + onChange: vi.fn(), + onCommit, + onCancel, + inputRef, + }) + ) + ) + try { + for (const options of [{ isComposing: true }, { isComposing: false, keyCode: 229 }]) { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...options, + }) + act(() => inputRef.current?.dispatchEvent(event)) + expect(event.defaultPrevented).toBe(false) + } + expect(onCommit).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + act(() => inputRef.current?.dispatchEvent(event)) + expect(event.defaultPrevented).toBe(true) + expect(key === 'Enter' ? onCommit : onCancel).toHaveBeenCalledOnce() + } finally { + act(() => root.unmount()) + host.remove() + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx index 66ad7a62745..3ebcc2c6312 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx @@ -1,6 +1,6 @@ import type { Ref } from 'react' import type { ChainedCommands } from '@tiptap/core' -import { normalizeLinkHref } from '../markdown-fidelity' +import { normalizeLinkHref } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' /** * Applies a link to the chain's current selection: normalizes `rawHref`, expands to the full link @@ -27,6 +27,7 @@ interface LinkUrlInputProps { onCommit: () => void onCancel: () => void inputRef: Ref + readOnly?: boolean } /** @@ -34,7 +35,14 @@ interface LinkUrlInputProps { * cancels. Styled to sit flush in the 28px floating micro-toolbar (a `ChipInput` would impose its own * field chrome and break the bar), so this is a deliberate raw ``. */ -export function LinkUrlInput({ value, onChange, onCommit, onCancel, inputRef }: LinkUrlInputProps) { +export function LinkUrlInput({ + value, + onChange, + onCommit, + onCancel, + inputRef, + readOnly = false, +}: LinkUrlInputProps) { return ( onChange(event.target.value)} onKeyDown={(event) => { - if (event.key === 'Enter') { + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return + if (event.key === 'Enter' && !readOnly) { event.preventDefault() onCommit() } else if (event.key === 'Escape') { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.test.tsx new file mode 100644 index 00000000000..22d85bd7555 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.test.tsx @@ -0,0 +1,247 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { computePosition } from '@floating-ui/dom' +import { Tooltip } from '@sim/emcn' +import { Editor } from '@tiptap/core' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' +import { LinkHoverCard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card' + +vi.mock('@floating-ui/dom', () => ({ + autoUpdate: (_reference: HTMLElement, _floating: HTMLElement, update: () => void) => { + update() + return () => {} + }, + computePosition: vi.fn(async () => ({ x: 10, y: 20 })), + flip: vi.fn(), + offset: vi.fn(), + shift: vi.fn(), +})) + +describe('link hover card focus and draft lifecycle', () => { + let host: HTMLDivElement + let editorHost: HTMLDivElement + let root: Root + let editor: Editor + + beforeEach(() => { + vi.useFakeTimers() + host = document.createElement('div') + editorHost = document.createElement('div') + document.body.append(host, editorHost) + root = createRoot(host) + editor = new Editor({ + element: editorHost, + extensions: createMarkdownEditorExtensions({ placeholder: '' }), + content: + '

    first and second

    ', + }) + act(() => + root.render( + + + + ) + ) + }) + + afterEach(() => { + act(() => root.unmount()) + editor.destroy() + host.remove() + editorHost.remove() + vi.useRealTimers() + }) + + async function hover(): Promise { + const link = editor.view.dom.querySelector('a') + if (!link) throw new Error('Expected a rendered editor link') + await act(async () => { + link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + }) + expect(document.querySelector('[role="dialog"][aria-label="Link"]')).not.toBeNull() + return link + } + + function button(label: string): HTMLButtonElement { + const element = document.querySelector(`button[aria-label="${label}"]`) + if (!element) throw new Error(`Missing ${label} button`) + return element + } + + function input(): HTMLInputElement { + const element = document.querySelector('input[aria-label="Link URL"]') + if (!element) throw new Error('Missing link URL field') + return element + } + + function editValue(value: string): void { + const field = input() + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(field, value) + field.dispatchEvent(new Event('input', { bubbles: true })) + }) + } + + it('keeps a focused draft through link/card mouse leave and hovering a different link', async () => { + const link = await hover() + act(() => link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))) + act(() => button('Edit link').click()) + editValue('https://example.com/draft') + expect(document.activeElement).toBe(input()) + const card = document.querySelector('[role="dialog"]') + act(() => { + card?.dispatchEvent( + new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body }) + ) + editor.view.dom + .querySelectorAll('a')[1] + .dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + vi.advanceTimersByTime(200) + }) + expect(input().value).toBe('https://example.com/draft') + expect(document.activeElement).toBe(input()) + act(() => button('Apply link').click()) + expect(editor.getHTML()).toContain('href="https://example.com/draft"') + expect(editor.getHTML()).toContain('href="https://example.com/second"') + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('does not reuse the previous anchor coordinates while a new link is being measured', async () => { + await hover() + const pending = Promise.withResolvers>>() + vi.mocked(computePosition).mockReturnValueOnce(pending.promise) + await act(async () => { + editor.view.dom + .querySelectorAll('a')[1]! + .dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + }) + const card = document.querySelector('[role="dialog"][aria-label="Link"]')! + expect(card.style.opacity).toBe('0') + expect(card.style.pointerEvents).toBe('none') + await act(async () => + pending.resolve({ x: 30, y: 40, placement: 'top', strategy: 'fixed', middlewareData: {} }) + ) + expect(card.style.opacity).toBe('1') + expect(card.style.transform).toBe('translate(30px, 40px)') + }) + + it('cancels without applying the draft when the user explicitly clicks outside', async () => { + await hover() + act(() => button('Edit link').click()) + editValue('https://example.com/draft') + act(() => document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(editor.getHTML()).toContain('href="https://example.com/first"') + expect(editor.getHTML()).not.toContain('/draft') + }) + + it('cancels on Escape but not on composition confirmation', async () => { + await hover() + act(() => button('Edit link').click()) + editValue('https://example.com/draft') + act(() => + input().dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', isComposing: true, bubbles: true }) + ) + ) + expect(document.querySelector('input[aria-label="Link URL"]')).not.toBeNull() + act(() => input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(editor.getHTML()).not.toContain('/draft') + }) + + it('closes a nonfocused preview after the hover bridge delay', async () => { + const link = await hover() + act(() => link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))) + act(() => vi.advanceTimersByTime(119)) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + act(() => vi.advanceTimersByTime(1)) + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('keeps a keyboard-focused preview action open when the mouse leaves', async () => { + const link = await hover() + act(() => button('Copy link').focus()) + act(() => { + link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })) + vi.advanceTimersByTime(200) + }) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + const external = document.createElement('button') + document.body.append(external) + act(() => external.focus()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + external.remove() + }) + + it('does not apply a stale draft after its target link has been removed', async () => { + await hover() + act(() => button('Edit link').click()) + editValue('https://example.com/draft') + act(() => editor.commands.setContent('

    replacement document

    ')) + act(() => button('Apply link').click()) + + expect(editor.getHTML()).toBe('

    replacement document

    ') + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('does not mutate the document if editing permission changes during a draft', async () => { + await hover() + act(() => button('Edit link').click()) + editValue('https://example.com/draft') + act(() => editor.setEditable(false)) + act(() => button('Apply link').click()) + + expect(editor.getHTML()).not.toContain('/draft') + expect(editor.getHTML()).toContain('href="https://example.com/first"') + }) + + it('removes stale edit actions immediately and ignores a queued edit click while read-only', async () => { + await hover() + const edit = button('Edit link') + act(() => { + editor.setEditable(false) + edit.click() + }) + expect(document.querySelector('button[aria-label="Edit link"]')).toBeNull() + expect(document.querySelector('button[aria-label="Remove link"]')).toBeNull() + expect(document.querySelector('input[aria-label="Link URL"]')).toBeNull() + expect(button('Copy link')).not.toBeNull() + act(() => editor.setEditable(true)) + expect(button('Edit link')).not.toBeNull() + }) + + it('retains a focused link draft through a temporary read-only interval and resumes editing', async () => { + await hover() + act(() => button('Edit link').click()) + editValue('https://example.com/resumed') + const field = input() + const before = editor.getJSON() + act(() => editor.setEditable(false)) + expect(field.readOnly).toBe(true) + expect(button('Apply link').disabled).toBe(true) + act(() => field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + expect(editor.getJSON()).toEqual(before) + expect(input()).toBe(field) + expect(field.value).toBe('https://example.com/resumed') + expect(document.activeElement).toBe(field) + act(() => editor.setEditable(true)) + expect(field.readOnly).toBe(false) + expect(button('Apply link').disabled).toBe(false) + act(() => button('Apply link').click()) + expect(editor.getHTML()).toContain('href="https://example.com/resumed"') + }) + + it('allows Escape to cancel a read-only link draft', async () => { + await hover() + act(() => button('Edit link').click()) + editValue('https://example.com/cancelled') + const before = editor.getJSON() + act(() => editor.setEditable(false)) + act(() => input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))) + expect(document.querySelector('input[aria-label="Link URL"]')).toBeNull() + expect(editor.getJSON()).toEqual(before) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx index 71d9499e644..30c71b60dad 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx @@ -3,11 +3,14 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d import { useCopyToClipboard } from '@sim/emcn' import { Check, Duplicate, Pencil, Unlink } from '@sim/emcn/icons' import { getMarkRange } from '@tiptap/core' -import type { Editor } from '@tiptap/react' +import { type Editor, useEditorState } from '@tiptap/react' import { createPortal } from 'react-dom' -import { normalizeLinkHref } from '../markdown-fidelity' -import { applyLink, LinkUrlInput } from './link-editing' -import { ToolbarButton } from './toolbar-button' +import { normalizeLinkHref } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { + applyLink, + LinkUrlInput, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing' +import { ToolbarButton } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button' interface LinkHoverCardProps { editor: Editor @@ -16,11 +19,11 @@ interface LinkHoverCardProps { interface LinkRange { from: number to: number - href: string } -/** Resolves the document range and href of the link rendered by `el`, or null if it isn't a link. */ +/** Resolves a still-mounted link's current document range, including changes since it was hovered. */ function resolveLinkRange(editor: Editor, el: HTMLElement): LinkRange | null { + if (!editor.view.dom.contains(el)) return null const { state } = editor.view const linkType = state.schema.marks.link if (!linkType) return null @@ -30,8 +33,7 @@ function resolveLinkRange(editor: Editor, el: HTMLElement): LinkRange | null { getMarkRange(state.doc.resolve(pos), linkType) ?? getMarkRange(state.doc.resolve(pos + 1), linkType) if (!range) return null - const href = el.getAttribute('href') ?? '' - return { from: range.from, to: range.to, href } + return { from: range.from, to: range.to } } /** @@ -41,30 +43,38 @@ function resolveLinkRange(editor: Editor, el: HTMLElement): LinkRange | null { * close delay plus the card's own hover bridge let the pointer travel from the link into the card. */ export function LinkHoverCard({ editor }: LinkHoverCardProps) { + const canEdit = useEditorState({ editor, selector: ({ editor: e }) => e.isEditable }) const [activeLink, setActiveLink] = useState(null) const [draftHref, setDraftHref] = useState(null) - const [position, setPosition] = useState<{ x: number; y: number } | null>(null) + const [measurement, setMeasurement] = useState<{ + anchor: HTMLElement + x: number + y: number + } | null>(null) + const position = measurement?.anchor === activeLink ? measurement : null const isEditing = draftHref !== null const editInputRef = useRef(null) const floatingRef = useRef(null) const { copied, copy } = useCopyToClipboard() const hideTimerRef = useRef(undefined) - // Keep the card anchored to the hovered link with Floating UI's DOM core (the same primitive the - // bubble menu positions through) — no React wrapper, so the harness/app share one React instance. useEffect(() => { const floating = floatingRef.current - if (!activeLink || !floating) { - setPosition(null) - return - } - return autoUpdate(activeLink, floating, () => { + if (!activeLink || !floating) return + let active = true + const cleanup = autoUpdate(activeLink, floating, () => { computePosition(activeLink, floating, { strategy: 'fixed', placement: 'top', middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })], - }).then(({ x, y }) => setPosition({ x, y })) + }).then(({ x, y }) => { + if (active) setMeasurement({ anchor: activeLink, x, y }) + }) }) + return () => { + active = false + cleanup() + } }, [activeLink]) const cancelHide = useCallback(() => window.clearTimeout(hideTimerRef.current), []) @@ -75,17 +85,23 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { }, [cancelHide]) const scheduleHide = useCallback(() => { cancelHide() + if (isEditing) return hideTimerRef.current = window.setTimeout(() => { + if (floatingRef.current?.contains(document.activeElement)) return setActiveLink(null) setDraftHref(null) }, 120) - }, [cancelHide]) + }, [cancelHide, isEditing]) useEffect(() => { const dom = editor.view.dom const onOver = (event: Event) => { - // Don't compete with the selection toolbar while text is selected. - if (!editor.state.selection.empty) return + if ( + isEditing || + floatingRef.current?.contains(document.activeElement) || + !editor.state.selection.empty + ) + return const link = (event.target as HTMLElement | null)?.closest('a') if (link && dom.contains(link)) { cancelHide() @@ -95,7 +111,6 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { const onOut = (event: MouseEvent) => { const link = (event.target as HTMLElement | null)?.closest('a') if (!link) return - // Ignore moves that stay within the same link. if (link.contains(event.relatedTarget as Node | null)) return scheduleHide() } @@ -106,7 +121,23 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { dom.removeEventListener('mouseout', onOut) window.clearTimeout(hideTimerRef.current) } - }, [editor, cancelHide, scheduleHide]) + }, [editor, cancelHide, scheduleHide, isEditing]) + + useEffect(() => { + if (!activeLink) return + const onPointerDown = (event: PointerEvent) => { + const target = event.target + if ( + !(target instanceof Node) || + activeLink.contains(target) || + floatingRef.current?.contains(target) + ) + return + dismiss() + } + document.addEventListener('pointerdown', onPointerDown) + return () => document.removeEventListener('pointerdown', onPointerDown) + }, [activeLink, dismiss]) useEffect(() => { if (isEditing) editInputRef.current?.focus() @@ -116,17 +147,21 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { const rawHref = activeLink.getAttribute('href') ?? '' const safeHref = normalizeLinkHref(rawHref) - const canEdit = editor.isEditable - - const startEdit = () => setDraftHref(rawHref) + const startEdit = () => { + if (editor.isDestroyed || !editor.isEditable) return + cancelHide() + setDraftHref(rawHref) + } const commitEdit = () => { + if (editor.isDestroyed || !editor.isEditable) return const range = resolveLinkRange(editor, activeLink) if (range) applyLink(editor.chain().focus().setTextSelection(range), draftHref ?? '') dismiss() } const removeLink = () => { + if (editor.isDestroyed || !editor.isEditable) return const range = resolveLinkRange(editor, activeLink) if (range) applyLink(editor.chain().focus().setTextSelection(range), '') dismiss() @@ -147,6 +182,10 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { aria-label='Link' onMouseEnter={cancelHide} onMouseLeave={scheduleHide} + onFocus={cancelHide} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) dismiss() + }} className='z-[var(--z-popover)] flex items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-xs transition-opacity duration-150 ease-out' > {isEditing ? ( @@ -154,11 +193,15 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { setDraftHref(null)} + onCancel={() => { + dismiss() + editor.commands.focus() + }} /> - + ) : ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.test.tsx new file mode 100644 index 00000000000..a11c0ccdee2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.test.tsx @@ -0,0 +1,113 @@ +/** @vitest-environment jsdom */ +import { act, createRef } from 'react' +import { Editor } from '@tiptap/core' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' +import { SuggestionList } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list' + +describe('suggestion list accessibility', () => { + let host: HTMLDivElement + let root: Root + let editors: Editor[] + + beforeEach(() => { + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + editors = [0, 1].map( + () => + new Editor({ + extensions: createMarkdownEditorExtensions({ placeholder: '' }), + }) + ) + }) + + afterEach(() => { + act(() => root.unmount()) + for (const editor of editors) editor.destroy() + host.remove() + }) + + function menu(editor: Editor, command = vi.fn(), activeIndex = 0) { + return ( + ()} + groups={[ + { + group: 'Commands', + items: [ + { item: 'First', index: 0 }, + { item: 'Second', index: 1 }, + ], + }, + ]} + activeIndex={activeIndex} + setActiveIndex={vi.fn()} + command={command} + ariaLabel='Commands' + idPrefix='slash-command' + emptyLabel='No results' + itemKey={(item) => item} + renderItem={(item) => item} + /> + ) + } + + it('uses unique IDs and editor-owned active-descendant references across instances', () => { + act(() => + root.render( + <> + {menu(editors[0])} + {menu(editors[1], vi.fn(), 1)} + + ) + ) + const listboxes = Array.from(host.querySelectorAll('[role="listbox"]')) + const ids = Array.from(host.querySelectorAll('[id]'), (element) => element.id) + expect(new Set(ids).size).toBe(ids.length) + editors.forEach((editor, index) => { + const activeId = editor.view.dom.getAttribute('aria-activedescendant') ?? '' + expect(editor.view.dom.getAttribute('aria-controls')).toBe(listboxes[index].id) + expect(listboxes[index].contains(document.getElementById(activeId))).toBe(true) + expect(document.getElementById(activeId)?.textContent).toBe(index === 0 ? 'First' : 'Second') + }) + }) + + it('supports synthetic click activation without pointer-only handling or popup Tab stops', () => { + const command = vi.fn() + act(() => root.render(menu(editors[0], command))) + const option = host.querySelector('[role="option"]') + expect(option).not.toBeNull() + const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + act(() => option?.dispatchEvent(down)) + expect(down.defaultPrevented).toBe(true) + expect(command).not.toHaveBeenCalled() + act(() => option?.click()) + expect(command).toHaveBeenCalledExactlyOnceWith('First') + expect( + Array.from(host.querySelectorAll('[role="option"]')).every( + (element) => element.tabIndex === -1 + ) + ).toBe(true) + }) + + it('updates the active descendant and removes transient attributes when the menu closes', () => { + act(() => root.render(menu(editors[0]))) + const listboxId = editors[0].view.dom.getAttribute('aria-controls') + act(() => root.render(menu(editors[0], vi.fn(), 1))) + const activeId = editors[0].view.dom.getAttribute('aria-activedescendant') ?? '' + expect(editors[0].view.dom.getAttribute('aria-controls')).toBe(listboxId) + expect(document.getElementById(activeId)?.textContent).toBe('Second') + act(() => root.render(null)) + for (const name of [ + 'aria-controls', + 'aria-activedescendant', + 'aria-expanded', + 'aria-haspopup', + ]) { + expect(editors[0].view.dom.hasAttribute(name)).toBe(false) + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx index c446e011fa0..f196bf34975 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, type RefObject, useEffect } from 'react' +import { type ReactNode, type RefObject, useEffect, useId } from 'react' import { cn } from '@sim/emcn' import type { Editor } from '@tiptap/core' import { @@ -6,7 +6,7 @@ import { SUGGESTION_ITEM_CLASS, SUGGESTION_SCROLL_CLASS, SUGGESTION_SURFACE_CLASS, -} from './suggestion-menu-chrome' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome' /** A labeled run of items; `index` is each item's flat position, used for keyboard nav + scroll. */ export interface SuggestionGroup { @@ -25,7 +25,7 @@ interface SuggestionListProps { /** Inserts the chosen item (the suggestion plugin's `command`). */ command: (item: T) => void ariaLabel: string - /** Prefix for each row's element id (`${idPrefix}-${index}`) and the listbox id. */ + /** Human-readable prefix; an instance identifier keeps IDs unique across editors. */ idPrefix: string /** Shown in place of the list when there are no groups (e.g. "No results" / "Loading…"). */ emptyLabel: string @@ -36,7 +36,7 @@ interface SuggestionListProps { /** * The shared grouped-list shell for the `/` and `@` suggestion menus: the bordered surface, the empty * state, the `role="listbox"` → `role="group"` → option-button structure, and the active-row / hover / - * mousedown-select wiring. Each menu computes its own `groups` and supplies `itemKey`/`renderItem`; + * click-selection wiring. Each menu computes its own `groups` and supplies `itemKey`/`renderItem`; * everything else (chrome, a11y, navigation hooks) lives here so the two menus stay identical. * * Accessibility: focus stays in the editor's contenteditable while the user arrows the menu, so the @@ -57,9 +57,11 @@ export function SuggestionList({ itemKey, renderItem, }: SuggestionListProps) { - const listboxId = `${idPrefix}-listbox` + const instanceId = useId() + const optionIdPrefix = `${idPrefix}-${instanceId}` + const listboxId = `${optionIdPrefix}-listbox` const hasOptions = groups.length > 0 - const activeOptionId = hasOptions ? `${idPrefix}-${activeIndex}` : null + const activeOptionId = hasOptions ? `${optionIdPrefix}-${activeIndex}` : null useEffect(() => { const dom = editor.view.dom @@ -112,7 +114,8 @@ export function SuggestionList({ key={itemKey(item)} type='button' role='option' - id={`${idPrefix}-${index}`} + id={`${optionIdPrefix}-${index}`} + tabIndex={-1} aria-selected={index === activeIndex} data-index={index} className={cn( @@ -120,10 +123,8 @@ export function SuggestionList({ index === activeIndex && 'bg-[var(--surface-active)]' )} onMouseEnter={() => setActiveIndex(index)} - onMouseDown={(event) => { - event.preventDefault() - command(item) - }} + onMouseDown={(event) => event.preventDefault()} + onClick={() => command(item)} > {renderItem(item)} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts index 8c4346499e5..a47b81fcd5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts @@ -9,7 +9,7 @@ */ import { Editor } from '@tiptap/core' import { afterEach, describe, expect, it } from 'vitest' -import { createMarkdownContentExtensions } from '../extensions' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' let editor: Editor | null = null afterEach(() => { @@ -49,11 +49,11 @@ describe('table toolbar commands', () => { expect(md).toContain('| --- | --- |') }) - it('inserts a row before the current row', () => { + it('keeps the required Markdown header first when inserting rows', () => { editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') editor.commands.setTextSelection(firstCellPos(editor)) - expect(editor.commands.addRowBefore()).toBe(true) - expect(editor.state.doc.firstChild?.childCount).toBe(3) + expect(editor.commands.addRowBefore()).toBe(false) + expect(editor.state.doc.firstChild?.childCount).toBe(2) }) it('deletes the current row', () => { @@ -94,12 +94,11 @@ describe('table toolbar commands', () => { expect(cols).toBe(2) }) - it('toggles the header row', () => { + it('does not expose an unpersistable header-row toggle', () => { editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') editor.commands.setTextSelection(firstCellPos(editor)) - const before = editor.isActive('tableHeader') - expect(editor.commands.toggleHeaderRow()).toBe(true) - expect(editor.isActive('tableHeader')).toBe(!before) + expect(editor.commands.toggleHeaderRow()).toBe(false) + expect(editor.isActive('tableHeader')).toBe(true) }) it('deletes the whole table', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx index 66f8849ecd2..13ec131d565 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx @@ -1,21 +1,16 @@ -import { useState } from 'react' -import { - ArrowDown, - ArrowLeft, - ArrowRight, - ArrowUp, - Columns3, - Rows3, - Table as TableIcon, - Trash, -} from '@sim/emcn/icons' +import { useCallback, useState } from 'react' +import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Columns3, Rows3, Trash } from '@sim/emcn/icons' import { PluginKey } from '@tiptap/pm/state' import type { Editor } from '@tiptap/react' import { useEditorState } from '@tiptap/react' import { BubbleMenu } from '@tiptap/react/menus' -import { BUBBLE_MENU_CLASS } from './bubble-menu-chrome' -import { ToolbarButton, ToolbarDivider } from './toolbar-button' -import { useBubbleMenuFloating } from './use-bubble-menu-floating' +import { BUBBLE_MENU_CLASS } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu-chrome' +import { + ToolbarButton, + ToolbarDivider, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button' +import { useBubbleMenuFloating } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating' +import { useEditorToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-editor-toolbar' interface TableBubbleMenuProps { editor: Editor @@ -28,9 +23,8 @@ const shouldShowTableMenu = ({ editor }: { editor: Editor }) => /** * Floating toolbar shown whenever the selection is inside a table: row/column insert-before/after, - * row/column delete, header-row toggle, and delete-table. `@tiptap/extension-table` already exposes - * all of these as editor commands (`addRowBefore`, `addColumnAfter`, …) — this is UI only, no schema - * or serializer change. + * row/column delete, and delete-table. The fixed header row is required by the Markdown storage + * format; capability checks omit operations that would remove it or insert a body row before it. */ export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuProps) { const [menuKey] = useState(() => new PluginKey('markdownTableMenu')) @@ -38,11 +32,26 @@ export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuP const active = useEditorState({ editor, selector: ({ editor: e }) => ({ - headerRow: e.isActive('tableHeader'), + editable: e.isEditable, + addRowBefore: e.can().addRowBefore(), + deleteRow: e.can().deleteRow(), }), }) const { resolveAnchor, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef) + const canFocus = useCallback( + () => + editor.isActive('table') && + editor.state.doc + .textBetween(editor.state.selection.from, editor.state.selection.to, ' ') + .trim().length === 0, + [editor] + ) + const toolbar = useEditorToolbar({ + editor, + pluginKey: menuKey, + canFocus, + }) return ( ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.test.tsx new file mode 100644 index 00000000000..e6bf381b8e8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.test.tsx @@ -0,0 +1,47 @@ +/** @vitest-environment jsdom */ +import { act, type ReactNode } from 'react' +import { Tooltip } from '@sim/emcn' +import { Blimp, Bold } from '@sim/emcn/icons' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ToolbarButton } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button' + +describe('ToolbarButton', () => { + const rendered: Array<{ host: HTMLDivElement; root: Root }> = [] + + afterEach(() => { + for (const { host, root } of rendered) { + act(() => root.unmount()) + host.remove() + } + rendered.length = 0 + }) + + function renderButton(button: ReactNode): HTMLDivElement { + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + act(() => root.render({button})) + rendered.push({ host, root }) + return host + } + + it('uses the canonical active button treatment for selected formatting', () => { + const host = renderButton() + const button = host.querySelector('button[aria-label="Bold"]') + + expect(button?.className).toContain('bg-[var(--surface-5)]') + expect(button?.className).toContain('border-[var(--border-1)]') + expect(button?.className).toContain('text-[var(--text-primary)]') + }) + + it('supports a compact glyph without reducing the button hit target', () => { + const host = renderButton( + + ) + + const button = host.querySelector('button[aria-label="Add to Chat"]') + expect(button?.className).toContain('size-[28px]') + expect(button?.querySelector('svg')?.className.baseVal).toContain('size-[12px]') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx index 013c980bd5c..3436913633f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx @@ -1,41 +1,47 @@ import type { ComponentType, SVGProps } from 'react' -import { cn, Tooltip } from '@sim/emcn' +import { Button, cn, Tooltip } from '@sim/emcn' interface ToolbarButtonProps { /** Any SVG icon component, e.g. from `@sim/emcn/icons`. */ icon: ComponentType> + /** Reduces optically dense filled glyphs while keeping the standard button hit target. */ + iconSize?: 'default' | 'compact' label: string shortcut?: string isActive?: boolean + disabled?: boolean onClick: () => void } /** A single icon button for the editor's floating toolbars (bubble menu, link hover card). */ export function ToolbarButton({ icon: Icon, + iconSize = 'default', label, shortcut, - isActive = false, + isActive, + disabled, onClick, }: ToolbarButtonProps) { return ( - + + {shortcut ? {label} : label} @@ -46,5 +52,5 @@ export function ToolbarButton({ /** Thin vertical separator between groups of {@link ToolbarButton}s. */ export function ToolbarDivider() { - return
    + return
    } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-editor-toolbar.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-editor-toolbar.test.tsx new file mode 100644 index 00000000000..202c729973b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-editor-toolbar.test.tsx @@ -0,0 +1,123 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { Tooltip } from '@sim/emcn' +import { Bold, Check } from '@sim/emcn/icons' +import { Editor } from '@tiptap/core' +import { PluginKey } from '@tiptap/pm/state' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { ToolbarButton } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button' +import { useEditorToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-editor-toolbar' + +let editor: Editor +let root: Root +let host: HTMLDivElement +let editorHost: HTMLDivElement +const pluginKey = new PluginKey('testToolbar') +const action = vi.fn() + +function Probe({ disabled = false, input = false }: { disabled?: boolean; input?: boolean }) { + const toolbar = useEditorToolbar({ editor, pluginKey, canFocus: () => true }) + return ( + +
    + + + {input && } +
    +
    + ) +} + +function buttons() { + return Array.from(host.querySelectorAll('button')) +} +function key(target: HTMLElement, key: string, extra: KeyboardEventInit = {}) { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...extra }) + act(() => target.dispatchEvent(event)) + return event +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + host = document.createElement('div') + editorHost = document.createElement('div') + document.body.append(editorHost, host) + editor = new Editor({ + element: editorHost, + extensions: createMarkdownContentExtensions(), + content: '

    text

    ', + editorProps: { handleScrollToSelection: () => true }, + }) + root = createRoot(host) + act(() => root.render()) +}) +afterEach(() => { + act(() => root.unmount()) + editor.destroy() + host.remove() + editorHost.remove() + vi.useRealTimers() +}) + +describe('editor toolbar keyboard interaction', () => { + it('has one tab stop, arrow navigation, wraparound, Home and End', () => { + const [first, second] = buttons() + expect(buttons().map((button) => button.tabIndex)).toEqual([0, -1]) + act(() => first!.focus()) + key(first!, 'ArrowRight') + expect(document.activeElement).toBe(second) + expect(buttons().map((button) => button.tabIndex)).toEqual([-1, 0]) + key(second!, 'ArrowRight') + expect(document.activeElement).toBe(first) + key(first!, 'End') + expect(document.activeElement).toBe(second) + key(second!, 'Home') + expect(document.activeElement).toBe(first) + }) + + it('moves the entry stop when its former button becomes disabled', () => { + act(() => root.render()) + expect(buttons()[1]!.tabIndex).toBe(0) + expect(buttons()[0]!.disabled).toBe(true) + expect(buttons()[0]!.getAttribute('aria-pressed')).toBe('false') + expect(buttons()[1]!.hasAttribute('aria-pressed')).toBe(false) + }) + + it('preserves modified and composing navigation and native URL editing', () => { + act(() => root.render()) + const first = buttons()[0]! + act(() => first.focus()) + expect(key(first, 'ArrowRight', { ctrlKey: true }).defaultPrevented).toBe(false) + expect(key(first, 'ArrowRight', { isComposing: true }).defaultPrevented).toBe(false) + expect(document.activeElement).toBe(first) + const input = host.querySelector('input')! + act(() => input.focus()) + expect(key(input, 'ArrowLeft').defaultPrevented).toBe(false) + expect(key(input, 'Home').defaultPrevented).toBe(false) + expect(document.activeElement).toBe(input) + }) + + it('enters with Alt+F10 and returns to the document with Escape', async () => { + expect(key(editor.view.dom, 'F10', { altKey: true }).defaultPrevented).toBe(true) + await act(async () => vi.advanceTimersToNextFrame()) + expect(document.activeElement).toBe(buttons()[0]) + key(buttons()[0]!, 'Escape') + await act(async () => vi.advanceTimersToNextFrame()) + expect(document.activeElement).toBe(editor.view.dom) + }) + + it('does not enter a toolbar when the document is read-only', () => { + editor.setEditable(false) + expect(key(editor.view.dom, 'F10', { altKey: true }).defaultPrevented).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-editor-toolbar.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-editor-toolbar.ts new file mode 100644 index 00000000000..da99cbb72b3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-editor-toolbar.ts @@ -0,0 +1,137 @@ +import { + type FocusEvent, + type KeyboardEvent, + type MouseEvent, + useEffect, + useLayoutEffect, + useRef, +} from 'react' +import type { PluginKey } from '@tiptap/pm/state' +import type { Editor } from '@tiptap/react' + +interface EditorToolbarOptions { + editor: Editor + pluginKey: PluginKey + canFocus: () => boolean + /** URL editing uses ordinary form tab order so its native arrow keys do not trap action buttons. */ + roving?: boolean +} + +function controls(toolbar: HTMLElement): HTMLElement[] { + return Array.from( + toolbar.querySelectorAll('button:not(:disabled), input:not(:disabled)') + ) +} + +function makeTabStop(toolbar: HTMLElement, target: HTMLElement): void { + for (const control of controls(toolbar)) control.tabIndex = control === target ? 0 : -1 +} + +/** Shared roving focus for the editor's context toolbars; text inputs retain their native editing keys. */ +export function useEditorToolbar({ + editor, + pluginKey, + canFocus, + roving = true, +}: EditorToolbarOptions) { + const ref = useRef(null) + + useLayoutEffect(() => { + const toolbar = ref.current + if (!toolbar) return + const items = controls(toolbar) + if (!roving) { + for (const item of items) item.tabIndex = 0 + return + } + const target = + items.find((item) => item === document.activeElement) ?? + items.find((item) => item.tabIndex === 0) ?? + items[0] + if (target) makeTabStop(toolbar, target) + }) + + useEffect(() => { + let frame: number | undefined + const enterToolbar = (event: globalThis.KeyboardEvent) => { + if ( + event.key !== 'F10' || + !event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.isComposing || + !editor.isEditable || + !canFocus() + ) + return + event.preventDefault() + editor.commands.setMeta(pluginKey, 'show') + editor.commands.setMeta(pluginKey, 'updatePosition') + frame = requestAnimationFrame(() => { + const toolbar = ref.current + if (!toolbar || editor.isDestroyed || !editor.isEditable || !canFocus()) return + const items = controls(toolbar) + const target = items.find((item) => item.tabIndex === 0) ?? items[0] + target?.focus() + }) + } + const dom = editor.view.dom + dom.addEventListener('keydown', enterToolbar) + return () => { + dom.removeEventListener('keydown', enterToolbar) + if (frame !== undefined) cancelAnimationFrame(frame) + } + }, [editor, pluginKey, canFocus]) + + const onFocusCapture = (event: FocusEvent) => { + if (!roving) return + if ( + event.target instanceof HTMLElement && + controls(event.currentTarget).includes(event.target) + ) { + makeTabStop(event.currentTarget, event.target) + } + } + + /** A permission/stream lock can precede React's render of the hidden toolbar. */ + const onClickCapture = (event: MouseEvent) => { + if (editor.isDestroyed || !editor.isEditable) { + event.preventDefault() + event.stopPropagation() + } + } + + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.nativeEvent.isComposing || + event.nativeEvent.keyCode === 229 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) + return + if (event.key === 'Escape') { + event.preventDefault() + editor.commands.focus() + editor.commands.setMeta(pluginKey, 'hide') + return + } + if (!roving || event.target instanceof HTMLInputElement) return + const items = controls(event.currentTarget) + const index = items.indexOf(event.target as HTMLElement) + if (index < 0 || items.length === 0) return + const direction = getComputedStyle(event.currentTarget).direction === 'rtl' ? -1 : 1 + let next: number + if (event.key === 'Home') next = 0 + else if (event.key === 'End') next = items.length - 1 + else if (event.key === 'ArrowRight') next = (index + direction + items.length) % items.length + else if (event.key === 'ArrowLeft') next = (index - direction + items.length) % items.length + else return + event.preventDefault() + items[next]?.focus() + } + + return { ref, onFocusCapture, onClickCapture, onKeyDown, 'aria-keyshortcuts': 'Alt+F10' } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.test.tsx new file mode 100644 index 00000000000..ee55022ee7e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.test.tsx @@ -0,0 +1,87 @@ +/** @vitest-environment jsdom */ +import { act, Suspense, startTransition, useLayoutEffect, useRef } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSuggestionKeyboard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard' + +describe('suggestion keyboard committed state', () => { + let host: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.unstubAllGlobals() + }) + + it.each([ + ['items', 'Enter'], + ['items', 'Tab'], + ['callback', 'Enter'], + ['callback', 'Tab'], + ['activeIndex', 'Enter'], + ['activeIndex', 'Tab'], + ] as const)('uses committed %s when %s follows a suspended render', async (change, key) => { + const items = ['Visible first', 'Visible second'] + const suspendedItems = ['Uncommitted first', 'Uncommitted second'] + const onSelect = vi.fn() + const suspendedOnSelect = vi.fn() + const pending = new Promise(() => {}) + let attemptedSuspension = false + let committed!: ReturnType> + + interface ProbeProps { + suspended: boolean + } + + function Probe({ suspended }: ProbeProps) { + const containerRef = useRef(null) + const keyboard = useSuggestionKeyboard( + suspended && change === 'items' ? suspendedItems : items, + suspended && change === 'callback' ? suspendedOnSelect : onSelect, + containerRef + ) + useLayoutEffect(() => { + committed = keyboard + }) + if (suspended) { + attemptedSuspension = true + throw pending + } + return
    {items[keyboard.activeIndex]}
    + } + + const view = (suspended: boolean) => ( + Loading
    }> + + + ) + + await act(async () => root.render(view(false))) + const capturedHandler = committed.onKeyDown + await act(async () => committed.setActiveIndex(1)) + expect(committed.onKeyDown).toBe(capturedHandler) + + await act(async () => { + startTransition(() => { + if (change === 'activeIndex') committed.setActiveIndex(0) + root.render(view(true)) + }) + }) + expect(attemptedSuspension).toBe(true) + expect(host.textContent).toBe('Visible second') + expect(committed.onKeyDown).toBe(capturedHandler) + act(() => { + expect(capturedHandler({ event: new KeyboardEvent('keydown', { key }) })).toBe(true) + }) + expect(onSelect).toHaveBeenCalledExactlyOnceWith('Visible second') + expect(suspendedOnSelect).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts index ad283545d13..a7e26912db1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts @@ -4,6 +4,7 @@ import { type SetStateAction, useCallback, useEffect, + useLayoutEffect, useRef, useState, } from 'react' @@ -47,9 +48,21 @@ export function useSuggestionKeyboard( }, [activeIndex, containerRef]) const latest = useRef({ items, activeIndex, onSelect }) - latest.current = { items, activeIndex, onSelect } + useLayoutEffect(() => { + latest.current = { items, activeIndex, onSelect } + }) const onKeyDown = useCallback(({ event }: { event: KeyboardEvent }) => { + if ( + event.isComposing || + event.keyCode === 229 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) { + return false + } const { items, activeIndex, onSelect } = latest.current if (items.length === 0) return false if (event.key === 'ArrowUp') { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list.test.ts new file mode 100644 index 00000000000..ef86feea046 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list.test.ts @@ -0,0 +1,222 @@ +/** @vitest-environment jsdom */ +import { Editor, type JSONContent } from '@tiptap/core' +import { OrderedList } from '@tiptap/extension-list' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { + editorNormalForm, + parseMarkdownToDoc, + serializeMarkdownDocument, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +import { isRoundTripSafe } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety' + +const editors: Editor[] = [] + +afterEach(() => { + editors.splice(0).forEach((editor) => editor.destroy()) +}) + +function mount(content: string | JSONContent = '

    ', stock = false): Editor { + const extensions = createMarkdownContentExtensions().map((extension) => + stock && extension.name === 'orderedList' ? OrderedList : extension + ) + const editor = new Editor({ extensions, content }) + editors.push(editor) + return editor +} + +function starts(doc: JSONContent): number[] { + const result: number[] = [] + const visit = (node: JSONContent): void => { + if (node.type === 'orderedList') result.push(node.attrs?.start ?? 1) + node.content?.forEach(visit) + } + visit(doc) + return result +} + +const LIST_RUN = + '
    1. first

    1. restart

    1. another

    ' + +describe('ordered-list Markdown boundaries', () => { + it.each([0, 9, 10, 123])('retains start %s and following item numbers', (start) => { + const editor = mount(`
    1. one

    2. two

    `) + expect(editor.getMarkdown()).toContain(`${start}. one\n${start + 1}. two`) + editor.commands.setContent(editorNormalForm(editor.getMarkdown())) + expect(starts(editor.getJSON())).toEqual([start]) + }) + + it.each(['a', 'A', 'i', 'I'])('retains stock %s list rendering and parsing', (type) => { + const html = `
    1. one

    2. two

    ` + const editor = mount(html) + const original = mount(html, true) + expect(editor.getMarkdown()).toBe(original.getMarkdown()) + const markdown = original.getMarkdown() + editor.commands.setContent(markdown, { contentType: 'markdown' }) + original.commands.setContent(markdown, { contentType: 'markdown' }) + expect(editor.getJSON()).toEqual(original.getJSON()) + }) + + it.each([ + ['top-level', LIST_RUN], + ['nested', `
    • parent

      ${LIST_RUN}
    `], + ['blockquote', `
    ${LIST_RUN}
    `], + ])('preserves three adjacent %s lists across repeated saves', (_name, html) => { + const editor = mount(html) + editor.commands.setContent(editor.getJSON()) + const original = editor.getJSON() + const markdown = editor.getMarkdown() + expect(starts(original)).toEqual([1, 7, 3]) + expect(markdown).toContain('7) restart') + + for (let cycle = 0; cycle < 3; cycle++) { + editor.commands.setContent(editorNormalForm(editor.getMarkdown())) + expect(editor.getJSON()).toEqual(original) + expect(editor.getMarkdown()).toBe(markdown) + } + }) + + it.each([ + '1. first\n\n7) restart\n\n3. another', + '- parent\n 1. first\n 7) restart\n 3. another', + '1. outer\n 4. child\n 7) child restart\n2. other', + '> 1. first\n>\n> 7) restart\n>\n> 3. another', + ])('preserves delimiter boundaries on first parse: %s', (markdown) => { + const editor = mount() + editor.commands.setContent(markdown, { contentType: 'markdown' }) + const original = editor.getJSON() + const expected = markdown.startsWith('1. outer') ? [1, 4, 7] : [1, 7, 3] + expect(starts(original)).toEqual(expected) + expect(starts(parseMarkdownToDoc(markdown))).toEqual(expected) + expect(starts(parseMarkdownToDoc(`${markdown}\n\n`))).toEqual(expected) + expect(serializeMarkdownDocument(serializeMarkdownDocument(markdown))).toBe( + serializeMarkdownDocument(markdown) + ) + }) + + it.each([ + '1. First\n - sub bullet\n - another\n 1. deep ordered\n 2. item\n2. Second', + '1. first\n\n second paragraph in item one\n\n2. second item', + '1. outer\n 1. child\n 1. grandchild\n 2. another child\n2. other', + '4. outer\n a. alphabetic child\n b. another child\n5. other', + ])('retains stock first-parse semantics for legacy indentation: %s', (markdown) => { + const editor = mount() + const original = mount('

    ', true) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + original.commands.setContent(markdown, { contentType: 'markdown' }) + expect(editor.getJSON()).toEqual(original.getJSON()) + }) + + it('preserves an ordered restart when references require whole-document parsing', () => { + const markdown = + '1. [first](https://example.com)\n\n7) restart\n\nOutside [reference][ref].\n\n[ref]: https://example.com' + const original = parseMarkdownToDoc(markdown) + expect(starts(original)).toEqual([1, 7]) + const editor = mount(editorNormalForm(markdown)) + const normalized = editor.getJSON() + editor.commands.setContent(editorNormalForm(editor.getMarkdown())) + expect(editor.getJSON()).toEqual(normalized) + expect(editor.getMarkdown()).toContain('[first](https://example.com)') + expect(editor.getMarkdown()).toContain('[reference](https://example.com)') + }) + + it.each([ + '1. [first][ref]', + '1. [ref]', + '1. [ref][]', + '1. **[first][ref]**', + '1. outer\n 1. [first][ref]', + '1. outer\n\n [first][ref]', + '1. first\n\n7) [first][ref]', + 'a. [first][ref]', + 'iii. [first][ref]', + ])('resolves ordered reference links with whole-document context: %s', (body) => { + const markdown = `${body}\n\n[ref]: https://example.com "Title"` + const editor = mount(editorNormalForm(markdown)) + const original = editor.getJSON() + let linkCount = 0 + editor.state.doc.descendants((node) => { + linkCount += node.marks.filter( + (mark) => mark.type.name === 'link' && mark.attrs.href === 'https://example.com' + ).length + }) + expect(linkCount).toBe(1) + expect(editor.getMarkdown()).toContain('(https://example.com "Title")') + editor.commands.setContent(editorNormalForm(editor.getMarkdown())) + expect(editor.getJSON()).toEqual(original) + expect(isRoundTripSafe(markdown)).toBe(true) + }) + + it('resolves reference images without changing escaped or code reference syntax', () => { + const markdown = + '1. ![photo][img] and \\[ref] and `[ref]`\n\nOutside [ref].\n\n[img]: https://example.com/photo.png\n[ref]: https://example.com' + const editor = mount(editorNormalForm(markdown)) + const original = editor.getJSON() + let imageSource: string | undefined + editor.state.doc.descendants((node) => { + if (node.type.name === 'image') imageSource = node.attrs.src + }) + expect(imageSource).toBe('https://example.com/photo.png') + expect(editor.getMarkdown()).toContain('\\[ref\\] and `[ref]`') + editor.commands.setContent(editorNormalForm(editor.getMarkdown())) + expect(editor.getJSON()).toEqual(original) + expect(isRoundTripSafe(markdown)).toBe(true) + }) + + it.each([ + ['blockquote', '1. outer\n\n > [first][ref]', true], + ['table', '1. outer\n\n | Header |\n | --- |\n | [first][ref] |', false], + ] as const)( + 'preserves nested %s references and verifies edit eligibility', + (type, body, editable) => { + const markdown = `${body}\n\n[ref]: https://example.com "Title"` + const editor = mount(editorNormalForm(markdown)) + const original = editor.getJSON() + let found = false + let linkCount = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === type) found = true + linkCount += node.marks.filter( + (mark) => mark.type.name === 'link' && mark.attrs.href === 'https://example.com' + ).length + }) + expect(found).toBe(true) + expect(linkCount).toBe(1) + expect(isRoundTripSafe(markdown)).toBe(editable) + if (!editable) { + const once = serializeMarkdownDocument(markdown) + expect(serializeMarkdownDocument(once)).not.toBe(once) + return + } + editor.commands.setContent(editorNormalForm(editor.getMarkdown())) + expect(editor.getJSON()).toEqual(original) + } + ) + + it('changes only item prefixes, not marker-shaped text inside multiline code', () => { + const editor = mount({ + type: 'doc', + content: [ + { type: 'orderedList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] }, + { + type: 'orderedList', + attrs: { start: 7 }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'prefix\n9. literal', marks: [{ type: 'code' }] }, + ], + }, + ], + }, + ], + }, + ], + }) + expect(editor.getMarkdown()).toContain('7) `prefix\n9. literal`') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list.ts new file mode 100644 index 00000000000..519eff78b1b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list.ts @@ -0,0 +1,85 @@ +import type { JSONContent, MarkdownToken } from '@tiptap/core' +import { OrderedList } from '@tiptap/extension-list' +import { + joinListInputRules, + orderedListContinues, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules' +import { excludeTableBlockInputRules } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/table' + +/** + * CommonMark distinguishes adjacent ordered lists by their delimiter, not their starting number. + * Alternate delimiters within a sibling run to preserve explicit restarts without adding nodes. + * The weak map carries only renderer context between sibling callbacks; nothing enters the schema + * or persisted attributes, and repeated serialization recomputes each delimiter in document order. + */ +export function createJoiningOrderedList() { + const delimiters = new WeakMap() + const parse = OrderedList.config.parseMarkdown + + return OrderedList.extend({ + addInputRules() { + return excludeTableBlockInputRules( + joinListInputRules(this.parent?.() ?? [], this.type, { compatible: orderedListContinues }) + ) + }, + parseMarkdown: (token, helpers) => { + if (!token.ordered || !token.items?.length) return parse?.(token, helpers) ?? [] + + /** + * The upstream tokenizer retains each item's raw marker but ignores delimiter changes. + * Split its parsed item tokens, retaining its legacy indentation and nested-block behavior. + * Nested list tokens pass through this same parser via the stock list-item parser. + */ + const groups: MarkdownToken[] = [] + let delimiter: string | undefined + for (const item of token.items) { + const marker = /^[ \t]*(\d+)([.)])(?:[ \t]|$)/.exec(item.raw ?? '') + if (!marker) { + groups.length = 0 + break + } + if (marker[2] !== delimiter) { + groups.push({ ...token, start: Number(marker[1]), items: [] }) + delimiter = marker[2] + } + groups[groups.length - 1].items?.push(item) + } + if (groups.length > 1) return helpers.parseChildren(groups) + + /** + * The custom tokenizer resolves item paragraphs before later reference definitions exist. + * At parse time the public helper uses the complete document's reference context. + */ + const resolved = { + ...token, + items: token.items.map((item) => ({ + ...item, + tokens: item.tokens?.map((child) => + child.type === 'paragraph' && child.raw?.includes('[') && helpers.tokenizeInline + ? { ...child, tokens: helpers.tokenizeInline(child.raw) } + : child + ), + })), + } + const parsed = parse?.(resolved, helpers) ?? [] + /** The stock parser's truthy default turns a valid zero start into one. */ + if (groups[0]?.start === 0 && !Array.isArray(parsed) && 'type' in parsed) { + return { ...parsed, attrs: { ...parsed.attrs, start: 0 } } + } + return parsed + }, + renderMarkdown: (node: JSONContent, helpers, context) => { + const previous = context.previousNode + const delimiter = + previous?.type === 'orderedList' && delimiters.get(previous) === '.' ? ')' : '.' + delimiters.set(node, delimiter) + const start = typeof node.attrs?.start === 'number' ? node.attrs.start : 1 + return (node.content ?? []) + .map((item, index) => { + const rendered = helpers.renderChild?.(item, index) ?? helpers.renderChildren([item]) + return rendered.replace(/^\d+\. /, `${start + index}${delimiter} `) + }) + .join('\n') + }, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts index a7b73083704..f0360df8663 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts @@ -1,10 +1,13 @@ /** * @vitest-environment jsdom */ + +import { PASTE_LIMITS, PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste' import { Editor } from '@tiptap/core' import { TextSelection } from '@tiptap/pm/state' import { afterEach, describe, expect, it, vi } from 'vitest' import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { MarkdownPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste' import { assessRawMarkdownPaste, createRichMarkdownPasteAdmission, @@ -39,7 +42,123 @@ function runPaste(ed: Editor, text: string, html = ''): { handled: boolean; prev return { handled: false, prevented } } +function dispatchPaste(ed: Editor, text: string) { + const event = new Event('paste', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'clipboardData', { + value: { + getData: (type: string) => (type === 'text/plain' ? text : ''), + files: [], + items: [], + }, + }) + ed.view.dom.dispatchEvent(event) +} + describe('rich Markdown paste admission', () => { + it('retains accepted literal text when appended autolink would exceed the limit', () => { + const onRejected = vi.fn() + editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions(), + MarkdownPaste, + createRichMarkdownPasteAdmission({ + maxResultBytes: 30, + maxResultCharacters: 30, + getCurrentText: () => editor?.getMarkdown() ?? '', + onRejected, + }), + ], + enablePasteRules: false, + }) + dispatchPaste(editor, 'www.example.com ') + expect(editor.getMarkdown()).toBe('www.example.com ') + expect(editor.state.doc.firstChild?.firstChild?.marks).toEqual([]) + expect(onRejected).toHaveBeenCalledExactlyOnceWith('formatting') + + editor.view.dispatch(editor.state.tr.insertText('x'.repeat(31), 1)) + expect(editor.state.doc.textContent).toContain('x'.repeat(31)) + expect(onRejected).toHaveBeenCalledTimes(1) + }) + + it('clears a rejected root paste before the next synchronous ordinary transaction', () => { + const onRejected = vi.fn() + editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions(), + createRichMarkdownPasteAdmission({ + maxResultBytes: 10, + getCurrentText: () => '', + onRejected, + }), + ], + }) + editor.view.dispatch(editor.state.tr.insertText('x'.repeat(11)).setMeta('uiEvent', 'paste')) + expect(editor.state.doc.textContent).toBe('') + expect(onRejected).toHaveBeenCalledExactlyOnceWith('paste') + editor.view.dispatch(editor.state.tr.insertText('ordinary typing')) + expect(editor.state.doc.textContent).toBe('ordinary typing') + expect(onRejected).toHaveBeenCalledTimes(1) + }) + + it('admits autolink formatting when the final result still fits', () => { + const onRejected = vi.fn() + editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions(), + MarkdownPaste, + createRichMarkdownPasteAdmission({ + maxResultBytes: 100, + maxResultCharacters: 100, + getCurrentText: () => '', + onRejected, + }), + ], + enablePasteRules: false, + }) + dispatchPaste(editor, 'https://example.com ') + expect(editor.getMarkdown()).toBe('[https://example.com](https://example.com) ') + expect(onRejected).not.toHaveBeenCalled() + }) + + it('rejects a canonical result that would reopen beyond the rich-editor character limit', () => { + const onRejected = vi.fn() + editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions(), + createRichMarkdownPasteAdmission({ + maxResultBytes: PASTE_LIMITS.RICH_MARKDOWN_BYTES, + getCurrentText: () => '', + onRejected, + }), + ], + }) + editor.view.dispatch( + editor.state.tr + .insertText('a'.repeat(PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS + 1)) + .setMeta('uiEvent', 'paste') + ) + expect(editor.state.doc.textContent).toBe('') + expect(onRejected).toHaveBeenCalledOnce() + }) + + it('includes preserved frontmatter in the projected character budget', () => { + const onRejected = vi.fn() + editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions(), + createRichMarkdownPasteAdmission({ + maxResultBytes: 100, + maxResultCharacters: 10, + getCurrentText: () => '', + getFrontmatter: () => '---\nx\n---\n', + onRejected, + }), + ], + }) + editor.view.dispatch(editor.state.tr.insertText('body').setMeta('uiEvent', 'paste')) + expect(editor.state.doc.textContent).toBe('') + expect(onRejected).toHaveBeenCalledOnce() + }) it('rejects a raw-text append whose projected result exceeds the limit', () => { expect( assessRawMarkdownPaste( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts index 106dce3a6b8..f3426c5693c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts @@ -1,17 +1,20 @@ import { assessTextPaste, PASTE_LIMITS, + PASTE_RENDER_THRESHOLDS, type TextPasteAdmission, utf8ByteLength, } from '@sim/utils/paste' import { Extension } from '@tiptap/core' -import { Plugin } from '@tiptap/pm/state' +import { Plugin, type Transaction } from '@tiptap/pm/state' import { postProcessSerializedMarkdown } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' export interface RichMarkdownPasteAdmissionOptions { maxResultBytes: number + maxResultCharacters?: number getCurrentText: () => string - onRejected: () => void + getFrontmatter?: () => string + onRejected: (reason?: 'paste' | 'formatting') => void } interface RawMarkdownPasteInput { @@ -37,7 +40,9 @@ export function assessRawMarkdownPaste( */ export function createRichMarkdownPasteAdmission({ maxResultBytes, + maxResultCharacters = PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS, getCurrentText, + getFrontmatter = () => '', onRejected, }: RichMarkdownPasteAdmissionOptions): Extension { return Extension.create({ @@ -47,13 +52,27 @@ export function createRichMarkdownPasteAdmission({ addProseMirrorPlugins() { const { editor } = this let pasteInProgress = false + let pasteRoot: Transaction | null = null + let formattingRejected = false + + const finishPaste = () => { + pasteInProgress = false + pasteRoot = null + formattingRejected = false + } return [ new Plugin({ + /** View updates occur after the complete synchronous transaction/append pipeline. */ + view: () => ({ update: finishPaste, destroy: finishPaste }), filterTransaction: (transaction) => { const isPaste = pasteInProgress || transaction.getMeta('uiEvent') === 'paste' if (!isPaste || !transaction.docChanged) return true - pasteInProgress = false + if (!pasteRoot) { + pasteRoot = transaction + pasteInProgress = true + queueMicrotask(finishPaste) + } if (!editor.markdown) { throw new Error('Rich Markdown paste admission requires the Markdown extension') @@ -61,9 +80,22 @@ export function createRichMarkdownPasteAdmission({ const projectedMarkdown = postProcessSerializedMarkdown( editor.markdown.serialize(transaction.doc.toJSON()) ) - if (utf8ByteLength(projectedMarkdown, maxResultBytes) <= maxResultBytes) return true + const frontmatter = getFrontmatter() + if ( + frontmatter.length + projectedMarkdown.length <= maxResultCharacters && + utf8ByteLength(frontmatter, maxResultBytes) + + utf8ByteLength(projectedMarkdown, maxResultBytes) <= + maxResultBytes + ) + return true - onRejected() + if (transaction === pasteRoot) { + finishPaste() + onRejected('paste') + } else if (!formattingRejected) { + formattingRejected = true + onRejected('formatting') + } return false }, props: { @@ -75,7 +107,7 @@ export function createRichMarkdownPasteAdmission({ if (pastedHtml && utf8ByteLength(pastedHtml, maxResultBytes) > maxResultBytes) { event.preventDefault() - onRejected() + onRejected('paste') return true } @@ -97,16 +129,14 @@ export function createRichMarkdownPasteAdmission({ const projectedBytes = Math.max(0, currentBytes - replacedBytes) + pastedBytes if (projectedBytes > maxResultBytes) { event.preventDefault() - onRejected() + onRejected('paste') return true } } } pasteInProgress = true - queueMicrotask(() => { - pasteInProgress = false - }) + queueMicrotask(finishPaste) return false }, }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index daf7eb5cb29..55384a26ff1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -1,10 +1,10 @@ 'use client' -import { memo, useCallback, useEffect, useRef, useState } from 'react' -import { cn, toast } from '@sim/emcn' +import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Chip, cn, toast } from '@sim/emcn' import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' -import { formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' -import type { Extensions, JSONContent } from '@tiptap/core' +import { PASTE_LIMITS, PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste' +import type { Extensions, JSONContent, Range } from '@tiptap/core' import { isChangeOrigin } from '@tiptap/extension-collaboration' import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' @@ -17,50 +17,64 @@ import { import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef, extractImgSrcs } from '@/lib/uploads/utils/embedded-image-ref' import { FindBar } from '@/app/workspace/[workspaceId]/components' -import { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title' -import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' -import { useAddToChat } from '@/hooks/use-add-to-chat' -import type { SaveStatus } from '@/hooks/use-autosave' -import { useFileContentSource } from '@/hooks/use-file-content-source' -import type { ChatContext } from '@/stores/panel' -import { PreviewLoadingFrame } from '../preview-shared' -import { useEditableFileContent } from '../use-editable-file-content' -import { useSelectionCopyBridge } from '../use-selection-copy-bridge' +import { FileSaveConflict } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict' +import { PreviewLoadingFrame } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared' import { announceAgentApplying, clearAgentApplying, isAgentStreamLeader, -} from './collaboration/agent-stream-leader' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/agent-stream-leader' import { type AgentStreamSession, applyAgentStreamFrame, beginAgentStream, endAgentStream, -} from './collaboration/apply-streamed-markdown' -import { nextCollabReadiness } from './collaboration/readiness' -import { useFileDocCollaboration } from './collaboration/use-file-doc-collaboration' -import { createMarkdownEditorExtensions } from './editor-extensions' -import { useMarkdownFind } from './find' -import { findHeadingPos } from './heading-anchors' -import { moveDraggedImageNode } from './image-drag-move' -import { extractImageFiles, findHostedImageAttrs, shouldSkipFileUpload } from './image-paste' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown' +import { isCollabReady } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness' +import { useFileDocCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration' +import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' +import { useMarkdownFind } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find' +import { findHeadingPos } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors' +import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move' +import { + extractImageFiles, + findHostedImageAttrs, + shouldSkipFileUpload, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste' +import { + beginImageUploads, + findImageUpload, + findImageUploadRange, + finishImageUpload, + removeImageUpload, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload' import { applyFrontmatter, normalizeLinkHref, postProcessSerializedMarkdown, splitFrontmatter, -} from './markdown-fidelity' -import { parseMarkdownToDoc } from './markdown-parse' -import { useEditorMentions } from './mention' -import { EditorBubbleMenu } from './menus/bubble-menu' -import { LinkHoverCard } from './menus/link-hover-card' -import { TableBubbleMenu } from './menus/table-menu' -import { normalizeMarkdownContent } from './normalize-content' -import { isRoundTripSafe } from './round-trip-safety' -import { firstHeadingTitle } from './title-heading' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +import { isPlainTextPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste' +import { useEditorMentions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention' +import { EditorBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu' +import { LinkHoverCard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card' +import { TableBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu' +import { normalizeMarkdownContent } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content' +import { isRoundTripSafe } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety' +import { firstHeadingTitle } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading' +import { TextEditor } from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor' +import { useEditableFileContent } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content' +import { useSelectionCopyBridge } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge' +import { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title' +import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' +import { useAddToChat } from '@/hooks/use-add-to-chat' +import type { SaveStatus } from '@/hooks/use-autosave' +import { useFileContentSource } from '@/hooks/use-file-content-source' +import type { ChatContext } from '@/stores/panel' import '@sim/emcn/components/code/code.css' -import '../document-table.css' -import './rich-markdown-editor.css' +import '@/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css' +import '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css' const PLACEHOLDER = "Write something, or press '/' for commands…" @@ -76,9 +90,15 @@ const STREAM_REPARSE_THROTTLE_MS = 120 /** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */ const DERIVE_TITLE_DEBOUNCE_MS = 600 -function warnRichMarkdownPasteLimit() { +function warnRichMarkdownPasteLimit(reason?: 'paste' | 'formatting') { + if (reason === 'formatting') { + toast.warning('Pasted text kept without automatic formatting', { + description: 'Adding that formatting would exceed the rich-text editing limit.', + }) + return + } toast.warning('Paste is too large for rich-text editing', { - description: `Keep this document under ${formatPasteLimit(PASTE_LIMITS.RICH_MARKDOWN_BYTES)}, or import the content as a file and open it read-only.`, + description: `Rich-text editing supports up to ${PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS.toLocaleString()} characters. Use the source editor for larger documents.`, }) } @@ -115,7 +135,15 @@ function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) { immediatelyRender: true, shouldRerenderOnTransaction: false, content, - editorProps: { attributes: { class: 'rich-markdown-nodes rich-markdown-prose' } }, + editorProps: { + attributes: { + class: 'rich-markdown-nodes rich-markdown-prose', + 'aria-label': 'Document preview', + role: 'textbox', + 'aria-multiline': 'true', + 'aria-readonly': 'true', + }, + }, }) return } @@ -169,8 +197,26 @@ interface RichMarkdownEditorProps { enableFind?: boolean } -/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */ -export const RichMarkdownEditor = memo(function RichMarkdownEditor({ +/** Source fallback unmounts the rich surface so only one editing engine owns the local draft. */ +export const RichMarkdownEditor = memo(function RichMarkdownEditor(props: RichMarkdownEditorProps) { + const [sourceFileId, setSourceFileId] = useState(null) + if (sourceFileId === props.file.id) + return ( + + ) + return setSourceFileId(props.file.id)} /> +}) + +interface RichMarkdownSurfaceProps extends RichMarkdownEditorProps { + onEditSource: () => void +} + +/** Inline rich editor; agent output streams read-only before editing becomes available on settle. */ +function RichMarkdownSurface({ file, workspaceId, canEdit, @@ -189,7 +235,8 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ collaborative = false, onDeriveTitleFromHeading, enableFind = false, -}: RichMarkdownEditorProps) { + onEditSource, +}: RichMarkdownSurfaceProps) { const { data: session, isPending: isSessionPending } = useSession() const userId = session?.user?.id ?? '' const userName = session?.user?.name?.trim() || 'Collaborator' @@ -199,13 +246,13 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ * autosaves the markdown). For a collaborative file it stays `false`: the realtime relay persists the * shared document to markdown server-side, so the client must never also autosave — a stale keystroke * saving over a server/copilot edit is exactly the clobber the server path closes. The child reports - * the right value up via `onCollabReadyChange`. + * the right value up via `onClientAutosaveChange`. * * Initialize from the `collaborative` prop (NOT unconditionally `true`): a collaborative file must * start with autosave OFF, or a save could fire in the window before the child mounts and reports — * re-clobbering exactly what this closes. The child turns it on for the non-collaborative fallback. */ - const [collabReady, setCollabReady] = useState(!collaborative) + const [canClientAutosave, setCanClientAutosave] = useState(!collaborative) const { content, @@ -214,6 +261,11 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ isContentLoading, hasContentError, saveImmediately, + hasConflict, + isReloading, + reloadLatestContent, + downloadDraft, + acceptedBaselineContent, } = useEditableFileContent({ file, workspaceId, @@ -225,7 +277,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ saveRef, discardRef, normalizeBaseline: normalizeMarkdownContent, - canAutosave: collabReady, + canAutosave: canClientAutosave, }) // Wait for the session too: the child decides collaboration ONCE at mount from @@ -243,35 +295,48 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ } return ( - + <> + {hasConflict && ( + + )} + + ) -}) +} interface LoadedRichMarkdownEditorProps { file: WorkspaceFileRecord workspaceId: string /** The live content from the engine — grows as the agent streams, then settles to the saved doc. */ content: string + /** Accepted external baseline, excluding local serialization echoes and own save acknowledgements. */ + acceptedBaselineContent?: string /** True while agent output is streaming in: the editor renders it read-only and syncs each chunk. */ isStreaming: boolean canEdit: boolean @@ -289,20 +354,23 @@ interface LoadedRichMarkdownEditorProps { collaborative?: boolean onChange: (markdown: string) => void onSaveShortcut: () => Promise - /** Reports whether the collaborative document is synced+seeded (autosave gate). */ - onCollabReadyChange: (ready: boolean) => void + /** Reports client autosave eligibility; collaborative documents are persisted by the relay. */ + onClientAutosaveChange: (canAutosave: boolean) => void /** See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. */ onDeriveTitleFromHeading?: (headingText: string) => void /** See {@link RichMarkdownEditorProps.enableFind}. */ enableFind: boolean + onEditSource?: () => void } +type CollaborationStatus = 'connecting' | 'ready' | 'reconnecting' | 'fatal' + interface SettledContent { frontmatter: string verdict: boolean } -/** Locks the round-trip verdict + frontmatter once; a round-trip-unsafe doc (raw HTML, footnotes, >256KB) opens read-only. */ +/** Assess an accepted source snapshot before rich editing can change its representation. */ function lockSettled(content: string): SettledContent { return { frontmatter: splitFrontmatter(content).frontmatter, verdict: isRoundTripSafe(content) } } @@ -312,6 +380,7 @@ export function LoadedRichMarkdownEditor({ file, workspaceId, content, + acceptedBaselineContent, isStreaming, canEdit, userId, @@ -324,45 +393,57 @@ export function LoadedRichMarkdownEditor({ collaborative = false, onChange, onSaveShortcut, - onCollabReadyChange, + onClientAutosaveChange, onDeriveTitleFromHeading, enableFind, + onEditSource, }: LoadedRichMarkdownEditorProps) { /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */ - const streamingAtMountRef = useRef(isStreaming) + const [streamingAtMount] = useState(isStreaming) - /** Verdict + frontmatter, locked once (at mount if settled, else on settle); null reads as read-only. */ - const settledRef = useRef(null) - if (!streamingAtMountRef.current && settledRef.current === null) { - settledRef.current = lockSettled(content) - } + /** Only accepted source snapshots and stream settlement change editing eligibility. */ + const [settled, setSettled] = useState(() => + streamingAtMount ? null : lockSettled(content) + ) + const [acceptedBaseline, setAcceptedBaseline] = useState(acceptedBaselineContent) /** * Collaboration is decided once at mount from synchronously-available inputs - * (`settledRef` is set just above) via `useState`-init, and never changes — TipTap + * via `useState`-init, and never changes — TipTap * fixes the extension set at editor creation, so it cannot turn on later. Enabled on a * `collaborative` surface (the Files page or the embedded chat file preview) for an editable, * round-trip-safe workspace document with a known user, as long as it is not ALREADY streaming at - * mount (`!streamingAtMountRef.current`). An agent stream that begins AFTER mount is applied as CRDT + * mount. An agent stream that begins AFTER mount is applied as CRDT * diffs into the live doc, so collaboration and streaming coexist (see the streaming effect below). */ const [collaborationEnabled] = useState( () => collaborative && canEdit && - !streamingAtMountRef.current && - (settledRef.current?.verdict ?? false) && + !streamingAtMount && + (settled?.verdict ?? false) && Boolean(userId) && (file.storageContext ?? 'workspace') === 'workspace' ) + if ( + !collaborationEnabled && + !isStreaming && + acceptedBaselineContent !== undefined && + acceptedBaselineContent !== acceptedBaseline + ) { + setAcceptedBaseline(acceptedBaselineContent) + setSettled(lockSettled(acceptedBaselineContent)) + } /** * Whether the collaborative document is safe to edit + persist: synced and seeded. * Starts `false` for a collaborative document — so the editor is read-only and * autosave gated until the shared content has arrived (a user must not type into an * empty, unsynced doc, which the seed would then discard) — and `true` for a local one. */ - const [collabReady, setCollabReady] = useState(!collaborationEnabled) - const isEditable = - canEdit && !isStreaming && (settledRef.current?.verdict ?? false) && collabReady + const [collabStatus, setCollabStatus] = useState(() => + collaborationEnabled ? 'connecting' : 'ready' + ) + const collabReady = collabStatus === 'ready' + const isEditable = canEdit && !isStreaming && (settled?.verdict ?? false) && collabReady const collaboration = useFileDocCollaboration({ fileId: file.id, @@ -377,7 +458,7 @@ export function LoadedRichMarkdownEditor({ * parsed markdown (chunked parse is linear vs the editor's ~O(n²) whole-body parse). */ const [initialContent] = useState(() => - streamingAtMountRef.current || collaborationEnabled + streamingAtMount || collaborationEnabled ? '' : parseMarkdownToDoc(splitFrontmatter(content).body) ) @@ -396,7 +477,7 @@ export function LoadedRichMarkdownEditor({ * rewrite holds the current content instead of collapsing to a partial result. */ const lastSyncedBodyRef = useRef( - streamingAtMountRef.current ? null : splitFrontmatter(content).body + streamingAtMount ? null : splitFrontmatter(content).body ) /** * The body the AGENT last applied into the collaborative doc — a dedup guard for the collab streaming @@ -407,23 +488,22 @@ export function LoadedRichMarkdownEditor({ */ const lastStreamedBodyRef = useRef(null) const onChangeRef = useRef(onChange) - onChangeRef.current = onChange const onSaveShortcutRef = useRef(onSaveShortcut) - onSaveShortcutRef.current = onSaveShortcut /** * The frontmatter to re-attach to the body on save. For a collaborative doc it lives in the CRDT * (config map, seeded/updated server-side), so a server edit that changes it is reflected rather - * than reverted by this editor's stale open-time copy; falls back to the locked `settledRef` copy + * than reverted by this editor's stale open-time copy; falls back to the accepted source snapshot * before the seed lands and for non-collaborative documents. */ - const resolveSaveFrontmatter = useCallback((): string => { + const resolveSaveFrontmatter = (): string => { const fromDoc = collaboration?.doc .getMap(FILE_DOC_SEED.configMap) .get(FILE_DOC_SEED.frontmatterKey) if (typeof fromDoc === 'string') return fromDoc - return settledRef.current?.frontmatter ?? '' - }, [collaboration]) + return settled?.frontmatter ?? '' + } + const saveFrontmatterResolverRef = useRef(resolveSaveFrontmatter) /** * While the file is still unnamed, name it after its leading heading: `onDeriveTitleFromHeading` is @@ -431,70 +511,66 @@ export function LoadedRichMarkdownEditor({ * read the current name without re-subscribing. See {@link isUntitledName}. */ const onDeriveTitleFromHeadingRef = useRef(onDeriveTitleFromHeading) - onDeriveTitleFromHeadingRef.current = onDeriveTitleFromHeading const fileNameRef = useRef(file.name) - fileNameRef.current = file.name const deriveTitleTimerRef = useRef | null>(null) /** * Read in the RAF tick so an already-scheduled tick still sees the latest edit kind (it can change * between sessions within one turn, e.g. an append followed by a rewrite). */ const streamIsIncrementalRef = useRef(streamIsIncremental) - streamIsIncrementalRef.current = streamIsIncremental const streamOperationRef = useRef(streamOperation) - streamOperationRef.current = streamOperation /** The live agent-stream shadow replica, held for the current stream and freed on settle/unmount. */ const agentStreamSessionRef = useRef(null) /** True once this client has announced candidacy in the agent-stream election for the current stream. */ const agentAnnouncedRef = useRef(false) const router = useRouter() const routerRef = useRef(router) - routerRef.current = router const containerRef = useRef(null) const uploadFile = useUploadWorkspaceFile() const editorInstanceRef = useRef(null) const source = useFileContentSource() const resolveImageSrcRef = useRef(source.resolveImageSrc) - resolveImageSrcRef.current = source.resolveImageSrc - /** - * The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret position - * captured when the command ran, so the upload inserts where `/Image` was typed. - */ + /** The picker anchor maps through edits while the operating-system file dialog is open. */ const imageInputRef = useRef(null) - const pendingImagePosRef = useRef(null) + const pendingImageAnchorRef = useRef(null) /** - * Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so - * handlers reach the latest. A persistent (`duration: 0`) progress toast shows per image during the - * upload and is dismissed once it settles, when the upload hook's own "Uploaded"/"Failed" toast takes over. + * Uploads are sequential; every position is anchored before awaiting so queued images also follow + * edits. Capture the editor instance, never a later file's editor, for completion and teardown. */ - const insertImagesRef = useRef<(images: File[], at: number) => Promise>(() => + const insertImagesRef = useRef<(images: File[], range: Range) => Promise>(() => Promise.resolve() ) - insertImagesRef.current = async (images, at) => { - let position = at - for (const image of images) { + const insertImages = async (images: File[], range: Range) => { + const editor = editorInstanceRef.current + if (!editor) return + const anchors = beginImageUploads( + editor, + range, + images.map((image) => image.name) + ) + for (const [index, image] of images.entries()) { + if (editor.isDestroyed) break + if (!editor.isEditable) { + for (const pending of anchors) removeImageUpload(editor, pending) + break + } + const anchor = anchors[index] + if (!anchor || findImageUpload(editor, anchor) === null) continue const uploadingToastId = toast.info(`Uploading "${image.name}"…`, { duration: 0 }) const result = await uploadFile .mutateAsync({ workspaceId, file: image, folderId: file.folderId ?? null }) .catch(() => null) toast.dismiss(uploadingToastId) - const editor = editorInstanceRef.current - if (!result || !editor) continue - const safePosition = Math.min(position, editor.state.doc.content.size) - try { - editor - .chain() - .insertContentAt(safePosition, { - type: 'image', - attrs: { src: result.file.url, alt: image.name }, - }) - .run() - position = editor.state.selection.to - } catch { - position = editor.state.doc.content.size + if (result) { + const inserted = finishImageUpload(editor, anchor, result.file.url, image.name) + if (!inserted && !editor.isDestroyed) { + toast.info('The image was uploaded to the workspace but was not inserted.') + } + } else { + removeImageUpload(editor, anchor) } } } @@ -509,16 +585,14 @@ export function LoadedRichMarkdownEditor({ * than a re-derived guess. Returns `false` (falls through to a normal upload) if no match is found, * which is always correct, just occasionally a redundant upload — unlike blindly trusting the html. */ - const cloneHostedImageRef = useRef<(imgSrcs: string[], at: number) => boolean>(() => false) - cloneHostedImageRef.current = (imgSrcs, at) => { + const cloneHostedImageRef = useRef<(imgSrcs: string[], range: Range) => boolean>(() => false) + const cloneHostedImage = (imgSrcs: string[], range: Range) => { const editor = editorInstanceRef.current if (!editor) return false const matchedAttrs = findHostedImageAttrs(editor.state.doc, imgSrcs, source.resolveImageSrc) if (!matchedAttrs) return false - const safePosition = Math.min(at, editor.state.doc.content.size) try { - editor.chain().insertContentAt(safePosition, { type: 'image', attrs: matchedAttrs }).run() - return true + return editor.chain().insertContentAt(range, { type: 'image', attrs: matchedAttrs }).run() } catch { return false } @@ -541,7 +615,11 @@ export function LoadedRichMarkdownEditor({ }, pasteAdmission: { maxResultBytes: PASTE_LIMITS.RICH_MARKDOWN_BYTES, - getCurrentText: () => lastSyncedBodyRef.current ?? '', + getCurrentText: () => { + const editor = editorInstanceRef.current + return editor ? postProcessSerializedMarkdown(editor.getMarkdown()) : '' + }, + getFrontmatter: () => saveFrontmatterResolverRef.current(), onRejected: warnRichMarkdownPasteLimit, }, }) @@ -551,6 +629,7 @@ export function LoadedRichMarkdownEditor({ pasteAdmission: { maxResultBytes: PASTE_LIMITS.RICH_MARKDOWN_BYTES, getCurrentText: () => lastSyncedBodyRef.current ?? '', + getFrontmatter: () => saveFrontmatterResolverRef.current(), onRejected: warnRichMarkdownPasteLimit, }, }) @@ -560,19 +639,25 @@ export function LoadedRichMarkdownEditor({ extensions, editable: isEditable, enablePasteRules: false, - autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false, + autofocus: streamingAtMount ? false : autoFocus ? 'end' : false, immediatelyRender: false, shouldRerenderOnTransaction: false, content: initialContent, editorProps: { attributes: { class: 'rich-markdown-nodes rich-markdown-prose', + 'aria-label': `${file.name} document body`, + role: 'textbox', + 'aria-multiline': 'true', + 'aria-readonly': String(!isEditable), 'data-owned-shortcuts': 'Mod+K', 'data-paste-max-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), 'data-paste-max-html-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), 'data-paste-handles-images': 'true', }, handleKeyDown: (_view, event) => { + if (event.isComposing || event.keyCode === 229 || event.shiftKey || event.altKey) + return false const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's' if (!isSaveShortcut) return false event.preventDefault() @@ -624,13 +709,12 @@ export function LoadedRichMarkdownEditor({ */ handlePaste: (view, event) => { if (!view.editable) return false + const currentEditor = editorInstanceRef.current + if (currentEditor && isPlainTextPaste(currentEditor)) return false const images = extractImageFiles(event.clipboardData) const html = event.clipboardData?.getData('text/html') ?? '' if (shouldSkipFileUpload(images, html, (src) => extractEmbeddedFileRef(src) !== null)) { - const cloned = cloneHostedImageRef.current( - extractImgSrcs(html), - view.state.selection.from - ) + const cloned = cloneHostedImageRef.current(extractImgSrcs(html), view.state.selection) if (cloned) { event.preventDefault() return true @@ -638,7 +722,7 @@ export function LoadedRichMarkdownEditor({ } if (images.length === 0) return false event.preventDefault() - void insertImagesRef.current(images, view.state.selection.from) + void insertImagesRef.current(images, view.state.selection) return true }, /** @@ -675,7 +759,8 @@ export function LoadedRichMarkdownEditor({ if (images.length > 0) { event.preventDefault() const dropPos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos - void insertImagesRef.current(images, dropPos ?? view.state.selection.from) + const at = dropPos ?? view.state.selection.from + void insertImagesRef.current(images, { from: at, to: at }) return true } if (event.dataTransfer?.files.length) { @@ -686,9 +771,12 @@ export function LoadedRichMarkdownEditor({ }, }, onUpdate: ({ editor, transaction }) => { - const md = postProcessSerializedMarkdown(editor.getMarkdown()) - lastSyncedBodyRef.current = md - onChangeRef.current(applyFrontmatter(resolveSaveFrontmatter(), md)) + /** The relay persists collaborative documents; a second Markdown projection has no consumer. */ + if (!collaborationEnabled && transaction.docChanged) { + const md = postProcessSerializedMarkdown(editor.getMarkdown()) + lastSyncedBodyRef.current = md + onChangeRef.current(applyFrontmatter(saveFrontmatterResolverRef.current(), md)) + } // While the file is still untitled, name it after its leading heading once typing settles — but // only for the LOCAL user's own edits. `isChangeOrigin` is true for a remote Yjs change (a peer // typing); bail BEFORE touching the timer so a remote edit never cancels or reschedules the local @@ -715,7 +803,6 @@ export function LoadedRichMarkdownEditor({ }, DERIVE_TITLE_DEBOUNCE_MS) }, }) - editorInstanceRef.current = editor useEffect( () => () => { @@ -729,7 +816,23 @@ export function LoadedRichMarkdownEditor({ * runs once at seed time rather than every render. */ const seedContentRef = useRef(content) - seedContentRef.current = content + + /** The lifetime-stable editor and its async work consume only committed React inputs. */ + useLayoutEffect(() => { + onChangeRef.current = onChange + onSaveShortcutRef.current = onSaveShortcut + saveFrontmatterResolverRef.current = resolveSaveFrontmatter + onDeriveTitleFromHeadingRef.current = onDeriveTitleFromHeading + fileNameRef.current = file.name + streamIsIncrementalRef.current = streamIsIncremental + streamOperationRef.current = streamOperation + routerRef.current = router + resolveImageSrcRef.current = source.resolveImageSrc + insertImagesRef.current = insertImages + cloneHostedImageRef.current = cloneHostedImage + editorInstanceRef.current = editor + seedContentRef.current = content + }) /** * The collaborative document lifecycle. In one effect because the three concerns @@ -761,14 +864,18 @@ export function LoadedRichMarkdownEditor({ * document that was already correct, and it opens mid-flight anyway whenever the updates arrive * more than a frame apart (which is what a remote Redis and a long room history produce). */ - const setReady = (ready: boolean) => { + const setReady = (ready: boolean, fatal = false) => { // Child-local: gates editability (a user must never type into an unsynced/unseeded doc). - setCollabReady(ready) + setCollabStatus((previous) => { + if (fatal) return 'fatal' + if (ready) return 'ready' + return previous === 'ready' || previous === 'reconnecting' ? 'reconnecting' : 'connecting' + }) // Parent: gates CLIENT autosave. In a collaborative session the relay persists the doc to // markdown server-side (debounced + on last-disconnect), so the client must NOT also autosave — // a stale keystroke saving over a server/copilot edit is the clobber the server path closes. // Only the non-collaborative (solo) path client-autosaves. - onCollabReadyChange(collaboration ? false : ready) + onClientAutosaveChange(collaboration ? false : ready) } if (!collaboration) { setReady(true) @@ -781,9 +888,6 @@ export function LoadedRichMarkdownEditor({ } const config = doc.getMap(FILE_DOC_SEED.configMap) - // Readiness LATCHES so a post-seed `synced` flap can't re-gate a new file's agent stream — see - // {@link nextCollabReadiness} for the full rationale. `offlineSeed` marks a local (read-only) seed. - let syncedOnce = false let offlineSeed = false const seedFromLoaded = () => { @@ -809,9 +913,7 @@ export function LoadedRichMarkdownEditor({ // `joinError` is latched ONLY on the provider's fatal paths (non-retryable rejection, access // revocation, readiness deadline), so it is exactly "this document is abandoned". const fatal = provider.joinError !== null - const next = nextCollabReadiness(syncedOnce, { synced, seeded, offlineSeed, fatal }) - syncedOnce = next.syncedOnce - setReady(next.ready) + setReady(isCollabReady({ synced, seeded, offlineSeed, fatal }), fatal) } /** * Re-report unconditionally, not just when the fallback seeds. A fatal that arrives on an ALREADY @@ -823,28 +925,9 @@ export function LoadedRichMarkdownEditor({ report() } - // A server edit that changes ONLY the frontmatter (e.g. copilot) updates the config map but not - // the body fragment, so TipTap's `onUpdate` never fires and the autosave draft would keep the - // stale open-time frontmatter — an explicit save could then revert the live change. Re-attach the - // new frontmatter to the current body and push a fresh draft whenever it changes on its own. - let lastFrontmatter = config.get(FILE_DOC_SEED.frontmatterKey) - const syncFrontmatter = () => { - const current = config.get(FILE_DOC_SEED.frontmatterKey) - if (current === lastFrontmatter) return - lastFrontmatter = current - // Null body ref ⇒ no body has synced yet (e.g. this fired before the seed's own `onUpdate`); - // that path re-attaches the frontmatter itself, so there is nothing to do here. - if (lastSyncedBodyRef.current !== null) { - onChangeRef.current( - applyFrontmatter(typeof current === 'string' ? current : '', lastSyncedBodyRef.current) - ) - } - } - provider.on('synced', report) provider.on('join-error', onJoinError) config.observe(report) - config.observe(syncFrontmatter) report() if (provider.joinError) onJoinError(provider.joinError) @@ -852,13 +935,12 @@ export function LoadedRichMarkdownEditor({ provider.off('synced', report) provider.off('join-error', onJoinError) config.unobserve(report) - config.unobserve(syncFrontmatter) // Report NOT ready on teardown — the safe direction. If this effect ever re-runs while mounted // (a future dep change), briefly gating autosave off is harmless; reporting `true` here could // ungate it while the doc is unready. - onCollabReadyChange(false) + onClientAutosaveChange(false) } - }, [collaboration, editor, onCollabReadyChange, setCollabReady]) + }, [collaboration, editor, onClientAutosaveChange]) /** * Owns editability for the collaborative lifecycle: `useEditor`'s `editable` is only the initial @@ -891,18 +973,29 @@ export function LoadedRichMarkdownEditor({ */ useEffect(() => { if (!editor) return + const input = imageInputRef.current + const cancelImagePicker = () => { + const anchor = pendingImageAnchorRef.current + if (anchor) removeImageUpload(editor, anchor) + pendingImageAnchorRef.current = null + } + input?.addEventListener('cancel', cancelImagePicker) editor.storage.slashCommand.insertImage = (at: number) => { - pendingImagePosRef.current = at + const previous = pendingImageAnchorRef.current + if (previous) removeImageUpload(editor, previous) + pendingImageAnchorRef.current = + beginImageUploads(editor, { from: at, to: at }, [''])[0] ?? null imageInputRef.current?.click() } return () => { + input?.removeEventListener('cancel', cancelImagePicker) editor.storage.slashCommand.insertImage = null } }, [editor]) useEditorMentions(editor, workspaceId, { navigable: true, disableTagging }) - const wasStreamingRef = useRef(streamingAtMountRef.current) + const wasStreamingRef = useRef(streamingAtMount) const pendingStreamBodyRef = useRef(null) const streamRafRef = useRef(null) @@ -1126,11 +1219,12 @@ export function LoadedRichMarkdownEditor({ streamRafRef.current = null } /** Settle: re-lock the verdict + frontmatter on the freshly-settled content (every stream→settle, not just the first). */ - const isInitialSettle = settledRef.current === null + const isInitialSettle = settled === null if (isInitialSettle || wasStreamingRef.current) { wasStreamingRef.current = false - settledRef.current = lockSettled(content) - const settledVerdict = settledRef.current.verdict + const nextSettled = lockSettled(content) + setSettled(nextSettled) + const settledVerdict = nextSettled.verdict const shouldFocus = isInitialSettle && autoFocus // A settle owes a selection collapse. Track it as a ref, not just inline in this microtask: if a // newer run bumps the token before this microtask fires, this settle's task is dropped — but the @@ -1150,7 +1244,6 @@ export function LoadedRichMarkdownEditor({ }) return } - const settled = settledRef.current runOffRender(() => { syncEditorBody(splitFrontmatter(content).body) // Honor a collapse a superseded settle owed but never applied (its microtask was dropped when this @@ -1164,6 +1257,9 @@ export function LoadedRichMarkdownEditor({ }, [ editor, content, + acceptedBaselineContent, + settled, + collaboration, isStreaming, canEdit, autoFocus, @@ -1215,11 +1311,9 @@ export function LoadedRichMarkdownEditor({ useSelectionCopyBridge(containerRef, buildSelectionContext, workspaceId) - // Show the read-only placeholder (the already-fetched markdown) whenever a collaborative doc has not yet - // seeded — including during an agent stream that begins before the seed lands. Streamed diffs are held - // until `collabReady` (see the streaming effect), so before then the editor is empty; the placeholder - // shows the base content until the seed swaps it in, avoiding both a blank frame and a garbled merge. - const showPlaceholder = collaborationEnabled && !collabReady + /** Use the stored-content placeholder only while the live document is bootstrapping. */ + const showPlaceholder = collaborationEnabled && collabStatus === 'connecting' + const showReconnecting = collaborationEnabled && collabStatus === 'reconnecting' /** * Find is off while the placeholder is up. The text on screen then belongs to the placeholder's own @@ -1233,6 +1327,26 @@ export function LoadedRichMarkdownEditor({ // The find bar is a sibling of the scroller, not a child: pinned inside `containerRef` it would // scroll away with the document the moment stepping moved the view.
    + {canEdit && !isStreaming && settled?.verdict === false && onEditSource && ( +
    +

    + This document needs source editing to preserve its content or handle its size. +

    + Edit source +
    + )} + {showReconnecting && ( +
    + Reconnecting… +
    + )} {find.isOpen && ( { const input = event.currentTarget const images = Array.from(input.files ?? []).filter((f) => f.type.startsWith('image/')) - const at = - pendingImagePosRef.current ?? editorInstanceRef.current?.state.selection.from ?? 0 - pendingImagePosRef.current = null + const editor = editorInstanceRef.current + const anchor = pendingImageAnchorRef.current + const range = editor && anchor ? findImageUploadRange(editor, anchor) : null + if (editor && anchor) removeImageUpload(editor, anchor) + pendingImageAnchorRef.current = null input.value = '' - if (images.length > 0) void insertImagesRef.current(images, at) + if (images.length === 0) return + if (range === null) { + toast.info( + 'The insertion location changed. Choose a new location and select the image again.' + ) + return + } + void insertImagesRef.current(images, range) }} /> {showPlaceholder && placeholderContent && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.test.tsx new file mode 100644 index 00000000000..c5146f88086 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.test.tsx @@ -0,0 +1,84 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { toast } from '@sim/emcn' +import { PASTE_LIMITS, PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste' +import type { Editor } from '@tiptap/core' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RichMarkdownField } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' + +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention', + () => ({ + useEditorMentions: vi.fn(), + }) +) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu', + () => ({ + EditorBubbleMenu: () => null, + }) +) +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card', + () => ({ + LinkHoverCard: () => null, + }) +) + +let root: Root +let container: HTMLDivElement +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.spyOn(toast, 'warning').mockReturnValue('paste-warning') + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.restoreAllMocks() +}) + +describe('shared field paste admission with real extensions', () => { + it.each([false, true])('counts preserved frontmatter (near limit=%s)', async (nearLimit) => { + const frontmatter = `---\ndescription: ${'f'.repeat(nearLimit ? 900 : 10)}\n---\n\n` + const body = 'x'.repeat(PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS - 1000) + const onChange = vi.fn() + await act(async () => + root.render() + ) + const element = container.querySelector('.tiptap') + expect(element).not.toBeNull() + const editor = element!.editor + await act(async () => editor.commands.setTextSelection(editor.state.doc.content.size - 1)) + const before = editor.getJSON() + const event = new Event('paste', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'clipboardData', { + value: { + files: [], + items: [], + types: ['text/plain'], + getData: (type: string) => (type === 'text/plain' ? 'y'.repeat(100) : ''), + }, + }) + await act(async () => element!.dispatchEvent(event)) + if (nearLimit) { + expect(editor.getJSON()).toEqual(before) + expect(onChange).not.toHaveBeenCalled() + expect(toast.warning).toHaveBeenCalledOnce() + } else { + expect(onChange).toHaveBeenCalledOnce() + expect(onChange.mock.lastCall?.[0]).toContain(frontmatter.trim()) + expect(new TextEncoder().encode(onChange.mock.lastCall?.[0]).byteLength).toBeLessThanOrEqual( + PASTE_LIMITS.RICH_MARKDOWN_BYTES + ) + expect(onChange.mock.lastCall?.[0].length).toBeLessThanOrEqual( + PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS + ) + expect(editor.getText()).toBe(body + 'y'.repeat(100)) + expect(toast.warning).not.toHaveBeenCalled() + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 3b3f10b6b3d..a5ec28c9920 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -217,6 +217,7 @@ function LoadedRichMarkdownField({ pasteAdmission: { maxResultBytes: PASTE_LIMITS.RICH_MARKDOWN_BYTES, getCurrentText: () => lastSyncedBodyRef.current, + getFrontmatter: () => frontmatterRef.current, onRejected: warnRichMarkdownPasteLimit, }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts index d673091006b..2fd713b8d62 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts @@ -2,7 +2,8 @@ * @vitest-environment jsdom */ import { describe, expect, it } from 'vitest' -import { isRoundTripSafe } from './round-trip-safety' +import { normalizeMarkdownContent } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content' +import { isRoundTripSafe } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety' describe('isRoundTripSafe', () => { it('passes ordinary markdown and lossless normalizations', () => { @@ -29,6 +30,55 @@ describe('isRoundTripSafe', () => { expect(isRoundTripSafe('use `npm install` here')).toBe(true) }) + it.each([ + ['[![foo][image]](/dest)', '[image]: /url'], + ['[![foo][]](/dest)', '[foo]: /url'], + ['[![foo]](/dest)', '[foo]: /url'], + ['[![foo](/url)][link]', '[link]: /dest'], + ['[![foo][image]][link]', '[image]: /url\n[link]: /dest'], + ['[![foo][image]][link]', '[image]: /url "Image"\n[link]: /dest "Link"'], + ])('keeps %s in source mode when rich parsing loses its wrapping link', (body, definitions) => { + for (const prefix of ['', '- ', '1. ']) { + expect(isRoundTripSafe(`${prefix}${body}\n\n${definitions}`)).toBe(false) + } + }) + + it.each([ + ['[foo][link]', '[link]: /dest'], + ['[foo][]', '[foo]: /dest'], + ['[foo]', '[foo]: /dest'], + ['![foo][link]', '[link]: /image'], + ])( + 'keeps task references in source mode regardless of definition order: %s', + (body, definitions) => { + const tasks = `- [x] ${body}\n - [ ] ${body}` + for (const source of [`${tasks}\n\n${definitions}`, `${definitions}\n\n${tasks}`]) { + expect(isRoundTripSafe(source)).toBe(false) + expect(normalizeMarkdownContent(source)).toBe(source) + } + } + ) + + it('does not count reference-looking code or escaped brackets as links', () => { + expect(isRoundTripSafe('- [ ] `[foo][link]`')).toBe(true) + expect(isRoundTripSafe('- [ ] \\[foo\\]\\[link\\]')).toBe(true) + expect(isRoundTripSafe('```md\n[![foo][image]](/dest)\n\n[image]: /url\n```')).toBe(true) + }) + + it('allows adjacent equal links to merge without treating the lower token count as data loss', () => { + expect(isRoundTripSafe('[a](/url)[b](/url)')).toBe(true) + expect(isRoundTripSafe('[a][link][b][link]\n\n[link]: /url')).toBe(true) + expect(isRoundTripSafe('- [ ] [a](/url)[b](/url)')).toBe(true) + expect(isRoundTripSafe('![a](/image)\n\n![b](/image)')).toBe(true) + }) + + it('does not let another link or image hide a lost wrapping link or duplicate table image', () => { + expect(isRoundTripSafe('[other](/dest)\n\n1. [![foo][image]](/dest)\n\n[image]: /url')).toBe( + false + ) + expect(isRoundTripSafe('![kept](/image)\n\n| h |\n| --- |\n| |')).toBe(false) + }) + it('passes a code block followed by other content (idempotent block separation)', () => { expect(isRoundTripSafe('```\ncode\n```\n\ntext after')).toBe(true) expect( @@ -84,12 +134,29 @@ describe('isRoundTripSafe', () => { expect(isRoundTripSafe('Use a
    break or the pipe | operator.')).toBe(true) }) - it('rejects
    inside a table cell (flattened to a space)', () => { - expect(isRoundTripSafe('| a | b |\n| --- | --- |\n| one
    two | x |')).toBe(false) + it('supports
    inside a table cell without flattening its hard break', () => { + expect(isRoundTripSafe('| a | b |\n| --- | --- |\n| one
    two | x |')).toBe(true) }) it('allows (a supported, resizable image node)', () => { expect(isRoundTripSafe('')).toBe(true) + expect(isRoundTripSafe('')).toBe(true) + expect(isRoundTripSafe('')).toBe(true) + expect(isRoundTripSafe("")).toBe(true) + expect(isRoundTripSafe('')).toBe(true) + }) + + it.each([ + '| |\n| --- |\n| body |', + '| header |\n| --- |\n| |', + '| header |\n| --- |\n| |', + '| header |\n| --- |\n| [](/dest) |', + ])('refuses unsupported HTML images inside GFM tables: %s', (source) => { + expect(isRoundTripSafe(source)).toBe(false) + }) + + it('allows literal HTML image examples in table code spans', () => { + expect(isRoundTripSafe('| header |\n| --- |\n| `` |')).toBe(true) }) it('does not flag a fenced block that merely contains html or backticks', () => { @@ -271,6 +338,7 @@ describe('editability gate — realistic documents stay editable', () => { it('frontmatter does not gate editability', () => { expect(isRoundTripSafe('---\ntitle: Hello\ntags: [a, b]\n---\n\n# Body\n\nText.')).toBe(true) + expect(isRoundTripSafe('---\ntitle: "[![foo][image]](/dest)"\n---\n\n# Body')).toBe(true) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts index 2e3dfe2ceb4..bab3759891f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts @@ -1,27 +1,20 @@ -import { serializeMarkdownDocument } from './markdown-parse' - -/** - * Above this size the file opens read-only. Parsing is chunked and linear now (see - * {@link serializeMarkdownBody}), so this is no longer about parse cost — it guards ProseMirror's - * whole-document-in-DOM rendering, which has no virtualization and gets sluggish to edit for very - * large documents. 256KB sits past the p99 of real markdown files while keeping a giant outlier from - * mounting thousands of editable DOM nodes. - */ -const PROBE_SIZE_LIMIT = 256 * 1024 +import { PASTE_RENDER_THRESHOLDS } from '@sim/utils/paste' +import { decodeHtmlEntities } from '@tiptap/core' +import { Marked, type Token } from 'marked' +import { extractImgSrcs } from '@/lib/uploads/utils/embedded-image-ref' +import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { serializeMarkdownDocument } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' /** * Constructs the editor drops or mangles in a way that survives a second serialization * unchanged — so the idempotency probe below can't see the loss. Each must be matched directly. - * (Linked images `[![alt](img)](href)` are handled by the image node and verified separately by - * the link-count check in {@link isRoundTripSafe}, not here.) + * Image sources and their wrapping links are verified separately against the first serialization. * * Footnotes, HTML comments, and raw HTML tags (`
    `, `
    `, ``, …) used to be listed * here — the schema had no node for any of them, so they were dropped or stripped (content kept, * structure lost). `./raw-markdown-snippet.ts` now holds each construct's exact source text and * re-emits it byte-for-byte, so none of them lose data on round-trip and none need a pattern below. * - * - **`
    ` inside a table cell** — a GFM cell can't hold a real line break, so the serializer - * flattens `one
    two` to `one two`. Matched on a table-shaped line (≥2 pipes) containing a `
    `. * - **Hard break inside a heading** (trailing two spaces or a backslash) — the serializer splits * the heading, ejecting the second line into a separate paragraph. * - **HTML entity** other than the lowercase canonical `&`/`<`/`>` (e.g. `©`, `'`, @@ -31,7 +24,6 @@ const PROBE_SIZE_LIMIT = 256 * 1024 * be treated as safe. A bare `&` with no matching `;`-terminated name is left alone (harmless churn). */ const STABLE_LOSS_PATTERNS: ReadonlyArray = [ - /^(?=(?:[^\n]*\|){2})[^\n]*/im, /^#{1,6}\s.*(?: {2,}|\\)$/m, /&(?!(?:amp|lt|gt);)(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/, ] @@ -49,17 +41,49 @@ function stripCode(content: string): string { .replace(/`+[^`\n]*`+/g, '') } +const fidelityLexer = new Marked({ gfm: true }) + +function imageSources(token: Token): string[] { + if (token.type === 'image') return [token.href] + return token.type === 'html' ? extractImgSrcs(token.raw) : [] +} + /** - * Linked images `[![alt](src)](href)`. The image node round-trips the common forms (clean URLs, - * optional titles) via its `href` attribute, but an exotic one it can't tokenize falls back to the - * stock parser, which drops the wrapping link — an invisible, stable loss. So instead of matching a - * fixed pattern, {@link isRoundTripSafe} counts these before and after one serialization and rejects - * if any disappeared. + * Resolve reference syntax before comparing images and their wrapping links: a stable second + * serialization cannot reveal first-pass loss. Plain-text link counts are deliberately excluded: + * adjacent equal link marks can merge losslessly. Task references are conservatively source-only: + * their parser resolves definitions before a task but loses definitions appearing after it, so + * rearranging otherwise valid Markdown can silently remove a destination. + * Count HTML image sources too, allowing lossless HTML-to-Markdown conversion while catching images + * dropped from inline-only table cells. Frontmatter is stored separately, not interpreted as Markdown. */ -const LINKED_IMAGE_PATTERN = /\[\s*!\[[^\]]*]\([^)]*\)\s*]\([^)]*\)/g - -function linkedImageCount(content: string): number { - return content.match(LINKED_IMAGE_PATTERN)?.length ?? 0 +function inspectMarkdownFidelity(content: string) { + const targets = new Map() + let hasTaskReference = false + const add = (kind: 'image' | 'linkedImage', ...destinations: string[]) => { + const target = JSON.stringify([kind, ...destinations.map(decodeHtmlEntities)]) + targets.set(target, (targets.get(target) ?? 0) + 1) + } + fidelityLexer.walkTokens(fidelityLexer.lexer(splitFrontmatter(content).body), (token) => { + for (const src of imageSources(token)) add('image', src) + if (token.type === 'link') { + fidelityLexer.walkTokens(token.tokens ?? [], (child) => { + for (const src of imageSources(child)) add('linkedImage', token.href, src) + }) + } + if (token.type === 'list_item' && token.task) { + for (const child of token.tokens ?? []) { + if ((child.type === 'text' || child.type === 'paragraph') && child.tokens) { + fidelityLexer.walkTokens(child.tokens, (inline) => { + if ((inline.type === 'link' || inline.type === 'image') && inline.raw.endsWith(']')) { + hasTaskReference = true + } + }) + } + } + } + }) + return { targets, hasTaskReference } } /** @@ -102,9 +126,9 @@ function hasOrphanReferenceDefinition(content: string): boolean { } /** - * Whether `content` survives the editor's markdown round-trip without data loss or autosave - * churn. The editor opens the content read-only when this is false, so the probe is deliberately - * conservative: it rejects on any doubt rather than risk an edit silently corrupting a file. + * Whether `content` fits the rich editor's rendering budget and survives its Markdown round-trip + * without known data loss or autosave churn. A refusal keeps rich preview read-only and offers + * source editing; the character cap is a performance boundary, separate from fidelity checks. * * Two complementary checks: known stable-loss constructs are matched directly (the idempotency * probe is blind to them), and everything else must reach a fixpoint — `serializeMarkdownDocument(x)` @@ -113,13 +137,18 @@ function hasOrphanReferenceDefinition(content: string): boolean { * pass and are allowed through; genuine churn (a blockquote wrapping a code fence keeps growing) is not. */ export function isRoundTripSafe(content: string): boolean { - if (content.length > PROBE_SIZE_LIMIT) return false + if (content.length > PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS) return false const stripped = stripCode(content) if (STABLE_LOSS_PATTERNS.some((pattern) => pattern.test(stripped))) return false if (hasOrphanReferenceDefinition(stripped)) return false try { + const source = inspectMarkdownFidelity(content) + if (source.hasTaskReference) return false const once = serializeMarkdownDocument(content) - if (linkedImageCount(stripped) !== linkedImageCount(stripCode(once))) return false + const serialized = inspectMarkdownFidelity(once) + for (const [target, count] of source.targets) { + if ((serialized.targets.get(target) ?? 0) < count) return false + } return serializeMarkdownDocument(once) === once } catch { return false diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts index ec34ef23e06..bfa83186ec4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts @@ -1,14 +1,14 @@ import { Extension } from '@tiptap/core' -import { PluginKey } from '@tiptap/pm/state' import Suggestion from '@tiptap/suggestion' -import { createSuggestionPopupRenderer } from '../menus/suggestion-popup' +import { createSuggestionPopupRenderer } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup' import { filterSlashCommands, type SlashCommandContext, type SlashCommandItem, type SlashCommandStorage, -} from './commands' -import { SlashCommandList } from './slash-command-list' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands' +import { SlashCommandList } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list' +import { SLASH_COMMAND_PLUGIN_KEY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/suggestion-plugin-keys' declare module '@tiptap/core' { interface Storage { @@ -16,9 +16,6 @@ declare module '@tiptap/core' { } } -/** Explicit key (distinct from the `@` mention's) so the keymap can detect an open menu. */ -export const SLASH_COMMAND_PLUGIN_KEY = new PluginKey('slashCommand') - /** * Adds the `/` slash-command menu to the editor. Typing `/` at the start of a block — or after * whitespace — opens {@link SlashCommandList}; selecting an item runs its block transform. The Image diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/suggestion-plugin-keys.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/suggestion-plugin-keys.ts new file mode 100644 index 00000000000..36c066acd99 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/suggestion-plugin-keys.ts @@ -0,0 +1,7 @@ +import { PluginKey } from '@tiptap/pm/state' + +/** Shared suggestion identities let keyboard guards read activity without importing popup UI. */ +export const MENTION_PLUGIN_KEY = new PluginKey<{ active: boolean }>('mention') + +/** Slash commands have their own state and must never reuse the mention plugin's identity. */ +export const SLASH_COMMAND_PLUGIN_KEY = new PluginKey<{ active: boolean }>('slashCommand') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/table-serialization.node.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/table-serialization.node.test.ts new file mode 100644 index 00000000000..85c5bcc15e6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/table-serialization.node.test.ts @@ -0,0 +1,150 @@ +/** + * @vitest-environment node + */ +import { getSchema, type JSONContent } from '@tiptap/core' +import { prosemirrorJSONToYDoc } from '@tiptap/y-tiptap' +import { describe, expect, it } from 'vitest' +import { yDocToMarkdown } from '@/lib/collab-doc/converter' +import { COLLAB_DOC_FIELD } from '@/lib/collab-doc/field' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { + parseMarkdownToDoc, + serializeMarkdownDocument, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' + +describe('legacy table preservation through the server converter', () => { + it('keeps rich blocks, marks, spans, and widths as standard HTML in a Node environment', () => { + expect(typeof globalThis.window).toBe('undefined') + const document: JSONContent = { + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + attrs: { colspan: 2, colwidth: [120, 240] }, + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'rich heading', marks: [{ type: 'bold' }] }], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'second block' }] }, + ], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + attrs: { rowspan: 2 }, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'a b', marks: [{ type: 'code' }] }], + }, + ], + }, + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'peer text' }] }], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '' }], + }, + ], + }, + ], + }, + ], + }, + ], + } + const shared = prosemirrorJSONToYDoc( + getSchema(createMarkdownContentExtensions()), + document, + COLLAB_DOC_FIELD + ) + try { + const markdown = yDocToMarkdown(shared).trim() + expect(markdown).toContain('rich heading') + expect(markdown).toContain('

    second block

    ') + expect(markdown).toContain('a b') + expect(markdown).toContain('peer text') + expect(markdown).toContain('<script>literal</script>') + expect(parseMarkdownToDoc(markdown).content?.[0].type).toBe('rawHtmlBlock') + expect(serializeMarkdownDocument(markdown).trim()).toBe(markdown) + } finally { + shared.destroy() + } + }) + + it('uses lossless HTML when a code span has a backslash immediately before a pipe', () => { + const document: JSONContent = { + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'value' }] }], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '\\|', marks: [{ type: 'code' }] }], + }, + ], + }, + ], + }, + ], + }, + ], + } + const shared = prosemirrorJSONToYDoc( + getSchema(createMarkdownContentExtensions()), + document, + COLLAB_DOC_FIELD + ) + try { + const markdown = yDocToMarkdown(shared).trim() + expect(markdown).toContain('\\|') + expect(parseMarkdownToDoc(markdown).content?.[0].type).toBe('rawHtmlBlock') + expect(serializeMarkdownDocument(markdown).trim()).toBe(markdown) + } finally { + shared.destroy() + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/table.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/table.ts new file mode 100644 index 00000000000..8e629b64bca --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/table.ts @@ -0,0 +1,270 @@ +import { + commands, + getHTMLFromFragment, + InputRule, + type JSONContent, + type MarkdownRendererHelpers, +} from '@tiptap/core' +import { Table } from '@tiptap/extension-table' +import { Fragment, type Node as ProseMirrorNode, type Schema, Slice } from '@tiptap/pm/model' +import { type EditorState, Plugin, type Transaction } from '@tiptap/pm/state' +import { isInTable, selectedRect } from '@tiptap/pm/tables' + +/** + * Keep the established collaborative schema intact: older peers may already have block content in + * cells. Newly authored content is constrained at the command/input-rule boundary instead of having + * schema fitting silently discard existing shared content. + */ +export function selectionTouchesTable(state: EditorState): boolean { + if (isInTable(state)) return true + let found = false + state.doc.nodesBetween(state.selection.from, state.selection.to, (node) => { + if (node.type.name === 'table') found = true + return !found + }) + return found +} + +/** Prevent Markdown block-prefix autoformat inside a GFM cell without consuming the user's text. */ +export function excludeTableBlockInputRules(rules: InputRule[]): InputRule[] { + return rules.map( + (rule) => + new InputRule({ + find: rule.find, + undoable: rule.undoable, + handler: (props) => (selectionTouchesTable(props.state) ? null : rule.handler(props)), + }) + ) +} + +/** New empty rows inherit the header's column alignment before they enter shared state. */ +function alignInsertedRow(tr: Transaction, tableStart: number, rowIndex: number): void { + const table = tr.doc.nodeAt(tableStart - 1) + if (!table?.firstChild || rowIndex >= table.childCount) return + let rowStart = tableStart + for (let index = 0; index < rowIndex; index++) rowStart += table.child(index).nodeSize + table.child(rowIndex).forEach((cell, offset, column) => { + const align = table.firstChild?.maybeChild(column)?.attrs.align ?? null + if (cell.attrs.align !== align) { + tr.setNodeMarkup(rowStart + 1 + offset, undefined, { ...cell.attrs, align }) + } + }) +} + +/** + * Pasted text blocks adopt a cell's inline-only authoring contract. Preserve every inline node and + * mark, including empty lines. Unsupported blocks return null so nested tables, media, and other + * richer content retain their existing lossless HTML serialization instead of being flattened. + */ +function inlineCellParagraph(content: Fragment, schema: Schema): Fragment | null { + if (content.childCount === 0) return null + const inline: ProseMirrorNode[] = [] + for (let index = 0; index < content.childCount; index++) { + const child = content.child(index) + if (child.type.name !== 'paragraph' && child.type.name !== 'heading') return null + if (index > 0) inline.push(schema.nodes.hardBreak.create()) + child.content.forEach((node) => inline.push(node)) + } + return Fragment.from(schema.nodes.paragraph.create(null, Fragment.fromArray(inline))) +} + +/** Only clipboard cells are rewritten; legacy shared nodes are never normalized during editing. */ +function normalizePastedTableCells(content: Fragment, schema: Schema): Fragment { + const nodes: ProseMirrorNode[] = [] + content.forEach((node) => { + const isCell = node.type.spec.tableRole === 'cell' || node.type.spec.tableRole === 'header_cell' + const normalized = isCell + ? inlineCellParagraph(node.content, schema) + : normalizePastedTableCells(node.content, schema) + nodes.push(normalized && !normalized.eq(node.content) ? node.copy(normalized) : node) + }) + return Fragment.fromArray(nodes) +} + +/** + * Tables expose only operations that GFM can persist: one fixed header row, rectangular body rows, + * inline cell content, and no merged cells or stored column widths. The header's text is editable; + * the structural row cannot be removed independently of the table. + */ +const MarkdownTable = Table.extend({ + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + new Plugin({ + props: { + transformPasted: (slice, view) => { + const { schema } = view.state + const inline = isInTable(view.state) ? inlineCellParagraph(slice.content, schema) : null + if (inline) { + return new Slice(inline, Math.min(slice.openStart, 1), Math.min(slice.openEnd, 1)) + } + return new Slice( + normalizePastedTableCells(slice.content, schema), + slice.openStart, + slice.openEnd + ) + }, + }, + }), + ] + }, + addCommands() { + const parent = this.parent?.() + return { + ...parent, + setNode: (type, attributes) => (props) => + (!selectionTouchesTable(props.state) || + (typeof type === 'string' ? type : type.name) === 'paragraph') && + commands.setNode(type, attributes)(props), + toggleList: + (...args) => + (props) => + !selectionTouchesTable(props.state) && commands.toggleList(...args)(props), + wrapInList: + (...args) => + (props) => + !selectionTouchesTable(props.state) && commands.wrapInList(...args)(props), + wrapIn: + (...args) => + (props) => + !selectionTouchesTable(props.state) && commands.wrapIn(...args)(props), + insertTable: (options) => (props) => + !selectionTouchesTable(props.state) && + (parent?.insertTable?.({ ...options, withHeaderRow: true })(props) ?? false), + addRowBefore: () => (props) => { + if (!isInTable(props.state)) return false + const rect = selectedRect(props.state) + if (rect.top === 0 || !parent?.addRowBefore?.()(props)) return false + if (props.dispatch) alignInsertedRow(props.tr, rect.tableStart, rect.top) + return true + }, + addRowAfter: () => (props) => { + if (!isInTable(props.state)) return false + const rect = selectedRect(props.state) + if (!parent?.addRowAfter?.()(props)) return false + if (props.dispatch) alignInsertedRow(props.tr, rect.tableStart, rect.bottom) + return true + }, + deleteRow: () => (props) => + isInTable(props.state) && + selectedRect(props.state).top > 0 && + (parent?.deleteRow?.()(props) ?? false), + toggleHeaderRow: () => () => false, + toggleHeaderColumn: () => () => false, + toggleHeaderCell: () => () => false, + mergeCells: () => () => false, + splitCell: () => () => false, + mergeOrSplit: () => () => false, + setCellAttribute: () => () => false, + } + }, + addKeyboardShortcuts() { + return { + ...this.parent?.(), + Enter: () => this.editor.isActive('table') && this.editor.commands.setHardBreak(), + } + }, +}) + +/** Whether a code span contains a pipe after an odd run of literal backslashes. */ +function hasAmbiguousCodePipe(value: string): boolean { + let backslashes = 0 + for (const character of value) { + if (character === '\\') { + backslashes++ + continue + } + if (character === '|' && backslashes % 2 === 1) return true + backslashes = 0 + } + return false +} + +/** Whether inline cell content can be represented without ambiguity in a GFM pipe table. */ +function isGfmCellContent(content: JSONContent[] | undefined): boolean { + return !(content ?? []).some( + (node) => + node.type === 'text' && + node.marks?.some((mark) => mark.type === 'code') && + hasAmbiguousCodePipe(node.text ?? '') + ) +} + +/** A GFM table has a single header, uniform columns, inline cell content, and column-level alignment. */ +function isGfmTable(node: JSONContent): boolean { + const rows = node.content ?? [] + const header = rows[0]?.content ?? [] + return ( + header.length > 0 && + rows.every( + (row, index) => + row.content?.length === header.length && + row.content.every( + (cell, column) => + cell.type === (index === 0 ? 'tableHeader' : 'tableCell') && + (cell.attrs?.colspan ?? 1) === 1 && + (cell.attrs?.rowspan ?? 1) === 1 && + cell.attrs?.colwidth == null && + (cell.attrs?.align ?? null) === (header[column].attrs?.align ?? null) && + cell.content?.length === 1 && + cell.content[0].type === 'paragraph' && + isGfmCellContent(cell.content[0].content) + ) + ) + ) +} + +/** Escape pipes that belong to cell content while leaving the structural delimiters to the renderer. */ +function escapeGfmCellPipes(value: string): string { + const escaped: string[] = [] + for (const character of value) escaped.push(character === '|' ? '\\|' : character) + return escaped.join('') +} + +/** + * Render compatible cells without collapsing interior whitespace: spaces inside code spans are + * content, not table padding. Formatting padding is bounded so one wide cell cannot multiply its + * width across thousands of otherwise-small rows. + */ +function renderGfmTable(node: JSONContent, helpers: MarkdownRendererHelpers): string { + const rows = (node.content ?? []).map((row) => + (row.content ?? []).map((cell) => + escapeGfmCellPipes(helpers.renderChildren(cell.content ?? [])).replace(/\r?\n/g, '
    ') + ) + ) + const widths = rows[0].map(() => 3) + for (const row of rows) { + row.forEach((cell, column) => { + widths[column] = Math.min(80, Math.max(widths[column], cell.length)) + }) + } + const line = (cells: string[]) => + `| ${cells.map((cell, column) => cell.padEnd(widths[column])).join(' | ')} |` + const separators = widths.map((width, column) => { + const align = node.content?.[0].content?.[column].attrs?.align + return `${align === 'left' || align === 'center' ? ':' : ''}${'-'.repeat(width)}${ + align === 'right' || align === 'center' ? ':' : '' + }` + }) + return [line(rows[0]), `| ${separators.join(' | ')} |`, ...rows.slice(1).map(line)].join('\n') +} + +/** + * Standard HTML is the lossless fallback for legacy cells with rich blocks, spans, or widths that + * GFM cannot encode. It reopens through the existing raw-HTML node instead of silently losing those + * attributes. The native serializer uses this editor's schema and the DOM already provisioned by + * the client or the server collab converter; no additional DOM globals or parser are installed here. + */ +export function createMarkdownTable(): typeof MarkdownTable { + let schema: Schema | undefined + return MarkdownTable.extend({ + onBeforeCreate() { + schema = this.editor.schema + }, + renderMarkdown: (node: JSONContent, helpers: MarkdownRendererHelpers) => { + if (isGfmTable(node)) return renderGfmTable(node, helpers) + if (!schema) throw new Error('Table serialization requires an initialized editor schema') + return getHTMLFromFragment(Fragment.from(schema.nodeFromJSON(node)), schema) + }, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts index ccc8b1c5530..bd105ef0a84 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts @@ -7,7 +7,7 @@ import { syncTextEditorContentState, type TextEditorContentState, textEditorContentReducer, -} from './text-editor-state' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state' function ready(content: string, savedContent = content): TextEditorContentState { return { phase: 'ready', content, savedContent, lastStreamedContent: null, hasBaseline: true } @@ -158,10 +158,227 @@ describe('syncTextEditorContentState — static fetch updates', () => { }) // Local edits take precedence — content should remain 'user edit' expect(next.content).toBe('user edit') + expect(next.conflict).toEqual({ version: undefined }) expect(next.phase).toBe('ready') }) }) +describe('content-version ordering', () => { + const version = (second: number) => `2026-09-03T20:00:0${second}.000Z` + it('accepts a new stream while the content query still contains the pre-save snapshot', () => { + let state = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, { + canReconcileToFetchedContent: true, + fetchedContent: 'original', + fetchedVersion: version(1), + }) + state = textEditorContentReducer(state, { type: 'edit', content: 'saved local' }) + state = textEditorContentReducer(state, { + type: 'save-success', + content: 'saved local', + version: version(2), + }) + const staleSnapshot = { + canReconcileToFetchedContent: true, + fetchedContent: 'original', + fetchedVersion: version(1), + } + + state = syncTextEditorContentState(state, { + ...staleSnapshot, + streamingContent: 'new agent output', + }) + expect(state).toMatchObject({ + phase: 'streaming', + content: 'new agent output', + savedContent: 'saved local', + savedVersion: version(2), + lastStreamedContent: 'new agent output', + }) + state = syncTextEditorContentState(state, staleSnapshot) + expect(state.phase).toBe('reconciling') + expect(state.content).toBe('new agent output') + expect(state.savedVersion).toBe(version(2)) + expect(syncTextEditorContentState(state, staleSnapshot)).toBe(state) + + state = syncTextEditorContentState(state, { + canReconcileToFetchedContent: true, + fetchedContent: 'final agent output', + fetchedVersion: version(3), + }) + expect(state).toMatchObject({ + phase: 'ready', + content: 'final agent output', + savedContent: 'final agent output', + savedVersion: version(3), + }) + }) + + it('preserves a trailing draft and records an incoming stream despite a stale fetch', () => { + const state = { ...ready('trailing draft', 'saved local'), savedVersion: version(2) } + const next = syncTextEditorContentState(state, { + canReconcileToFetchedContent: true, + fetchedContent: 'original', + fetchedVersion: version(1), + streamingContent: 'agent output', + }) + expect(next).toMatchObject({ + content: 'trailing draft', + savedContent: 'saved local', + savedVersion: version(2), + conflict: { streamInterrupted: true }, + }) + expect(next.conflict?.version).toBeUndefined() + }) + + it.each([1, 2])( + 'records streams without replacing a newer conflict with version %i', + (second) => { + const state = { + ...ready('local draft', 'original'), + savedVersion: version(2), + conflict: { version: version(3) }, + } + const next = syncTextEditorContentState(state, { + canReconcileToFetchedContent: true, + fetchedContent: 'stale remote', + fetchedVersion: version(second), + streamingContent: 'agent output', + }) + expect(next.content).toBe('local draft') + expect(next.savedVersion).toBe(version(2)) + expect(next.conflict).toEqual({ ...state.conflict, streamInterrupted: true }) + } + ) + + it.each([false, true])( + 'keeps conflict ordering across an unversioned fetch (streaming=%s)', + (streaming) => { + const original = { + ...ready('local draft', 'baseline'), + savedVersion: version(1), + conflict: { version: version(3) }, + } + const options = { canReconcileToFetchedContent: true, fetchedContent: 'unknown remote' } + const unversioned = syncTextEditorContentState(original, { + ...options, + streamingContent: streaming ? 'agent output' : undefined, + }) + expect(unversioned.conflict).toEqual({ + version: version(3), + ...(streaming ? { streamInterrupted: true } : {}), + }) + const stale = syncTextEditorContentState(unversioned, { + ...options, + fetchedVersion: version(2), + }) + expect(stale).toBe(unversioned) + const acknowledged = textEditorContentReducer(stale, { + type: 'save-success', + content: 'local saved', + version: version(2), + }) + expect(acknowledged.content).toBe('local draft') + expect(acknowledged.conflict).toEqual(unversioned.conflict) + const newer = syncTextEditorContentState(acknowledged, { + ...options, + fetchedVersion: version(4), + }) + expect(newer.conflict?.version).toBe(version(4)) + } + ) + + it('adopts a known version for a previously unversioned conflict', () => { + const original = { ...ready('draft', 'baseline'), conflict: {} } + const options = { canReconcileToFetchedContent: true, fetchedContent: 'remote' } + expect(syncTextEditorContentState(original, options)).toBe(original) + expect( + syncTextEditorContentState(original, { ...options, fetchedVersion: version(3) }).conflict + ).toEqual({ version: version(3) }) + }) + + it('never replaces a successfully saved baseline with an older in-flight fetch', () => { + const state = { ...ready('saved'), savedVersion: version(2) } + const next = syncTextEditorContentState(state, { + canReconcileToFetchedContent: true, + fetchedContent: 'stale', + fetchedVersion: version(1), + }) + expect(next).toBe(state) + }) + + it('retains a later remote conflict when an earlier save response arrives', () => { + const state = { + ...ready('local trailing', 'original'), + savedVersion: version(1), + conflict: { version: version(3) }, + } + const next = textEditorContentReducer(state, { + type: 'save-success', + content: 'local saved', + version: version(2), + }) + expect(next.savedVersion).toBe(version(2)) + expect(next.content).toBe('local trailing') + expect(next.conflict).toEqual(state.conflict) + }) + + it.each([ + { fetchedVersion: undefined, savedVersion: version(2) }, + { fetchedVersion: undefined, savedVersion: undefined }, + { fetchedVersion: version(2), savedVersion: undefined }, + ])( + 'retains conflicts when acknowledgement ordering is unknown: %o', + ({ fetchedVersion, savedVersion }) => { + const conflicted = syncTextEditorContentState( + { + ...ready('local trailing', 'original'), + savedVersion: version(1), + }, + { + canReconcileToFetchedContent: true, + fetchedContent: 'remote', + fetchedVersion, + } + ) + expect(conflicted.conflict).toBeDefined() + const acknowledged = textEditorContentReducer(conflicted, { + type: 'save-success', + content: 'local saved', + version: savedVersion, + }) + expect(acknowledged.conflict).toEqual(conflicted.conflict) + expect(acknowledged.content).toBe('local trailing') + expect(acknowledged.savedContent).toBe('local saved') + const reloaded = textEditorContentReducer(acknowledged, { + type: 'reload', + content: 'latest remote', + version: version(3), + }) + expect(reloaded.conflict).toBeUndefined() + expect(reloaded.content).toBe('latest remote') + expect(reloaded.savedVersion).toBe(version(3)) + } + ) + + it.each([2, 3])( + 'clears a versioned conflict when acknowledgement version %i catches up', + (second) => { + const state = { + ...ready('local trailing', 'original'), + savedVersion: version(1), + conflict: { version: version(2) }, + } + const next = textEditorContentReducer(state, { + type: 'save-success', + content: 'local saved', + version: version(second), + }) + expect(next.content).toBe('local trailing') + expect(next.conflict).toBeUndefined() + } + ) +}) + describe('syncTextEditorContentState — streaming', () => { it('enters streaming phase when streamingContent arrives (replace mode)', () => { const state = ready('existing') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts index 0742749845e..792823fe05e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts @@ -12,18 +12,27 @@ export interface TextEditorContentState { * agent's write advancing past the baseline (which would finalize the editor to stale content). */ hasBaseline: boolean + /** Content-version token paired with the accepted immutable storage object. */ + savedVersion?: string + acceptedBaselineContent?: string + /** Remote conflict metadata; the local draft remains in `content`. */ + conflict?: { version?: string; streamInterrupted?: true } } export interface SyncTextEditorContentStateOptions { canReconcileToFetchedContent: boolean fetchedContent?: string streamingContent?: string + fetchedVersion?: string } export type TextEditorContentAction = | ({ type: 'sync-external' } & SyncTextEditorContentStateOptions) | { type: 'edit'; content: string } - | { type: 'save-success'; content: string } + | { type: 'save-success'; content: string; version?: string } + | { type: 'save-conflict' } + | { type: 'restore-conflicting-draft'; content: string } + | { type: 'reload'; content: string; version: string } export const INITIAL_TEXT_EDITOR_CONTENT_STATE: TextEditorContentState = { phase: 'uninitialized', @@ -103,7 +112,7 @@ function moveTextEditorContentStateToReconcile( } } -export function syncTextEditorContentState( +function syncUnversionedTextEditorContentState( state: TextEditorContentState, options: SyncTextEditorContentStateOptions ): TextEditorContentState { @@ -179,6 +188,64 @@ export function syncTextEditorContentState( return state } +/** Keeps local drafts and their original compare-and-swap token when remote bytes advance. */ +export function syncTextEditorContentState( + state: TextEditorContentState, + options: SyncTextEditorContentStateOptions +): TextEditorContentState { + const isStaleSnapshot = + options.fetchedVersion && + ((state.savedVersion && Date.parse(options.fetchedVersion) < Date.parse(state.savedVersion)) || + (state.conflict?.version && + Date.parse(options.fetchedVersion) < Date.parse(state.conflict.version))) + const currentOptions = isStaleSnapshot + ? { ...options, fetchedContent: undefined, fetchedVersion: undefined } + : options + const { fetchedContent, fetchedVersion, streamingContent } = currentOptions + + if (state.conflict) { + const conflict = + streamingContent !== undefined + ? { ...state.conflict, streamInterrupted: true as const } + : state.conflict + if ( + fetchedContent === undefined || + fetchedVersion === undefined || + fetchedVersion === conflict.version + ) + return conflict === state.conflict ? state : { ...state, conflict } + return { ...state, conflict: { ...conflict, version: fetchedVersion } } + } + + if ( + state.phase === 'ready' && + state.content !== state.savedContent && + (streamingContent !== undefined || + (fetchedContent !== undefined && fetchedContent !== state.savedContent)) + ) + return { + ...state, + conflict: { + version: fetchedVersion, + ...(streamingContent !== undefined ? { streamInterrupted: true as const } : {}), + }, + } + + const next = syncUnversionedTextEditorContentState(state, currentOptions) + if (fetchedContent !== undefined && fetchedVersion && next.savedContent === fetchedContent) { + if (next.savedVersion === fetchedVersion && next.acceptedBaselineContent === fetchedContent) + return next + return { ...next, savedVersion: fetchedVersion, acceptedBaselineContent: fetchedContent } + } + return next === state + ? state + : { + ...next, + savedVersion: state.savedVersion, + acceptedBaselineContent: state.acceptedBaselineContent, + } +} + export function textEditorContentReducer( state: TextEditorContentState, action: TextEditorContentAction @@ -195,6 +262,12 @@ export function textEditorContentReducer( content: action.content, } case 'save-success': + if ( + action.version && + state.savedVersion && + Date.parse(action.version) < Date.parse(state.savedVersion) + ) + return state // Advance only the saved baseline. Never roll `content` back to the saved snapshot: a // keystroke landing while the save was in flight makes `content` newer than `action.content`, // and overwriting it would silently drop that edit (and leave the doc looking clean so it's @@ -202,7 +275,8 @@ export function textEditorContentReducer( if ( state.phase === 'ready' && state.savedContent === action.content && - state.lastStreamedContent === null + state.lastStreamedContent === null && + (!action.version || state.savedVersion === action.version) ) { return state } @@ -212,6 +286,33 @@ export function textEditorContentReducer( savedContent: action.content, lastStreamedContent: null, hasBaseline: true, + savedVersion: action.version ?? state.savedVersion, + conflict: + state.conflict && + (state.conflict.streamInterrupted || + !state.conflict.version || + !action.version || + Date.parse(state.conflict.version) > Date.parse(action.version)) + ? state.conflict + : undefined, + } + case 'save-conflict': + return state.conflict ? state : { ...state, conflict: {} } + case 'restore-conflicting-draft': + return { + ...state, + content: action.content, + conflict: { version: state.savedVersion }, + } + case 'reload': + return { + phase: 'ready', + content: action.content, + savedContent: action.content, + savedVersion: action.version, + acceptedBaselineContent: action.content, + hasBaseline: true, + lastStreamedContent: null, } default: return state diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-sync.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-sync.test.tsx index 1cf90eea515..c016e4c8b23 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-sync.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-sync.test.tsx @@ -1,11 +1,13 @@ /** * @vitest-environment jsdom */ -import { act, type ComponentProps } from 'react' +import { act, type ComponentProps, Suspense } from 'react' import { createRoot, type Root } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' import { TextEditor } from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor' +import { useFileViewerStore } from '@/stores/file-viewer/store' interface MockMonacoProps { onChange?: (value: string | undefined) => void @@ -15,6 +17,7 @@ interface MockMonacoProps { const state = vi.hoisted(() => ({ content: 'initial', + streaming: false, editorProps: null as MockMonacoProps | null, })) @@ -33,7 +36,7 @@ vi.mock( setDraftContent: (content: string) => { state.content = content }, - isStreamInteractionLocked: false, + isStreamInteractionLocked: state.streaming, isContentLoading: false, hasContentError: false, saveImmediately: vi.fn(), @@ -48,6 +51,11 @@ vi.mock( vi.mock('@/hooks/use-add-to-chat', () => ({ useAddToChat: () => vi.fn() })) +vi.mock('@/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel', () => ({ + resolvePreviewType: (_type: string, name: string) => (name.endsWith('.html') ? 'html' : null), + PreviewPanel: () =>
    , +})) + const file: WorkspaceFileRecord = { id: 'file-1', workspaceId: 'workspace-1', @@ -82,6 +90,8 @@ function createEditor() { }), applyEdits, getFullModelRange: vi.fn(() => ({})), + getLineCount: vi.fn(() => 10), + getLineMaxColumn: vi.fn(() => 8), } const editor = { getModel: vi.fn(() => model), @@ -89,6 +99,11 @@ function createEditor() { getSelection: vi.fn(() => null), onContextMenu: vi.fn(() => ({ dispose: vi.fn() })), onDidDispose: vi.fn(), + onDidScrollChange: vi.fn((_listener: () => void) => ({ dispose: vi.fn() })), + getScrollTop: vi.fn(() => 900), + getScrollHeight: vi.fn(() => 1000), + getLayoutInfo: vi.fn(() => ({ height: 100 })), + revealLine: vi.fn(), } const monaco = { KeyMod: { CtrlCmd: 1 }, @@ -107,13 +122,187 @@ function renderEditor(): { rerender: () => void; root: Root } { } } +function renderSplitEditor(direction = 'ltr') { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + act(() => root.render()) + const separator = container.querySelector('[role="separator"]')! + const surface = container.querySelector('[data-find-tooltip-fix]')! + surface.style.direction = direction + const paneId = separator.getAttribute('aria-controls')! + const sourcePane = document.getElementById(paneId)! + + return { + separator, + surface, + sourcePane, + key: (key: string, options: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...options, + }) + act(() => { + separator.dispatchEvent(event) + }) + return event + }, + unmount: () => { + act(() => root.unmount()) + container.remove() + }, + } +} + describe('TextEditor content synchronization', () => { beforeEach(() => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true state.content = 'initial' + state.streaming = false state.editorProps = null + useFileViewerStore.getState().reset() + }) + + it('shares page recognition with already mounted viewers and keeps it across remounts', () => { + const pageFile = { ...file, id: 'page-a', name: 'page.html', type: 'text/html' } + const container = document.createElement('div') + const root = createRoot(container) + const render = (recognized: boolean) => + act(() => + root.render( + <> + + {recognized && ( + + )} + + ) + ) + + render(false) + expect(container.querySelectorAll('[data-testid="monaco-editor"]')).toHaveLength(1) + render(true) + expect(container.querySelectorAll('[data-testid="monaco-editor"]')).toHaveLength(0) + expect(container.querySelectorAll('[data-testid="preview"]')).toHaveLength(2) + act(() => root.unmount()) + + const remounted = createRoot(container) + act(() => remounted.render()) + expect(container.querySelector('[data-testid="monaco-editor"]')).toBeNull() + expect(container.querySelector('[data-testid="preview"]')).not.toBeNull() + act(() => remounted.unmount()) + }) + + it('does not remember a page from a render that never commits', () => { + const pending = new Promise(() => {}) + function Suspend(): never { + throw pending + } + const root = createRoot(document.createElement('div')) + act(() => + root.render( + Loading
    }> + + + + ) + ) + expect(useFileViewerStore.getState().pageFileIds.has('abandoned-page')).toBe(false) + act(() => root.unmount()) + }) + + it('exposes a focusable splitter with source-pane values and keyboard resizing', () => { + const view = renderSplitEditor() + try { + expect(view.separator.tabIndex).toBe(0) + expect(view.separator.getAttribute('aria-orientation')).toBe('vertical') + expect(view.separator.getAttribute('aria-valuemin')).toBe('20') + expect(view.separator.getAttribute('aria-valuemax')).toBe('80') + expect(view.separator.getAttribute('aria-valuenow')).toBe('50') + expect( + view.sourcePane.contains(document.querySelector('[data-testid="monaco-editor"]')) + ).toBe(true) + act(() => view.separator.focus()) + expect(document.activeElement).toBe(view.separator) + expect(view.key('ArrowRight').defaultPrevented).toBe(true) + expect(view.sourcePane.style.width).toBe('55%') + expect(view.separator.getAttribute('aria-valuenow')).toBe('55') + expect(view.separator.getAttribute('aria-valuetext')).toBe('55% source, 45% preview') + view.key('ArrowLeft') + expect(view.sourcePane.style.width).toBe('50%') + expect(document.activeElement).toBe(view.separator) + } finally { + view.unmount() + } + }) + + it('uses Home and End and clamps arrow keys to the existing pointer bounds', () => { + const view = renderSplitEditor() + try { + view.key('Home') + view.key('ArrowLeft') + expect(view.sourcePane.style.width).toBe('20%') + expect(view.separator.getAttribute('aria-valuenow')).toBe('20') + view.key('End') + view.key('ArrowRight') + expect(view.sourcePane.style.width).toBe('80%') + expect(view.separator.getAttribute('aria-valuenow')).toBe('80') + } finally { + view.unmount() + } + }) + + it('does not consume unrelated, modified, or composing keys on the splitter', () => { + const view = renderSplitEditor() + try { + for (const key of ['Tab', 'ArrowUp', 'Enter', 'a']) { + expect(view.key(key).defaultPrevented).toBe(false) + } + for (const options of [ + { ctrlKey: true }, + { metaKey: true }, + { altKey: true }, + { shiftKey: true }, + { isComposing: true }, + { keyCode: 229 }, + ]) { + expect(view.key('ArrowRight', options).defaultPrevented).toBe(false) + } + expect(view.sourcePane.style.width).toBe('50%') + } finally { + view.unmount() + } }) + it.each(['ltr', 'rtl'])( + 'keeps keyboard and pointer movement aligned in %s layout', + (direction) => { + const view = renderSplitEditor(direction) + try { + view.key('ArrowRight') + expect(view.sourcePane.style.width).toBe(direction === 'rtl' ? '45%' : '55%') + view.key('ArrowLeft') + expect(view.sourcePane.style.width).toBe('50%') + vi.spyOn(view.surface, 'getBoundingClientRect').mockReturnValue( + new DOMRect(100, 0, 1000, 600) + ) + act(() => view.separator.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) + act(() => document.dispatchEvent(new MouseEvent('mousemove', { clientX: 800 }))) + expect(view.sourcePane.style.width).toBe(direction === 'rtl' ? '30%' : '70%') + act(() => document.dispatchEvent(new MouseEvent('mousemove', { clientX: 0 }))) + expect(view.sourcePane.style.width).toBe(direction === 'rtl' ? '80%' : '20%') + act(() => document.dispatchEvent(new MouseEvent('mouseup'))) + } finally { + view.unmount() + } + } + ) + it('does not reread the complete Monaco model after a local edit', () => { const view = renderEditor() const { editor, monaco, getValue } = createEditor() @@ -134,6 +323,26 @@ describe('TextEditor content synchronization', () => { act(() => view.root.unmount()) }) + it('attaches scroll tracking when Monaco mounts after the streaming surface', () => { + state.streaming = true + const view = renderEditor() + const { editor, monaco } = createEditor() + act(() => state.editorProps?.onMount?.(editor, monaco)) + expect(editor.onDidScrollChange).toHaveBeenCalledOnce() + state.content = 'initial append' + view.rerender() + expect(editor.revealLine).toHaveBeenCalled() + editor.revealLine.mockClear() + editor.getScrollTop.mockReturnValue(100) + act(() => editor.onDidScrollChange.mock.calls[0]![0]()) + state.content = 'initial append next' + view.rerender() + expect(editor.revealLine).not.toHaveBeenCalled() + const listener = editor.onDidScrollChange.mock.results[0]!.value + act(() => view.root.unmount()) + expect(listener.dispose).toHaveBeenCalledOnce() + }) + it('still reconciles an external update when the editor has no local changes', () => { const view = renderEditor() const { editor, monaco, getValue, applyEdits } = createEditor() @@ -150,4 +359,17 @@ describe('TextEditor content synchronization', () => { expect(applyEdits).toHaveBeenCalledWith([{ range: {}, text: 'server update' }]) act(() => view.root.unmount()) }) + + it('applies a deliberate conflict reload after the editor previously held a local draft', () => { + const view = renderEditor() + const { editor, monaco, model, applyEdits } = createEditor() + act(() => state.editorProps?.onMount?.(editor, monaco)) + model.setValue('local draft') + act(() => state.editorProps?.onChange?.('local draft')) + view.rerender() + state.content = 'reloaded remote' + view.rerender() + expect(applyEdits).toHaveBeenCalledWith([{ range: {}, text: 'reloaded remote' }]) + act(() => view.root.unmount()) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 883bf4bc6f4..b6ce213337b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -3,8 +3,11 @@ import { memo, type ClipboardEvent as ReactClipboardEvent, + type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, + useId, + useLayoutEffect, useMemo, useRef, useState, @@ -21,18 +24,20 @@ import { import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { EditorContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu' +import { FileSaveConflict } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict' +import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer' +import { + PreviewPanel, + resolvePreviewType, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel' +import { PreviewLoadingFrame } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared' import { assessTextEditorPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste' +import { useEditableFileContent } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content' +import { useSelectionCopyBridge } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge' import { useAddToChat } from '@/hooks/use-add-to-chat' +import { useFileViewerStore } from '@/stores/file-viewer/store' import type { ChatContext } from '@/stores/panel' -import { EditorContextMenu } from './editor-context-menu' -import type { PreviewMode } from './file-viewer' -import { PreviewPanel, resolvePreviewType } from './preview-panel' -import { PreviewLoadingFrame } from './preview-shared' -import { useEditableFileContent } from './use-editable-file-content' -import { useSelectionCopyBridge } from './use-selection-copy-bridge' - -/** File ids observed rendering as Sim pages this session (see the sticky lock). */ -const KNOWN_PAGE_FILE_IDS = new Set() const TEXT_EDITOR_OPTIONS = { largeFileOptimizations: true, @@ -267,6 +272,7 @@ const MonacoEditor = dynamic( const SPLIT_MIN_PCT = 20 const SPLIT_MAX_PCT = 80 const SPLIT_DEFAULT_PCT = 50 +const SPLIT_KEYBOARD_STEP_PCT = 5 /** * Monaco's find-widget button hover tooltips render above the button by default. Because the find @@ -422,12 +428,13 @@ export const TextEditor = memo(function TextEditor({ const containerRef = useRef(null) const monacoEditorRef = useRef[0] | null>(null) const lastEditorValueRef = useRef('') - const lastSyncedContentRef = useRef('') const hasAutoFocusedRef = useRef(false) const contentRef = useRef('') const textareaStuckRef = useRef(false) const suppressScrollListenerRef = useRef(false) + const [mountedEditor, setMountedEditor] = useState[0] | null>(null) + const sourcePaneId = useId() const [splitPct, setSplitPct] = useState(SPLIT_DEFAULT_PCT) const [isResizing, setIsResizing] = useState(false) const [contextMenu, setContextMenu] = useState<{ @@ -478,6 +485,10 @@ export const TextEditor = memo(function TextEditor({ isContentLoading, hasContentError, saveImmediately, + hasConflict, + isReloading, + reloadLatestContent, + downloadDraft, } = useEditableFileContent({ file, workspaceId, @@ -489,7 +500,9 @@ export const TextEditor = memo(function TextEditor({ saveRef, discardRef, }) - contentRef.current = content + useLayoutEffect(() => { + contentRef.current = content + }, [content]) // Enable once content has loaded — the container (and Monaco) only mount after // the `isContentLoading` early return below, so the bridge must (re-)attach then. @@ -505,42 +518,38 @@ export const TextEditor = memo(function TextEditor({ const monacoValue = model.getValue() lastEditorValueRef.current = monacoValue if (monacoValue === content) return - - if (isStreamInteractionLocked || monacoValue === lastSyncedContentRef.current) { - if (isStreamInteractionLocked) { - const scrollTop = editor.getScrollTop() - const scrollHeight = editor.getScrollHeight() - const { height } = editor.getLayoutInfo() - if (scrollHeight - scrollTop - height < 80) { - textareaStuckRef.current = true - } + if (isStreamInteractionLocked) { + const scrollTop = editor.getScrollTop() + const scrollHeight = editor.getScrollHeight() + const { height } = editor.getLayoutInfo() + if (scrollHeight - scrollTop - height < 80) { + textareaStuckRef.current = true } - suppressScrollListenerRef.current = true - if (content.startsWith(monacoValue) && monacoValue.length < content.length) { - const lastLine = model.getLineCount() - const lastCol = model.getLineMaxColumn(lastLine) - model.applyEdits([ - { - range: { - startLineNumber: lastLine, - startColumn: lastCol, - endLineNumber: lastLine, - endColumn: lastCol, - }, - text: content.slice(monacoValue.length), + } + suppressScrollListenerRef.current = true + if (content.startsWith(monacoValue) && monacoValue.length < content.length) { + const lastLine = model.getLineCount() + const lastCol = model.getLineMaxColumn(lastLine) + model.applyEdits([ + { + range: { + startLineNumber: lastLine, + startColumn: lastCol, + endLineNumber: lastLine, + endColumn: lastCol, }, - ]) - } else { - model.applyEdits([{ range: model.getFullModelRange(), text: content }]) - } - suppressScrollListenerRef.current = false - lastEditorValueRef.current = content - lastSyncedContentRef.current = content + text: content.slice(monacoValue.length), + }, + ]) + } else { + model.applyEdits([{ range: model.getFullModelRange(), text: content }]) } + suppressScrollListenerRef.current = false + lastEditorValueRef.current = content }, [content, isStreamInteractionLocked]) useEffect(() => { - const editor = monacoEditorRef.current + const editor = mountedEditor if (!editor || !isStreamInteractionLocked || disableStreamingAutoScroll) { textareaStuckRef.current = false return @@ -559,7 +568,7 @@ export const TextEditor = memo(function TextEditor({ return () => { disposable.dispose() } - }, [isStreamInteractionLocked, disableStreamingAutoScroll]) + }, [mountedEditor, isStreamInteractionLocked, disableStreamingAutoScroll]) useEffect(() => { if (!isStreamInteractionLocked || !textareaStuckRef.current || disableStreamingAutoScroll) @@ -579,7 +588,9 @@ export const TextEditor = memo(function TextEditor({ const container = containerRef.current if (!container) return const rect = container.getBoundingClientRect() - const pct = ((e.clientX - rect.left) / rect.width) * 100 + const isRtl = getComputedStyle(container).direction === 'rtl' + const sourceWidth = isRtl ? rect.right - e.clientX : e.clientX - rect.left + const pct = (sourceWidth / rect.width) * 100 setSplitPct(Math.min(SPLIT_MAX_PCT, Math.max(SPLIT_MIN_PCT, pct))) } @@ -598,8 +609,34 @@ export const TextEditor = memo(function TextEditor({ } }, [isResizing]) + const handleSplitKeyDown = (event: ReactKeyboardEvent) => { + if ( + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.nativeEvent.isComposing || + event.keyCode === 229 + ) { + return + } + const { key } = event + if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'Home' && key !== 'End') return + event.preventDefault() + event.stopPropagation() + if (key === 'Home' || key === 'End') { + setSplitPct(key === 'Home' ? SPLIT_MIN_PCT : SPLIT_MAX_PCT) + return + } + const container = containerRef.current + const isRtl = container !== null && getComputedStyle(container).direction === 'rtl' + const delta = (key === 'ArrowLeft' ? -1 : 1) * (isRtl ? -1 : 1) * SPLIT_KEYBOARD_STEP_PCT + setSplitPct((current) => Math.min(SPLIT_MAX_PCT, Math.max(SPLIT_MIN_PCT, current + delta))) + } + const handleEditorMount: OnMount = (editor, monaco) => { monacoEditorRef.current = editor + setMountedEditor(editor) editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { saveImmediately() @@ -611,7 +648,6 @@ export const TextEditor = memo(function TextEditor({ if (model.getValue() !== currentContent) { model.setValue(currentContent) } - lastSyncedContentRef.current = currentContent lastEditorValueRef.current = currentContent } @@ -703,12 +739,13 @@ export const TextEditor = memo(function TextEditor({ (previewType === 'html' && (isSimPageSource(content) || (isStreaming && (trimmedContent === '' || trimmedContent.startsWith('-'))))) - // Sticky per file: a PATCH stream carries only the replacement snippet, - // which is not frontmatter-shaped — without memory the lock would drop - // mid-edit and flash raw source. Once a file is known to be a page, it - // stays one for the session. - if (detectedSimPage) KNOWN_PAGE_FILE_IDS.add(file.id) - const isSimPageFile = detectedSimPage || KNOWN_PAGE_FILE_IDS.has(file.id) + const knownPage = useFileViewerStore((state) => state.pageFileIds.has(file.id)) + const rememberPage = useFileViewerStore((state) => state.rememberPage) + /** Share only committed observations, before another viewer can paint partial page source. */ + useLayoutEffect(() => { + if (detectedSimPage) rememberPage(file.id) + }, [detectedSimPage, file.id, rememberPage]) + const isSimPageFile = detectedSimPage || knownPage const effectiveMode = isSimPageFile ? 'preview' : isStreaming && isIframeRendered @@ -730,121 +767,144 @@ export const TextEditor = memo(function TextEditor({ const closeContextMenu = () => setContextMenu(null) return ( -
    - - {showEditor && ( -
    - -
    +
    + {hasConflict && ( + )} - {showPreviewPane && ( - <> - {showEditor && ( -
    -
    -
    setIsResizing(true)} - role='separator' - aria-orientation='vertical' - aria-label='Resize split' - /> - {isResizing && ( -
    - )} -
    - )} +
    + + {showEditor && (
    -
    - - )} - {contextMenu && ( - { - handleAddSelectionToChat() - closeContextMenu() - }} - onCut={() => { - monacoEditorRef.current?.focus() - monacoEditorRef.current?.trigger( - 'contextmenu', - 'editor.action.clipboardCutAction', - null - ) - closeContextMenu() - }} - onCopy={() => { - monacoEditorRef.current?.focus() - monacoEditorRef.current?.trigger( - 'contextmenu', - 'editor.action.clipboardCopyAction', - null - ) - closeContextMenu() - }} - onCopyAll={() => { - navigator.clipboard.writeText(monacoEditorRef.current?.getValue() ?? '').catch(() => {}) - closeContextMenu() - }} - onPaste={() => { - monacoEditorRef.current?.focus() - monacoEditorRef.current?.trigger( - 'contextmenu', - 'editor.action.clipboardPasteAction', - null - ) - closeContextMenu() - }} - onSelectAll={() => { - monacoEditorRef.current?.focus() - monacoEditorRef.current?.trigger('contextmenu', 'editor.action.selectAll', null) - closeContextMenu() - }} - onFind={() => { - monacoEditorRef.current?.getAction('actions.find')?.run() - closeContextMenu() - }} - /> - )} + )} + {showPreviewPane && ( + <> + {showEditor && ( +
    +
    +
    setIsResizing(true)} + onKeyDown={handleSplitKeyDown} + tabIndex={0} + role='separator' + aria-orientation='vertical' + aria-label='Resize split' + aria-controls={sourcePaneId} + aria-valuemin={SPLIT_MIN_PCT} + aria-valuemax={SPLIT_MAX_PCT} + aria-valuenow={splitPct} + aria-valuetext={`${Math.round(splitPct)}% source, ${Math.round(100 - splitPct)}% preview`} + /> + {isResizing && ( +
    + )} +
    + )} +
    + +
    + + )} + {contextMenu && ( + { + handleAddSelectionToChat() + closeContextMenu() + }} + onCut={() => { + monacoEditorRef.current?.focus() + monacoEditorRef.current?.trigger( + 'contextmenu', + 'editor.action.clipboardCutAction', + null + ) + closeContextMenu() + }} + onCopy={() => { + monacoEditorRef.current?.focus() + monacoEditorRef.current?.trigger( + 'contextmenu', + 'editor.action.clipboardCopyAction', + null + ) + closeContextMenu() + }} + onCopyAll={() => { + navigator.clipboard + .writeText(monacoEditorRef.current?.getValue() ?? '') + .catch(() => {}) + closeContextMenu() + }} + onPaste={() => { + monacoEditorRef.current?.focus() + monacoEditorRef.current?.trigger( + 'contextmenu', + 'editor.action.clipboardPasteAction', + null + ) + closeContextMenu() + }} + onSelectAll={() => { + monacoEditorRef.current?.focus() + monacoEditorRef.current?.trigger('contextmenu', 'editor.action.selectAll', null) + closeContextMenu() + }} + onFind={() => { + monacoEditorRef.current?.getAction('actions.find')?.run() + closeContextMenu() + }} + /> + )} +
    ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-conflicts.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-conflicts.test.tsx new file mode 100644 index 00000000000..91c05073ee7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-conflicts.test.tsx @@ -0,0 +1,507 @@ +/** + * @vitest-environment jsdom + */ +import { act, createRef, StrictMode, Suspense, startTransition, useLayoutEffect } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiClientError } from '@/lib/api/client/errors' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { useEditableFileContent } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content' + +const mocks = vi.hoisted(() => ({ + query: { content: 'original' as string | undefined }, + save: vi.fn(), + reload: vi.fn(), + readDraft: vi.fn(), + writeDraft: vi.fn(), + deleteDraft: vi.fn(), +})) +vi.mock('@/hooks/queries/workspace-files', () => ({ + useWorkspaceFileContent: () => ({ data: mocks.query.content, isLoading: false, error: null }), + useUpdateWorkspaceFileContent: () => ({ mutateAsync: mocks.save }), + useReloadWorkspaceFileContent: () => ({ mutateAsync: mocks.reload, isPending: false }), +})) +vi.mock('idb-keyval', () => ({ + get: mocks.readDraft, + set: mocks.writeDraft, + del: mocks.deleteDraft, +})) + +const V1 = '2026-09-03T20:00:00.000Z' +const V2 = '2026-09-03T20:00:01.000Z' +const V3 = '2026-09-03T20:00:02.000Z' +const FILE: WorkspaceFileRecord = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'notes.txt', + type: 'text/plain', + key: 'immutable-v1', + path: '/file', + size: 8, + uploadedBy: 'user-1', + uploadedAt: new Date(V1), + updatedAt: new Date(V1), + contentUpdatedAt: new Date(V1), +} +let latest: ReturnType +let root: Root +let container: HTMLDivElement +let file = FILE +let streamingContent: string | undefined +let isAgentEditing = false +const discardRef = createRef<(() => void) | null>() + +function Probe() { + latest = useEditableFileContent({ + file, + workspaceId: FILE.workspaceId, + canEdit: true, + discardRef, + streamingContent, + isAgentEditing, + }) + return null +} +async function render() { + await act(async () => root.render()) +} +async function edit(content: string) { + await act(async () => latest.setDraftContent(content)) +} +async function advance(ms = 2000) { + await act(async () => vi.advanceTimersByTimeAsync(ms)) +} + +interface TransitionProbeProps { + streamingContent?: string + isAgentEditing?: boolean +} + +function TransitionProbe(options: TransitionProbeProps) { + const state = useEditableFileContent({ + file, + workspaceId: FILE.workspaceId, + canEdit: true, + discardRef, + ...options, + }) + useLayoutEffect(() => { + latest = state + }) + return
    {state.content}
    +} + +/** Suspend after the hook finishes its render-phase reconciliation, without committing the tree. */ +const pendingTransition = new Promise(() => {}) +function SuspendTransition(): never { + throw pendingTransition +} + +async function renderTransition(options: TransitionProbeProps = {}, suspend = false) { + const view = ( + Loading
    }> + + {suspend && } + + ) + await act(async () => { + if (suspend) startTransition(() => root.render(view)) + else root.render(view) + }) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + file = FILE + streamingContent = undefined + isAgentEditing = false + mocks.query.content = 'original' + mocks.readDraft.mockResolvedValue(undefined) + mocks.writeDraft.mockResolvedValue(undefined) + mocks.deleteDraft.mockResolvedValue(undefined) + mocks.save.mockResolvedValue({ success: true, file: { ...FILE, contentUpdatedAt: new Date(V2) } }) + container = document.createElement('div') + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.useRealTimers() +}) + +describe('versioned solo-file editing', () => { + it('does not block a committed save when an abandoned render observes an agent stream', async () => { + await renderTransition() + await edit('committed local draft') + const committed = latest + await renderTransition( + { streamingContent: 'abandoned agent frame', isAgentEditing: true }, + true + ) + expect(container.textContent).toBe('committed local draft') + expect(latest.hasConflict).toBe(false) + await act(async () => committed.saveImmediately()) + expect(mocks.save).toHaveBeenCalledWith( + expect.objectContaining({ content: 'committed local draft', expectedUpdatedAt: V1 }) + ) + }) + + it('keeps the committed agent reload lock when a suspended render removes it', async () => { + await renderTransition({ isAgentEditing: true }) + const committed = latest + await renderTransition({}, true) + expect(latest.isStreamInteractionLocked).toBe(true) + await act(async () => { + await expect(committed.reloadLatestContent()).rejects.toThrow('Wait for the agent edit') + }) + expect(mocks.reload).not.toHaveBeenCalled() + }) + + it('discards to the committed baseline after a suspended remote-baseline render', async () => { + await renderTransition() + await edit('local draft') + const committedDiscard = discardRef.current + file = { ...FILE, key: 'immutable-v2', contentUpdatedAt: new Date(V2) } + mocks.query.content = 'abandoned remote baseline' + await renderTransition({}, true) + expect(container.textContent).toBe('local draft') + file = FILE + mocks.query.content = 'original' + await act(async () => committedDiscard?.()) + expect(latest.content).toBe('original') + expect(mocks.save).not.toHaveBeenCalled() + }) + + it('exports the visible committed content rather than a suspended stream snapshot', async () => { + const OriginalBlob = Blob + const createBlob = vi.fn(class extends OriginalBlob {}) + const createObjectURL = vi.fn(() => 'blob:local-draft') + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + vi.stubGlobal('Blob', createBlob) + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL: vi.fn() }) + try { + await renderTransition() + const committed = latest + await renderTransition({ streamingContent: 'abandoned agent frame' }, true) + expect(container.textContent).toBe('original') + act(() => committed.downloadDraft()) + expect(createBlob).toHaveBeenCalledWith(['original'], { + type: 'text/plain;charset=utf-8', + }) + expect(click).toHaveBeenCalledOnce() + await advance(0) + } finally { + click.mockRestore() + vi.unstubAllGlobals() + } + }) + + it('keeps a stream conflict and trailing draft when an earlier save finishes during the stream', async () => { + const pending = Promise.withResolvers<{ success: boolean; file: WorkspaceFileRecord }>() + mocks.save.mockReturnValueOnce(pending.promise) + await render() + await edit('first draft') + let saving: Promise | undefined + act(() => { + saving = latest.saveImmediately() + }) + await edit('trailing draft') + streamingContent = 'agent partial' + await render() + await act(async () => { + pending.resolve({ success: true, file: { ...FILE, contentUpdatedAt: new Date(V2) } }) + await saving + }) + expect(latest.content).toBe('trailing draft') + expect(latest.hasConflict).toBe(true) + expect(latest.isStreamInteractionLocked).toBe(true) + await act(async () => root.unmount()) + root = createRoot(container) + expect(mocks.writeDraft).toHaveBeenCalledWith(expect.any(String), { + content: 'trailing draft', + savedContent: 'first draft', + }) + expect(mocks.save).toHaveBeenCalledTimes(1) + }) + + it('persists a dirty draft on unmount when the agent lock arrives before the first stream chunk', async () => { + await render() + await edit('local not yet persisted') + isAgentEditing = true + await render() + expect(latest.isStreamInteractionLocked).toBe(true) + await act(async () => root.unmount()) + root = createRoot(container) + expect(mocks.writeDraft).toHaveBeenCalledWith(expect.any(String), { + content: 'local not yet persisted', + savedContent: 'original', + }) + expect(mocks.save).not.toHaveBeenCalled() + }) + + it.each(['original', 'older server'])( + 'recovers a draft through StrictMode effect replay (baseline=%s)', + async (savedContent) => { + mocks.readDraft.mockResolvedValue({ content: 'recovered local', savedContent }) + await act(async () => + root.render( + + + + ) + ) + expect(latest.content).toBe('recovered local') + expect(latest.hasConflict).toBe(savedContent !== 'original') + } + ) + + it('preserves and persists a dirty local draft when an agent stream starts', async () => { + await render() + await edit('local unsaved') + streamingContent = 'agent partial' + await render() + expect(latest.content).toBe('local unsaved') + expect(latest.hasConflict).toBe(true) + expect(latest.isStreamInteractionLocked).toBe(true) + await advance() + expect(mocks.save).not.toHaveBeenCalled() + expect(mocks.writeDraft).toHaveBeenCalledWith(expect.any(String), { + content: 'local unsaved', + savedContent: 'original', + }) + await act(async () => { + await expect(latest.reloadLatestContent()).rejects.toThrow('Wait for the agent edit') + }) + expect(mocks.reload).not.toHaveBeenCalled() + file = { ...FILE, key: 'immutable-v2', contentUpdatedAt: new Date(V2) } + mocks.query.content = 'agent final' + streamingContent = undefined + await render() + expect(latest.content).toBe('local unsaved') + expect(latest.hasConflict).toBe(true) + expect(latest.isStreamInteractionLocked).toBe(false) + mocks.reload.mockResolvedValueOnce({ content: 'agent final', file }) + await act(async () => latest.reloadLatestContent()) + expect(latest.content).toBe('agent final') + expect(latest.hasConflict).toBe(false) + }) + + it('sends the original content version even when metadata advances before bytes arrive', async () => { + await render() + await edit('local') + file = { ...FILE, key: 'immutable-v2', contentUpdatedAt: new Date(V2) } + mocks.query.content = undefined + await render() + await act(async () => latest.saveImmediately()) + expect(mocks.save).toHaveBeenCalledWith( + expect.objectContaining({ content: 'local', expectedUpdatedAt: V1 }) + ) + }) + + it('retains both versions and pauses saves when dirty content receives a remote update', async () => { + await render() + await edit('local') + file = { ...FILE, key: 'immutable-v2', contentUpdatedAt: new Date(V2) } + mocks.query.content = 'remote' + await render() + expect(latest.content).toBe('local') + expect(latest.hasConflict).toBe(true) + expect(latest.isDirty).toBe(true) + expect(latest.saveStatus).toBe('error') + await edit('local continuing') + await advance() + await act(async () => latest.saveImmediately()) + expect(mocks.save).not.toHaveBeenCalled() + expect(mocks.writeDraft).toHaveBeenCalledWith(expect.any(String), { + content: 'local continuing', + savedContent: 'original', + }) + }) + + it('does not retry a rejected CAS on typing, manual save, or unmount', async () => { + mocks.save.mockRejectedValue( + new ApiClientError({ status: 409, message: 'File changed', body: {} }) + ) + await render() + await edit('local') + await act(async () => latest.saveImmediately()) + expect(latest.hasConflict).toBe(true) + await edit('local newer') + await advance(5000) + await act(async () => latest.saveImmediately()) + await act(async () => root.unmount()) + root = createRoot(container) + expect(mocks.save).toHaveBeenCalledTimes(1) + expect(mocks.writeDraft).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ content: 'local newer' }) + ) + }) + + it('keeps an unversioned remote conflict paused after an in-flight save succeeds', async () => { + const pending = Promise.withResolvers<{ success: boolean; file: WorkspaceFileRecord }>() + mocks.save.mockReturnValueOnce(pending.promise) + await render() + await edit('local saved') + let saving: Promise | undefined + act(() => { + saving = latest.saveImmediately() + }) + await edit('local trailing') + file = { ...FILE, key: 'unknown-version', contentUpdatedAt: null } + mocks.query.content = 'remote' + await render() + expect(latest.hasConflict).toBe(true) + await act(async () => { + pending.resolve({ success: true, file: { ...FILE, contentUpdatedAt: new Date(V2) } }) + await saving + }) + expect(latest.hasConflict).toBe(true) + expect(latest.content).toBe('local trailing') + await advance() + await act(async () => latest.saveImmediately()) + expect(mocks.save).toHaveBeenCalledTimes(1) + file = { ...FILE, key: 'immutable-v3', contentUpdatedAt: new Date(V3) } + mocks.reload.mockResolvedValueOnce({ content: 'latest remote', file }) + await act(async () => latest.reloadLatestContent()) + expect(latest.hasConflict).toBe(false) + await edit('resolved') + await act(async () => latest.saveImmediately()) + expect(mocks.save).toHaveBeenLastCalledWith(expect.objectContaining({ expectedUpdatedAt: V3 })) + }) + + it('uses the successful response token for trailing edits before the file-list refetch', async () => { + const pending = Promise.withResolvers<{ success: boolean; file: WorkspaceFileRecord }>() + mocks.save.mockReturnValueOnce(pending.promise) + await render() + await edit('first') + let saving: Promise | undefined + act(() => { + saving = latest.saveImmediately() + }) + await edit('second') + await act(async () => { + pending.resolve({ success: true, file: { ...FILE, contentUpdatedAt: new Date(V2) } }) + await saving + }) + await advance(700) + expect(mocks.save).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ content: 'second', expectedUpdatedAt: V2 }) + ) + expect(latest.content).toBe('second') + }) + + it('keeps a recovered local draft when its former baseline differs from the server', async () => { + mocks.readDraft.mockResolvedValue({ content: 'recovered local', savedContent: 'older server' }) + await render() + expect(latest.content).toBe('recovered local') + expect(latest.hasConflict).toBe(true) + await advance() + expect(mocks.save).not.toHaveBeenCalled() + expect(mocks.deleteDraft).not.toHaveBeenCalled() + }) + + it('preserves the draft on reload failure, then accepts explicitly reloaded bytes and their token', async () => { + await render() + await edit('local') + file = { ...FILE, key: 'immutable-v2', contentUpdatedAt: new Date(V2) } + mocks.query.content = 'remote' + await render() + mocks.reload.mockRejectedValueOnce(new Error('offline')) + await act(async () => { + await expect(latest.reloadLatestContent()).rejects.toThrow('offline') + }) + expect(latest.content).toBe('local') + expect(latest.hasConflict).toBe(true) + mocks.reload.mockResolvedValueOnce({ + content: 'latest remote', + file: { ...FILE, key: 'immutable-v3', contentUpdatedAt: new Date(V3) }, + }) + await act(async () => latest.reloadLatestContent()) + expect(latest.content).toBe('latest remote') + expect(latest.hasConflict).toBe(false) + expect(latest.isDirty).toBe(false) + expect(latest.acceptedBaselineContent).toBe('latest remote') + await edit('resolved') + await act(async () => latest.saveImmediately()) + expect(mocks.save).toHaveBeenCalledWith(expect.objectContaining({ expectedUpdatedAt: V3 })) + }) + + it('does not undo a deliberate reload when an older save acknowledgement arrives afterward', async () => { + const pending = Promise.withResolvers<{ success: boolean; file: WorkspaceFileRecord }>() + mocks.save.mockReturnValueOnce(pending.promise) + await render() + await edit('local saved') + let saving: Promise | undefined + act(() => { + saving = latest.saveImmediately() + }) + file = { ...FILE, key: 'immutable-v3', contentUpdatedAt: new Date(V3) } + mocks.query.content = 'latest remote' + await render() + expect(latest.hasConflict).toBe(true) + mocks.reload.mockResolvedValueOnce({ content: 'latest remote', file }) + await act(async () => latest.reloadLatestContent()) + await act(async () => { + pending.resolve({ success: true, file: { ...FILE, contentUpdatedAt: new Date(V2) } }) + await saving + }) + await advance(5000) + expect(latest.content).toBe('latest remote') + expect(latest.isDirty).toBe(false) + expect(mocks.save).toHaveBeenCalledTimes(1) + await edit('resolved') + await act(async () => latest.saveImmediately()) + expect(mocks.save).toHaveBeenLastCalledWith(expect.objectContaining({ expectedUpdatedAt: V3 })) + }) + + it('preserves edits made while a conflict reload is pending', async () => { + await render() + await edit('local') + file = { ...FILE, key: 'immutable-v2', contentUpdatedAt: new Date(V2) } + mocks.query.content = 'remote' + await render() + const pending = Promise.withResolvers<{ content: string; file: WorkspaceFileRecord }>() + mocks.reload.mockReturnValueOnce(pending.promise) + let reloading: Promise | undefined + act(() => { + reloading = latest.reloadLatestContent() + }) + await edit('local newer') + await act(async () => { + pending.resolve({ content: 'remote', file }) + await expect(reloading).rejects.toThrow('Your draft changed') + }) + expect(latest.content).toBe('local newer') + expect(latest.hasConflict).toBe(true) + }) + + it('does not re-enter conflict when a rejected older save arrives after explicit reload', async () => { + const pending = Promise.withResolvers<{ success: boolean; file: WorkspaceFileRecord }>() + mocks.save.mockReturnValueOnce(pending.promise) + await render() + await edit('local') + let saving: Promise | undefined + act(() => { + saving = latest.saveImmediately() + }) + file = { ...FILE, key: 'immutable-v3', contentUpdatedAt: new Date(V3) } + mocks.query.content = 'latest remote' + await render() + mocks.reload.mockResolvedValueOnce({ content: 'latest remote', file }) + await act(async () => latest.reloadLatestContent()) + await act(async () => { + pending.reject(new ApiClientError({ status: 409, message: 'File changed', body: {} })) + await saving + }) + expect(latest.content).toBe('latest remote') + expect(latest.hasConflict).toBe(false) + expect(latest.isDirty).toBe(false) + await edit('resolved') + await act(async () => latest.saveImmediately()) + expect(mocks.save).toHaveBeenLastCalledWith(expect.objectContaining({ expectedUpdatedAt: V3 })) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.test.tsx index 58c5447df66..69b8894937a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.test.tsx @@ -33,6 +33,7 @@ vi.mock('@/hooks/queries/workspace-files', () => ({ return { data: queryState.fetched, isLoading: queryState.fetched === undefined, error: null } }, useUpdateWorkspaceFileContent: () => ({ mutateAsync: vi.fn(async () => ({ success: true })) }), + useReloadWorkspaceFileContent: () => ({ mutateAsync: vi.fn(), isPending: false }), })) vi.mock('idb-keyval', () => ({ @@ -46,7 +47,7 @@ import { RECONCILING_REFETCH_SLOW_INTERVAL_MS, RECONCILING_REFETCH_WINDOW_MS, useEditableFileContent, -} from './use-editable-file-content' +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content' const FILE = { id: 'f1', diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts index db6206c43b1..7e57f6834c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts @@ -1,20 +1,23 @@ 'use client' -import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from 'react' import { toast } from '@sim/emcn' +import { isApiClientError } from '@/lib/api/client/errors' +import { saveBlob } from '@/lib/uploads/client/download' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { GENERATED_DOCUMENT_SOURCE_TYPES } from '@/lib/uploads/utils/file-utils' import { + INITIAL_TEXT_EDITOR_CONTENT_STATE, + type SyncTextEditorContentStateOptions, + textEditorContentReducer, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state' +import { + useReloadWorkspaceFileContent, useUpdateWorkspaceFileContent, useWorkspaceFileContent, } from '@/hooks/queries/workspace-files' import { type SaveStatus, useAutosave } from '@/hooks/use-autosave' import { useSmoothText } from '@/hooks/use-smooth-text' -import { - INITIAL_TEXT_EDITOR_CONTENT_STATE, - type SyncTextEditorContentStateOptions, - textEditorContentReducer, -} from './text-editor-state' /** * Generated-document source files (`.pptx`/`.docx`/`.pdf`/`.xlsx` builders) whose @@ -86,6 +89,11 @@ interface EditableFileContent { saveStatus: SaveStatus saveImmediately: () => Promise isDirty: boolean + hasConflict: boolean + isReloading: boolean + reloadLatestContent: () => Promise + downloadDraft: () => void + acceptedBaselineContent?: string } /** @@ -95,15 +103,15 @@ interface EditableFileContent { function useFileContentState(options: SyncTextEditorContentStateOptions) { const [state, dispatch] = useReducer(textEditorContentReducer, INITIAL_TEXT_EDITOR_CONTENT_STATE) - const prevOptionsRef = useRef(null) - const prev = prevOptionsRef.current + const [prev, setPrev] = useState(null) if ( prev === null || prev.canReconcileToFetchedContent !== options.canReconcileToFetchedContent || prev.fetchedContent !== options.fetchedContent || + prev.fetchedVersion !== options.fetchedVersion || prev.streamingContent !== options.streamingContent ) { - prevOptionsRef.current = options + setPrev(options) dispatch({ type: 'sync-external', ...options }) } @@ -111,10 +119,20 @@ function useFileContentState(options: SyncTextEditorContentStateOptions) { dispatch({ type: 'edit', content }) }, []) - const markSavedContent = useCallback((content: string) => { - dispatch({ type: 'save-success', content }) + const markSavedContent = useCallback((content: string, version?: string) => { + dispatch({ type: 'save-success', content, version }) }, []) + const markConflict = useCallback(() => dispatch({ type: 'save-conflict' }), []) + const restoreConflictingDraft = useCallback( + (content: string) => dispatch({ type: 'restore-conflicting-draft', content }), + [] + ) + const acceptReload = useCallback( + (content: string, version: string) => dispatch({ type: 'reload', content, version }), + [] + ) + return { content: state.content, savedContent: state.savedContent, @@ -123,6 +141,12 @@ function useFileContentState(options: SyncTextEditorContentStateOptions) { isReconciling: state.phase === 'reconciling', setDraftContent, markSavedContent, + savedVersion: state.savedVersion, + hasConflict: Boolean(state.conflict), + acceptedBaselineContent: state.acceptedBaselineContent, + markConflict, + restoreConflictingDraft, + acceptReload, } } @@ -147,8 +171,6 @@ export function useEditableFileContent({ }: UseEditableFileContentOptions): EditableFileContent { const onDirtyChangeRef = useRef(onDirtyChange) const onSaveStatusChangeRef = useRef(onSaveStatusChange) - onDirtyChangeRef.current = onDirtyChange - onSaveStatusChangeRef.current = onSaveStatusChange /** * Mirrors the reducer's `reconciling` phase (assigned below the reducer hook; read here through a @@ -189,27 +211,38 @@ export function useEditableFileContent({ } ) - /** - * Latches once this mount has ever streamed (agent edit). A mount that streams keeps the raw fetched - * value as its baseline for its whole life, so normalization can never perturb the stream-reconcile - * comparisons in {@link syncTextEditorContentState}. A pure at-rest open never latches and normalizes - * freely. Set during render (not an effect) so it is observed before the baseline is derived. - */ - const everStreamedRef = useRef(false) - if (streamingContent !== undefined || isAgentEditing) everStreamedRef.current = true - - // Re-derived only when the fetched content changes (never on a stream-flag flip), so the dirty - // baseline stays stable through a post-stream reconcile. - const baselineContent = useMemo(() => { - if (fetchedContent === undefined || !normalizeBaseline || everStreamedRef.current) { - return fetchedContent - } - return normalizeBaseline(fetchedContent) - }, [fetchedContent, normalizeBaseline]) + /** A stream-only transition retains the accepted representation; subsequent fetched snapshots stay raw for reconciliation. */ + const isStreamObserved = streamingContent !== undefined || Boolean(isAgentEditing) + const [baseline, setBaseline] = useState(() => ({ + source: fetchedContent, + normalizer: normalizeBaseline, + everStreamed: isStreamObserved, + content: + fetchedContent !== undefined && normalizeBaseline && !isStreamObserved + ? normalizeBaseline(fetchedContent) + : fetchedContent, + })) + const everStreamed = baseline.everStreamed || isStreamObserved + const sourceChanged = + baseline.source !== fetchedContent || baseline.normalizer !== normalizeBaseline + if (sourceChanged || everStreamed !== baseline.everStreamed) { + setBaseline({ + source: fetchedContent, + normalizer: normalizeBaseline, + everStreamed, + content: sourceChanged + ? fetchedContent !== undefined && normalizeBaseline && !everStreamed + ? normalizeBaseline(fetchedContent) + : fetchedContent + : baseline.content, + }) + } + const baselineContent = baseline.content + const everStreamedRef = useRef(everStreamed) const updateContent = useUpdateWorkspaceFileContent() + const reloadContent = useReloadWorkspaceFileContent() const updateContentRef = useRef(updateContent) - updateContentRef.current = updateContent const { content, @@ -219,15 +252,25 @@ export function useEditableFileContent({ isReconciling, setDraftContent, markSavedContent, + savedVersion, + hasConflict, + acceptedBaselineContent, + markConflict, + restoreConflictingDraft, + acceptReload, } = useFileContentState({ canReconcileToFetchedContent: file.key.length > 0, fetchedContent: baselineContent, + fetchedVersion: + fetchedContent !== undefined && file.contentUpdatedAt + ? new Date(file.contentUpdatedAt).toISOString() + : undefined, streamingContent, }) - if (isReconciling && !isReconcilingRef.current) reconcilingSinceRef.current = Date.now() - isReconcilingRef.current = isReconciling - - const isStreamInteractionLocked = isStreamPhaseLocked || Boolean(isAgentEditing) + const isAgentStreamActive = streamingContent !== undefined || Boolean(isAgentEditing) + const streamActiveRef = useRef(isAgentStreamActive) + const isStreamInteractionLocked = + isStreamPhaseLocked || Boolean(isAgentEditing) || (hasConflict && isAgentStreamActive) // Pace the streamed reveal for DISPLAY only. The reducer above keeps the true content so // reconciliation, dirty tracking, and saves are never thrown off by the paced prefix. Pacing is @@ -239,31 +282,137 @@ export function useEditableFileContent({ const displayContent = isStreamPhaseLocked ? pacedReveal : content const contentRef = useRef(content) - contentRef.current = content + const saveVersionRef = useRef(savedVersion) + const conflictRef = useRef(hasConflict) + + /** Publish one committed draft/baseline to asynchronous saves, reloads, and query polling. */ + useLayoutEffect(() => { + if (isReconciling && !isReconcilingRef.current) reconcilingSinceRef.current = Date.now() + isReconcilingRef.current = isReconciling + everStreamedRef.current = everStreamed + onDirtyChangeRef.current = onDirtyChange + onSaveStatusChangeRef.current = onSaveStatusChange + updateContentRef.current = updateContent + streamActiveRef.current = isAgentStreamActive + contentRef.current = content + saveVersionRef.current = savedVersion + conflictRef.current = hasConflict + }) const onSave = useCallback( async (overrideContent?: string) => { const next = overrideContent ?? contentRef.current - await updateContentRef.current.mutateAsync({ workspaceId, fileId: file.id, content: next }) - markSavedContent(next) + const expectedUpdatedAt = saveVersionRef.current + if (conflictRef.current) throw new Error('Reload the latest file before saving this draft') + if (!expectedUpdatedAt) { + conflictRef.current = true + markConflict() + throw new Error('The file version is unavailable; reload before saving') + } + try { + const result = await updateContentRef.current.mutateAsync({ + workspaceId, + fileId: file.id, + content: next, + expectedUpdatedAt, + }) + const version = result.file.contentUpdatedAt + if (!version) { + conflictRef.current = true + markConflict() + throw new Error('Saved file version is unavailable; reload before continuing') + } + const acknowledgedVersion = new Date(version).toISOString() + if ( + !saveVersionRef.current || + Date.parse(acknowledgedVersion) >= Date.parse(saveVersionRef.current) + ) { + saveVersionRef.current = acknowledgedVersion + } + markSavedContent(next, acknowledgedVersion) + } catch (error) { + if ( + isApiClientError(error) && + error.status === 409 && + saveVersionRef.current === expectedUpdatedAt + ) { + conflictRef.current = true + markConflict() + } + throw error + } }, - [workspaceId, file.id, markSavedContent] + [workspaceId, file.id, markSavedContent, markConflict] ) - const autosaveEnabled = canEdit && isInitialized && !isStreamInteractionLocked && canAutosave + const autosaveEnabled = + canEdit && + isInitialized && + canAutosave && + (!isStreamInteractionLocked || + hasConflict || + (!isStreamPhaseLocked && content !== savedContent)) - const { saveStatus, saveImmediately, isDirty, discard } = useAutosave({ + const { + saveStatus: autosaveStatus, + saveImmediately, + isDirty, + discard, + } = useAutosave({ content, savedContent, onSave, enabled: autosaveEnabled, + pauseSaving: hasConflict || isStreamInteractionLocked, draftKey: autosaveEnabled ? `${workspaceId}:${file.id}` : undefined, onRestoreDraft: setDraftContent, + onRestoreConflictingDraft: restoreConflictingDraft, onDiscardCorrectionFailed: () => toast.error( `Failed to discard "${file.name}" — the server may still have the discarded edit` ), }) + const saveStatus = hasConflict ? 'error' : autosaveStatus + + const reloadLatestContent = useCallback(async () => { + if (streamActiveRef.current) + throw new Error('Wait for the agent edit to finish before reloading') + const draftAtStart = contentRef.current + const result = await reloadContent.mutateAsync({ + workspaceId, + fileId: file.id, + raw: GENERATED_SOURCE_FILE_TYPES.has(file.type), + }) + if (streamActiveRef.current) + throw new Error('An agent edit started while reloading. Retry after it finishes.') + if (contentRef.current !== draftAtStart) + throw new Error('Your draft changed while reloading. Download it or retry reload.') + if (!result.file.contentUpdatedAt) throw new Error('The latest file version is unavailable') + const latestContent = + normalizeBaseline && !everStreamedRef.current + ? normalizeBaseline(result.content) + : result.content + discard({ correctInFlightSave: false }) + const version = new Date(result.file.contentUpdatedAt).toISOString() + saveVersionRef.current = version + conflictRef.current = false + acceptReload(latestContent, version) + }, [ + reloadContent.mutateAsync, + workspaceId, + file.id, + file.type, + normalizeBaseline, + discard, + acceptReload, + ]) + + const downloadDraft = useCallback(() => { + saveBlob( + new Blob([contentRef.current], { type: 'text/plain;charset=utf-8' }), + `${file.name}.local-draft.txt` + ) + }, [file.name]) // When the client can't autosave it isn't the durability owner: the collaborative editor holds // `canAutosave` permanently false because the relay persists the doc server-side (debounced + on @@ -279,9 +428,9 @@ export function useEditableFileContent({ useEffect(() => { onSaveStatusChangeRef.current?.( saveStatus, - saveStatus === 'error' ? saveImmediately : undefined + saveStatus === 'error' && !hasConflict ? saveImmediately : undefined ) - }, [saveStatus, saveImmediately]) + }, [saveStatus, saveImmediately, hasConflict]) useEffect(() => { if (!saveRef) return @@ -323,5 +472,10 @@ export function useEditableFileContent({ saveStatus, saveImmediately, isDirty: isDirtyForCaller, + hasConflict, + isReloading: reloadContent.isPending, + reloadLatestContent, + downloadDraft, + acceptedBaselineContent, } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx index 5c9f0a1b469..6185773176e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx @@ -25,12 +25,12 @@ import { searchFilterParsers, UPDATED_WINDOWS, } from '@/app/workspace/[workspaceId]/home/search-params' -import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useWorkspaceMemberConnectors, type WorkspaceMemberConnector, } from '@/hooks/queries/kb/connectors' import { useKnowledgeBasesQuery, useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge' +import { useMemberAccessAvailable } from '@/hooks/use-member-access' const EMPTY_MEMBER_CONNECTORS: WorkspaceMemberConnector[] = [] @@ -185,13 +185,11 @@ export function KnowledgeSearchResults({ isPlaceholderData, error, } = useWorkspaceKnowledgeSearch(workspaceId, knowledgeBaseIds, query) - const { features } = useWorkspaceHostContext() /** - * Judged by the workspace, as the server judges it: with per-member access - * off, member-scoped documents are hidden, so no source is indexing anything - * the viewer will see, and the list is not worth asking for. + * With per-member access off, member-scoped documents are hidden, so the + * indexing list is not worth asking for. */ - const memberAccessAvailable = features?.knowledgeMemberAccess === true + const memberAccessAvailable = useMemberAccessAvailable() const { data: memberConnectorRows } = useWorkspaceMemberConnectors(workspaceId, { enabled: memberAccessAvailable, }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 166f5fd01c5..d8f012c636e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -98,12 +98,16 @@ const TOOL_ICONS: Record = { browser_navigate: Cursor, browser_go_back: Cursor, browser_go_forward: Cursor, + browser_reload: Cursor, browser_open_tab: Cursor, browser_switch_tab: Cursor, browser_close_tab: Cursor, browser_list_tabs: Cursor, + browser_list_sessions: Globe, + browser_list_downloads: File, browser_wait_for: Cursor, browser_snapshot: Eye, + browser_find: Search, browser_read_text: File, browser_screenshot: Eye, browser_extract: Search, @@ -112,7 +116,9 @@ const TOOL_ICONS: Record = { browser_press_key: Cursor, browser_scroll: Cursor, browser_select_option: Cursor, + browser_set_checked: Cursor, browser_hover: Cursor, + browser_zoom: Eye, browser_request_takeover: Cursor, terminal: TerminalWindow, terminal_run: TerminalWindow, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 38bf0ac757a..c6bdfebecee 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { Button, cn, @@ -22,6 +22,7 @@ import { BROWSER_SESSION_RESOURCE_ID, TERMINAL_SESSION_RESOURCE_ID, } from '@/lib/copilot/resources/types' +import { subscribeDesktopPreferences } from '@/lib/desktop' import { isTerminalAvailable } from '@/lib/terminal/transport' import { type AvailableItem, @@ -145,6 +146,16 @@ export function useAvailableResources( ): AvailableResources { const enabled = options?.enabled ?? true const excludeTypes = options?.excludeTypes + const browserAvailable = useSyncExternalStore( + subscribeDesktopPreferences, + isBrowserAgentAvailable, + () => false + ) + const terminalAvailable = useSyncExternalStore( + subscribeDesktopPreferences, + isTerminalAvailable, + () => false + ) // Destructured without `= []` defaults on purpose: a literal default allocates a // fresh array every render while `data` is undefined (exactly the disabled state), // which would bust the group memo below on every render. Undefined is stable. @@ -292,7 +303,7 @@ export function useAvailableResources( ] // The live browser panel — desktop app only (needs the agent-browser // bridge). There is one top-level panel; repeated launches open inner tabs. - if (isBrowserAgentAvailable()) { + if (browserAvailable) { groups.push({ type: 'browser' as const, items: [ @@ -305,7 +316,7 @@ export function useAvailableResources( } // The live terminal — desktop app only (needs the PTY bridge), and a // single top-level panel like the browser. - if (isTerminalAvailable()) { + if (terminalAvailable) { groups.push({ type: 'terminal' as const, items: [ @@ -319,6 +330,8 @@ export function useAvailableResources( return groups.filter((g) => !excluded.has(g.type)).sort(byResourceMenuOrder) }, [ enabled, + browserAvailable, + terminalAvailable, workflows, folders, fileFolders, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session-ui.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session-ui.test.tsx new file mode 100644 index 00000000000..6233931f9c4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session-ui.test.tsx @@ -0,0 +1,163 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import type { BrowserPageState } from '@sim/browser-protocol' +import type { BrowserToolbarCommand } from '@sim/desktop-bridge' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { desktop, navigateToSettings, removeResource } = vi.hoisted(() => ({ + navigateToSettings: vi.fn(), + removeResource: vi.fn(), + desktop: { + settings: { getPreferences: vi.fn(async () => ({ browserTheme: 'app' })) }, + browserCredentials: { list: vi.fn(async () => []), onFillAvailability: vi.fn(() => () => {}) }, + browserImport: { + listChromeProfiles: vi.fn(async () => []), + listSites: vi.fn(async () => []), + importFromChrome: vi.fn(), + }, + browserAgent: { + supportsAtomicPanelOcclusion: true, + setTheme: vi.fn(), + setPanelBounds: vi.fn(), + setPanelFocused: vi.fn(), + setPanelOccluded: vi.fn(async () => true), + capturePanelSnapshot: vi.fn(async () => null), + getKnownSessions: vi.fn(async () => ({ sessions: [] })), + getDownloadsState: vi.fn(async () => ({ downloads: [] })), + onAppearanceThemeChanged: vi.fn(() => () => {}), + onToolbarCommand: vi.fn( + (_callback: (command: BrowserToolbarCommand, scopeId: string) => void) => () => {} + ), + onAddToChat: vi.fn(() => () => {}), + onFocusOmnibox: vi.fn(() => () => {}), + onOpenFind: vi.fn(() => () => {}), + onCloseFind: vi.fn(() => () => {}), + onDownloadsState: vi.fn(() => () => {}), + }, + }, +})) + +vi.mock('@/lib/desktop', () => ({ getDesktopBridge: () => desktop })) +vi.mock('@/hooks/use-settings-navigation', () => ({ + useSettingsNavigation: () => ({ navigateToSettings }), +})) +vi.mock('@/app/workspace/[workspaceId]/home/components/mothership-resources-context', () => ({ + useMothershipResources: () => ({ removeResource }), +})) + +import { BrowserSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session' +import { useBrowserSessionStore } from '@/stores/browser-session/store' + +const PAGE: BrowserPageState = { + scopeId: 'browser-ui-test', + tabId: 'tab-1', + url: 'about:blank', + title: '', + loading: false, + canGoBack: false, + canGoForward: false, +} +let container: HTMLDivElement +let root: Root + +async function render(page: BrowserPageState = PAGE, visible = true) { + await act(async () => { + useBrowserSessionStore.getState().setPageState(page) + root.render() + }) + await act(async () => vi.advanceTimersByTime(20)) +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + unobserve() {} + } + ) + vi.clearAllMocks() + useBrowserSessionStore.setState({ sessions: {} }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + width: 700, + height: 600, + top: 0, + bottom: 600, + left: 0, + right: 700, + toJSON: () => ({}), + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('browser empty state and in-place import', () => { + it('replaces the native blank page with shared guidance and no extra actions', async () => { + await render() + const emptyState = container.querySelector('section[aria-label="New tab"]') + expect(emptyState?.textContent).toContain('Browse the web') + expect(emptyState?.textContent).toContain('Search or enter a URL above.') + expect(emptyState?.querySelector('button')).toBeNull() + expect(desktop.browserAgent.setPanelBounds).toHaveBeenLastCalledWith(null, null, PAGE.scopeId) + expect(container.querySelector('input')).not.toBeNull() + }) + + it('hands the surface back to the native page for navigation and restores it on a blank tab', async () => { + await render() + await render({ ...PAGE, loading: true }) + expect(container.querySelector('section[aria-label="New tab"]')).toBeNull() + expect(desktop.browserAgent.setPanelBounds.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ width: 700, height: 600 }) + ) + await render({ ...PAGE, url: 'https://example.com' }) + expect(container.querySelector('section[aria-label="New tab"]')).toBeNull() + await render() + expect(container.querySelector('section[aria-label="New tab"]')).not.toBeNull() + expect(desktop.browserAgent.setPanelBounds).toHaveBeenLastCalledWith(null, null, PAGE.scopeId) + }) + + it('does not replace load errors with the new-tab state', async () => { + await render({ + ...PAGE, + issue: { + kind: 'load-error', + url: 'https://missing.example', + code: -105, + description: 'ERR_NAME_NOT_RESOLVED', + }, + }) + expect(container.textContent).toContain("This site can't be reached") + expect(container.querySelector('section[aria-label="New tab"]')).toBeNull() + }) + + it('opens import in place from the native toolbar command', async () => { + await render() + const callback = desktop.browserAgent.onToolbarCommand.mock.calls[0][0] + await act(async () => callback('import', PAGE.scopeId)) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Import from your browser' + ) + expect(navigateToSettings).not.toHaveBeenCalled() + expect(desktop.browserImport.listChromeProfiles).toHaveBeenCalledOnce() + expect(document.querySelector('[role="dialog"]')?.style.visibility).not.toBe( + 'hidden' + ) + expect(desktop.browserAgent.setPanelOccluded).toHaveBeenCalledWith(true, PAGE.scopeId, true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts index 680ad92e1c2..e9fa882f8d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts @@ -117,12 +117,10 @@ describe('browser permission prompt', () => { it('describes the scope and consequence of site and media access', () => { expect(browserPermissionPrompt(siteRequest)).toEqual({ - title: 'Allow this browser task to visit https://outside.example?', - text: expect.stringContaining('send requests to and receive data from this origin'), + title: 'Open another website?', + text: expect.stringContaining('This page wants to open https://outside.example.'), }) - expect(browserPermissionPrompt(siteRequest).text).toContain( - 'the full path and query remain hidden' - ) + expect(browserPermissionPrompt(siteRequest).text).toContain('for the rest of this browser task') expect(browserPermissionPrompt(mediaRequest)).toEqual({ title: 'Allow https://meeting.example to use your microphone and camera?', text: expect.stringContaining('until it navigates'), @@ -144,10 +142,9 @@ describe('browser permission prompt', () => { expect(dialog).not.toBeNull() const labelledBy = dialog?.getAttribute('aria-labelledby') expect(labelledBy).toBeTruthy() - expect(document.getElementById(labelledBy ?? '')?.textContent).toBe( - 'Allow this browser task to visit https://outside.example?' - ) - expect(dialog?.textContent).toContain('the full path and query remain hidden') + expect(document.getElementById(labelledBy ?? '')?.textContent).toBe('Open another website?') + expect(dialog?.textContent).toContain('https://outside.example') + expect(dialog?.textContent).toContain('for the rest of this browser task') expect(document.querySelector('[data-native-surface-occlusion="modal"]')).not.toBeNull() expect(document.querySelector('[data-chip-modal-default-policy="dismiss"]')).not.toBeNull() expect(document.activeElement).toBe(buttonByText('Block')) @@ -157,6 +154,22 @@ describe('browser permission prompt', () => { expect(onDecision).toHaveBeenCalledWith(siteRequest.requestId, 'respond-site-permission', false) }) + it('keeps long destinations in the wrapping body instead of the truncated title', () => { + const origin = `https://${'a'.repeat(63)}.${'b'.repeat(63)}.example` + mount( + createElement(BrowserPermissionModal, { + request: { ...siteRequest, origin }, + open: true, + onDecision: vi.fn(), + }) + ) + const dialog = document.querySelector('[role="dialog"]') + const descriptionId = dialog?.getAttribute('aria-describedby') + const description = document.getElementById(descriptionId ?? '') + expect(description?.textContent).toContain(origin) + expect(description?.classList.contains('break-words')).toBe(true) + }) + it('makes Allow an explicit primary decision', () => { const onDecision = vi.fn() mount( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index e51d745252d..41f0a077bc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -38,6 +38,8 @@ import { import { ArrowLeft, ArrowRight, Globe, Key, Link, RefreshCw, Search } from '@sim/emcn/icons' import { useTheme } from 'next-themes' import { createPortal } from 'react-dom' +import { BrowserImportDialog } from '@/components/browser-import/browser-import-dialog' +import { EmptyState } from '@/components/empty-state/empty-state' import { onFocusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-shortcuts' import { fillBrowserCredential, @@ -66,6 +68,7 @@ import { } from '@/lib/browser-agent/transport' import { BROWSER_SESSION_RESOURCE_ID } from '@/lib/copilot/resources/types' import { faviconUrl } from '@/lib/core/utils/favicon' +import { getDesktopBridge } from '@/lib/desktop' import { loadDesktopBrowserAppearanceTheme, resolveDesktopAppearanceTheme, @@ -238,8 +241,8 @@ export function browserPermissionPrompt(request: BrowserPermissionRequest): { } { if ('tabId' in request) { return { - title: `Allow this browser task to visit ${request.origin}?`, - text: 'Allowing lets the task send requests to and receive data from this origin until the browser task ends. Only the origin is shown here; the full path and query remain hidden.', + title: 'Open another website?', + text: `This page wants to open ${request.origin}. Allow this destination for the rest of this browser task? Only continue if you trust this website.`, } } @@ -480,7 +483,16 @@ export function BrowserSession({ (state) => state.sessions[scopeId]?.sessionAlive ?? true ) const suspended = useBrowserSessionStore((state) => state.sessions[scopeId]?.suspended ?? false) - const hasPageIssue = Boolean(pageState?.issue) + const showEmptyState = Boolean( + pageState && + !pageState.loading && + !pageState.issue && + (!pageState.url || pageState.url === 'about:blank') + ) + const hasRendererPage = Boolean(pageState?.issue) || showEmptyState + const [importOpen, setImportOpen] = useState(false) + const [importVersion, setImportVersion] = useState(0) + const canImport = Boolean(getDesktopBridge()?.browserImport?.importFromChrome) const mediaPermissionRequest = pageState?.mediaPermissionRequest const sitePermissionRequest = pageState?.sitePermissionRequest const permissionRequest = sitePermissionRequest ?? mediaPermissionRequest @@ -636,11 +648,7 @@ export function BrowserSession({ () => onBrowserToolbarCommand((command) => { if (command === 'import') { - navigateToSettings({ - section: 'browser', - browserView: 'passwords', - browserImport: true, - }) + setImportOpen(true) return } navigateToSettings({ section: 'browser' }) @@ -668,7 +676,7 @@ export function BrowserSession({ return () => { active = false } - }, [panelVisible]) + }, [panelVisible, importVersion]) /** Debounced live completions never block the immediate local/search row. */ useEffect(() => { @@ -880,7 +888,7 @@ export function BrowserSession({ reportBrowserPanelBounds(null, null, scopeId) return } - if (hasPageIssue) { + if (hasRendererPage) { setPanelVisible(true) reportBrowserPanelBounds(null, null, scopeId) return @@ -1046,7 +1054,7 @@ export function BrowserSession({ void geometryOcclusionLease.setDesired(false) reportBrowserPanelBounds(null, null, scopeId) } - }, [hasPageIssue, visible, suspended, scopeId]) + }, [hasRendererPage, visible, suspended, scopeId]) /** * Programmatic focus on a new tab keeps the omnibox ready for typing without @@ -1073,7 +1081,7 @@ export function BrowserSession({ void closeOverlay('suggestions') return } - if (hasPageIssue) { + if (hasRendererPage) { void closeOverlay('suggestions') return } @@ -1084,14 +1092,14 @@ export function BrowserSession({ void closeOverlay('suggestions') }, [ closeOverlay, - hasPageIssue, + hasRendererPage, mediaPermissionRequest, requestOverlay, sitePermissionRequest, suggestions.length, ]) - const suggestionsOpen = hasPageIssue + const suggestionsOpen = hasRendererPage ? suggestions.length > 0 : shouldOpenUrlSuggestions(activeOverlay, suggestions.length) @@ -1552,13 +1560,8 @@ export function BrowserSession({ /> - navigateToSettings({ - section: 'browser', - browserView: 'passwords', - browserImport: true, - }) - )} + onSelect={afterToolbarClose(() => setImportOpen(true))} + disabled={!canImport} > Import Passwords @@ -1594,6 +1597,18 @@ export function BrowserSession({

    )} + {showEmptyState && ( +
    +
    + )} {pageState?.issue && ( )}
    + setImportVersion((version) => version + 1)} + /> void>() vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) +/** The switcher renders only where Search mode exists, so these tests are that workspace. */ +vi.mock('@/hooks/use-member-access', () => ({ useMemberAccessAvailable: () => true })) vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.test.tsx new file mode 100644 index 00000000000..5a73f648331 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.test.tsx @@ -0,0 +1,234 @@ +/** + * @vitest-environment jsdom + */ +import { act, createRef } from 'react' +import type { DesktopPreferences } from '@sim/desktop-bridge' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const fixtures = vi.hoisted(() => ({ + browserAvailable: vi.fn(() => true), + terminalAvailable: vi.fn(() => true), + resources: { data: [{ id: 'resource-1', name: 'Example' }], isPending: false }, + folders: { data: [], isPending: false }, + tabs: [], + logs: { + data: { + pages: [{ logs: [{ id: 'log-1', createdAt: '2026-01-01T12:00:00Z', status: 'success' }] }], + }, + isPending: false, + }, +})) + +vi.mock('@/lib/browser-agent/transport', () => ({ + isBrowserAgentAvailable: fixtures.browserAvailable, +})) +vi.mock('@/lib/terminal/transport', () => ({ + isTerminalAvailable: fixtures.terminalAvailable, +})) +vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => fixtures.resources })) +vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => fixtures.resources })) +vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => fixtures.resources })) +vi.mock('@/hooks/queries/kb/knowledge', () => ({ + useKnowledgeBasesQuery: () => fixtures.resources, +})) +vi.mock('@/hooks/queries/folders', () => ({ useFolders: () => fixtures.folders })) +vi.mock('@/hooks/queries/workspace-file-folders', () => ({ + useWorkspaceFileFolders: () => fixtures.folders, +})) +vi.mock('@/hooks/queries/mothership-chats', () => ({ + useMothershipChats: () => fixtures.resources, +})) +vi.mock('@/hooks/queries/logs', () => ({ useLogsList: () => fixtures.logs })) +vi.mock('@/blocks/integration-matcher', () => ({ + listIntegrationsByPopularity: () => [ + { blockType: 'example', name: 'Example integration', icon: () => null }, + ], +})) +vi.mock('@/stores/browser-session/store', () => ({ useBrowserSessionStore: () => fixtures.tabs })) +vi.mock('@/stores/copilot-terminal/store', () => ({ useCopilotTerminalStore: () => fixtures.tabs })) + +import { + BROWSER_SESSION_RESOURCE_ID, + TERMINAL_SESSION_RESOURCE_ID, +} from '@/lib/copilot/resources/types' +import { setDesktopPreferencesSnapshot } from '@/lib/desktop' +import { + mapResourceToContext, + type PlusMenuHandle, +} from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' +import { PlusMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown' + +let root: Root +let container: HTMLDivElement + +const PREFERENCES: DesktopPreferences = { + notificationsEnabled: true, + notificationSounds: true, + notificationsOnlyWhenUnfocused: true, + launchAtLogin: false, + autoDownloadUpdates: true, + browserEnabled: true, + terminalEnabled: true, +} + +function openMenu(mention = false) { + const ref = createRef() + const onResourceSelect = vi.fn() + act(() => + root.render( + ()} + pendingCursorRef={{ current: null }} + /> + ) + ) + act(() => ref.current?.open({ left: 0, top: 0 }, { mention })) + return { ref, onResourceSelect } +} + +function menuItems(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="menuitem"]')).filter( + (item) => !item.closest('[hidden]') + ) +} + +function selectItem(name: string) { + const item = menuItems().find((item) => item.textContent === name) + if (!item) throw new Error(`Missing menu item: ${name}`) + act(() => item.click()) +} + +describe('PlusMenuDropdown desktop resources', () => { + const originalScrollIntoView = Object.getOwnPropertyDescriptor( + Element.prototype, + 'scrollIntoView' + ) + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + vi.clearAllMocks() + fixtures.browserAvailable.mockReturnValue(true) + fixtures.terminalAvailable.mockReturnValue(true) + setDesktopPreferencesSnapshot(PREFERENCES) + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + if (originalScrollIntoView) { + Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView) + } else { + Reflect.deleteProperty(Element.prototype, 'scrollIntoView') + } + vi.unstubAllGlobals() + }) + + it('keeps shared categories in the same order in browse and mention modes', () => { + const { ref } = openMenu() + const browseOrder = menuItems().map((item) => item.textContent) + expect(browseOrder).toEqual([ + 'Chats', + 'Tables', + 'Files', + 'Knowledge Bases', + 'Workflows', + 'Logs', + 'Browser', + 'Terminal', + ]) + + act(() => ref.current?.open({ left: 0, top: 0 }, { mention: true })) + const headings = menuItems().map((item) => item.previousElementSibling?.textContent) + expect(headings).toEqual(['Integrations', ...browseOrder]) + }) + + it.each([false, true])('selects the same whole Browser in mention=%s mode', (mention) => { + const { onResourceSelect } = openMenu(mention) + selectItem('Browser') + + expect(onResourceSelect).toHaveBeenCalledExactlyOnceWith({ + type: 'browser', + id: BROWSER_SESSION_RESOURCE_ID, + title: 'Browser', + }) + expect(mapResourceToContext(onResourceSelect.mock.calls[0][0])).toEqual({ + kind: 'browser_tab', + tabId: BROWSER_SESSION_RESOURCE_ID, + label: 'Browser', + }) + }) + + it.each([false, true])( + 'updates mounted desktop rows when preferences change in mention=%s mode', + (mention) => { + openMenu(mention) + expect(menuItems().map((item) => item.textContent)).toContain('Browser') + + fixtures.browserAvailable.mockReturnValue(false) + act(() => setDesktopPreferencesSnapshot({ ...PREFERENCES, browserEnabled: false })) + expect(menuItems().map((item) => item.textContent)).not.toContain('Browser') + expect(menuItems().map((item) => item.textContent)).toContain('Terminal') + } + ) + + it('finds Browser through plus-menu search and selects it with Enter', () => { + const { onResourceSelect } = openMenu() + const search = document.querySelector( + 'input[placeholder="Search resources..."]' + ) + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + if (!search || !valueSetter) throw new Error('Search input is unavailable') + act(() => { + valueSetter.call(search, 'browser') + search.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(menuItems().map((item) => item.textContent)).toEqual(['Browser']) + act(() => search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + expect(onResourceSelect).toHaveBeenCalledExactlyOnceWith({ + type: 'browser', + id: BROWSER_SESSION_RESOURCE_ID, + title: 'Browser', + }) + }) + + it.each([false, true])('keeps unavailable Browser hidden in mention=%s mode', (mention) => { + fixtures.browserAvailable.mockReturnValue(false) + const { onResourceSelect } = openMenu(mention) + expect(menuItems().some((item) => item.textContent === 'Browser')).toBe(false) + selectItem('Terminal') + expect(onResourceSelect).toHaveBeenCalledExactlyOnceWith({ + type: 'terminal', + id: TERMINAL_SESSION_RESOURCE_ID, + title: 'Terminal', + }) + }) + + it.each([false, true])('omits both desktop resources on web in mention=%s mode', (mention) => { + fixtures.browserAvailable.mockReturnValue(false) + fixtures.terminalAvailable.mockReturnValue(false) + openMenu(mention) + const names = menuItems().map((item) => item.textContent) + expect(names).not.toContain('Browser') + expect(names).not.toContain('Terminal') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index da971050c25..d78a021bf79 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -52,7 +52,6 @@ const MENTION_MAX_HEIGHT_CLASS = 'max-h-[min(280px,var(--radix-popper-available- * (`ADD_RESOURCE_EXCLUDED_TYPES` in `resource-tabs`). */ const MENTION_ONLY_RESOURCE_TYPES = new Set(['integration']) -const NON_ATTACHABLE_RESOURCE_TYPES = new Set(['browser']) const EMPTY_BROWSER_TABS = [] as const const EMPTY_TERMINAL_TABS = [] as const @@ -121,17 +120,11 @@ export const PlusMenuDropdown = React.memo( setOpen(false) }, []) - // The `+` browse menu hides non-attachable and mention-only resource types. - // `@` mode exposes the full catalog and adds each live Browser/Terminal tab - // after its always-present whole-resource row. const visibleResources = useMemo(() => { if (isMention) { return withDesktopTabMentions(availableResources, browserTabs, terminalTabs) } - const attachable = availableResources.filter( - ({ type }) => !NON_ATTACHABLE_RESOURCE_TYPES.has(type) - ) - return attachable.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type)) + return availableResources.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type)) }, [availableResources, browserTabs, isMention, terminalTabs]) const treeSections = useResourceTreeSections({ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx index 240becccf96..c11218a3b60 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx @@ -9,14 +9,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PromptEditorInstance } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor' import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types' -const { mockSubmit, mockResetTranscript } = vi.hoisted(() => ({ +const { mockSubmit, mockResetTranscript, mockMemberAccessAvailable } = vi.hoisted(() => ({ mockSubmit: vi.fn(), mockResetTranscript: vi.fn(), + /** Search mode exists only where per-member access is on; these tests are that workspace. */ + mockMemberAccessAvailable: vi.fn(() => true), })) 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('@/hooks/use-member-access', () => ({ + useMemberAccessAvailable: () => mockMemberAccessAvailable(), +})) vi.mock('@/hooks/use-settings-navigation', () => ({ useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }), })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index c8ec2df8990..da77bd436a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -68,6 +68,7 @@ import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { useWorkflows } from '@/hooks/queries/workflows' import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useMemberAccessAvailable } from '@/hooks/use-member-access' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { @@ -184,15 +185,19 @@ export function Home({ chatId, userName, userId }: HomeProps) { }, [setSearchQueryParam, setSearchFilters] ) + const memberAccessAvailable = useMemberAccessAvailable() const [composerMode, setComposerMode] = useMothershipMode() /** * A link that carries a query but no mode opens in Search with the query in * the box; the composer follows the live query the same way (below), so the - * box and the results never show two different queries. + * box and the results never show two different queries. Where per-member + * access is off there is no Search to open into, so the query stays a plain + * Build draft rather than a mode write `useMothershipMode` would drop. */ useEffect(() => { + if (!memberAccessAvailable) return if (searchQuery.trim() && composerMode === 'build') void setComposerMode('search') - }, [searchQuery, composerMode, setComposerMode]) + }, [memberAccessAvailable, searchQuery, composerMode, setComposerMode]) const hasCheckedLandingStorageRef = useRef(false) const initialViewInputRef = useRef(null) const initialViewUserInputRef = useRef(null) @@ -490,8 +495,13 @@ export function Home({ chatId, userName, userId }: HomeProps) { * Search lists documents, not a turn of the agent, and only a query can * be searched: attachments alone have nothing to search for. Assistant * makes the query a turn of the agent grounded in the sources. + * + * The override skips `useMothershipMode`, so the gate is applied again + * where the mode is consumed: both modes answer from the workspace's + * indexed sources, and neither is offered where those do not exist. */ - const mode = modeOverride ?? composerMode + const requestedMode = modeOverride ?? composerMode + const mode = requestedMode !== 'build' && !memberAccessAvailable ? 'build' : requestedMode const answering = mode === 'assistant' if (mode === 'search') { /** A search sends nothing, so an edit in progress is released rather than left waiting. */ @@ -534,6 +544,7 @@ export function Home({ chatId, userName, userId }: HomeProps) { workspaceId, chatId, composerMode, + memberAccessAvailable, editingQueuedId, cancelQueueEdit, prepareResourceViewForAgentTurn, @@ -803,7 +814,7 @@ export function Home({ chatId, userName, userId }: HomeProps) { defaultValue={initialPrompt || searchQuery} draftScopeKey={draftScopeKey} onSubmit={handleSubmit} - canSearch + canSearch={memberAccessAvailable} clearOnSubmit={composerMode !== 'search'} onCleared={clearSearch} isSending={isSending} @@ -835,7 +846,7 @@ export function Home({ chatId, userName, userId }: HomeProps) { isReconnecting={isReconnecting} isLoading={showChatSkeleton} onSubmit={handleSubmit} - canSearch + canSearch={memberAccessAvailable} clearOnSubmit={composerMode !== 'search'} onCleared={clearSearch} onStopGeneration={handleStopGeneration} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx new file mode 100644 index 00000000000..0b6cede7104 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx @@ -0,0 +1,113 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MothershipMode } from '@/app/workspace/[workspaceId]/home/search-params' + +const { mockMemberAccessAvailable } = vi.hoisted(() => ({ + mockMemberAccessAvailable: vi.fn(() => true), +})) +const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() + +vi.mock('@/hooks/use-member-access', () => ({ + useMemberAccessAvailable: () => mockMemberAccessAvailable(), +})) + +import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' + +let root: Root | null = null +let container: HTMLDivElement | null = null +let current: ReturnType | null = null + +function Probe() { + current = useMothershipMode() + return null +} + +function mount(searchParams = '') { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => + root?.render( + + + + ) + ) +} + +function mode(): MothershipMode { + if (!current) throw new Error('Probe did not render') + return current[0] +} + +/** nuqs batches its URL write onto a timeout, so a write is read back after the tick. */ +async function setMode(next: MothershipMode) { + await act(async () => { + current?.[1](next) + await vi.advanceTimersByTimeAsync(1) + }) +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + mockMemberAccessAvailable.mockReturnValue(true) + mockUrlUpdate.mockClear() +}) + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null + current = null + vi.useRealTimers() +}) + +/** + * The mode's ordinary read/write behavior is covered through the UI in + * `mode-switcher.test.tsx`; one write stands here as the control the + * per-member-access cases are read against. + */ +describe('useMothershipMode', () => { + it('writes the chosen mode to the URL', async () => { + mount() + await setMode('search') + + expect(mode()).toBe('search') + expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('search') + }) + + describe('without per-member access', () => { + beforeEach(() => { + mockMemberAccessAvailable.mockReturnValue(false) + }) + + it('reads Build from a link naming a mode the workspace does not have', () => { + mount('?mode=search') + + expect(mode()).toBe('build') + }) + + it('writes no mode the workspace does not have', async () => { + mount() + await setMode('search') + + expect(mode()).toBe('build') + expect(mockUrlUpdate).not.toHaveBeenCalled() + }) + + it('still returns to Build, so a stale link can be left', async () => { + mount('?mode=search&q=budget') + await setMode('build') + + expect(mode()).toBe('build') + expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe('') + }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts index b9f66ed060a..f97e4409458 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts @@ -8,25 +8,34 @@ import { type MothershipMode, resourceUrlKeys, } from '@/app/workspace/[workspaceId]/home/search-params' +import { useMemberAccessAvailable } from '@/hooks/use-member-access' /** * The composer's mode, read from and written to the URL's `mode` param so a * refresh, back, forward, or shared link lands in the same mode, as Glean's * separate Search and Assistant routes do. Build is the clean URL. + * + * Search and Assistant both answer from the workspace's indexed sources, so + * both exist only where per-member access is on. With the feature off the mode + * reads Build whatever the URL says, and a write to either is dropped rather + * than leaving a mode in the URL that the next read would contradict. */ export function useMothershipMode() { + const memberAccessAvailable = useMemberAccessAvailable() const [{ mode }, setParams] = useQueryStates(composerModeParsers, resourceUrlKeys) const setMode = useCallback( - (next: MothershipMode) => - setParams( + async (next: MothershipMode) => { + if (next !== 'build' && !memberAccessAvailable) return + await setParams( { mode: next, ...(next === 'search' ? {} : { q: null, ...CLEARED_SEARCH_FILTERS }), }, { history: 'replace', scroll: false } - ), - [setParams] + ) + }, + [memberAccessAvailable, setParams] ) - return [mode, setMode] as const + return [memberAccessAvailable ? mode : 'build', setMode] as const } 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 914fe2d366b..1279b4489b3 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 @@ -19,6 +19,7 @@ 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 { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { AddPeopleModal, CredentialDetailHeading, @@ -211,7 +212,9 @@ export function ConnectedCredentialDetail({ onClick={ credential.type === 'service_account' ? () => setReconnectOpen(true) - : handleReconnectOAuth + : credential.providerId === 'quickbooks' + ? () => setReconnectOpen(true) + : handleReconnectOAuth } disabled={ connectOAuthService.isPending || @@ -354,6 +357,24 @@ export function ConnectedCredentialDetail({ credentialDescription={credential.description ?? undefined} /> )} + + {credential.type === 'oauth' && credential.providerId === 'quickbooks' && ( + } + toolName='QuickBooks' + requiredScopes={serviceConfig?.scopes ?? []} + reconnectTarget={{ + workspaceId, + credentialId: credential.id, + displayName: credential.displayName, + }} + /> + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index ef75dfa1fbd..239df07c125 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -56,6 +56,7 @@ import type { ConnectorMeta } from '@/connectors/types' import { useCreateConnector } from '@/hooks/queries/kb/connectors' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' +import { useMemberAccessAvailable } from '@/hooks/use-member-access' const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_META_REGISTRY) @@ -92,9 +93,9 @@ export function AddConnectorModal({ const [searchTerm, setSearchTerm] = useState('') const { workspaceId } = useParams<{ workspaceId: string }>() - const { ownerBilling, features } = useWorkspaceHostContext() + const { ownerBilling } = useWorkspaceHostContext() const { canAdmin } = useUserPermissionsContext() - const memberAccessAvailable = features?.knowledgeMemberAccess === true + const memberAccessAvailable = useMemberAccessAvailable() const { mutate: createConnector, isPending: isCreating } = useCreateConnector() const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index db5acdc2c56..9cab5298d6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -55,6 +55,7 @@ import { useUpdateConnectorAccess, } from '@/hooks/queries/kb/connectors' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' +import { useMemberAccessAvailable } from '@/hooks/use-member-access' const logger = createLogger('EditConnectorModal') @@ -232,7 +233,7 @@ export function EditConnectorModal({ initialCanonicalModes, }) - const { ownerBilling, features } = useWorkspaceHostContext() + const { ownerBilling } = useWorkspaceHostContext() const { canAdmin } = useUserPermissionsContext() const { workspaceId } = useParams<{ workspaceId: string }>() const { mutate: updateConnector, isPending: isSavingSettings } = useUpdateConnector() @@ -243,7 +244,7 @@ export function EditConnectorModal({ * member keeps it where the flag has since been turned off, so an admin can * still bring it back to workspace mode; per-member cannot be re-chosen. */ - const memberAccessAvailable = features?.knowledgeMemberAccess === true + const memberAccessAvailable = useMemberAccessAvailable() const showAccessField = memberAccessAvailable || connector.accessMode === 'members' const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling) diff --git a/apps/sim/app/workspace/[workspaceId]/search/page.tsx b/apps/sim/app/workspace/[workspaceId]/search/page.tsx index 1623fd074e2..83ac1cf864f 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/page.tsx @@ -1,5 +1,8 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components' import { Search } from '@/app/workspace/[workspaceId]/search/search' @@ -8,13 +11,24 @@ export const metadata: Metadata = { } /** - * Sim Search page entry. `Search` reads URL query params via nuqs (which uses - * `useSearchParams` internally), so it must sit under a Suspense boundary. The - * fallback renders the real page chrome (background + tab header) so a suspend - * never shows a blank frame. + * Sim Search page entry, served only where per-member access is on: the whole + * surface is the feature, so with it off the route is not there to be typed, + * linked, or bookmarked into — the same judgement the tab header hides itself + * on. The host context is request-memoized, so this re-reads what the workspace + * layout already resolved. + * + * `Search` reads URL query params via nuqs (which uses `useSearchParams` + * internally), so it must sit under a Suspense boundary. The fallback renders + * the real page chrome (background + tab header) so a suspend never shows a + * blank frame. */ export default async function SearchPage({ params }: { params: Promise<{ workspaceId: string }> }) { + const session = await getSession() + if (!session?.user) redirect('/login') + const { workspaceId } = await params + const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id) + if (!hostContext?.features?.knowledgeMemberAccess) notFound() return ( ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ - useWorkspaceHostContext: () => ({ features: mockFeatures() }), + useOptionalWorkspaceHostContext: () => ({ features: mockFeatures() }), })) vi.mock('nuqs', () => ({ useQueryState: () => ['', vi.fn()], diff --git a/apps/sim/app/workspace/[workspaceId]/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/search/search.tsx index 2d99116f189..ab9ed1419a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/search.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/search.tsx @@ -18,7 +18,6 @@ import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration' -import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { MemberConnectorsSection } from '@/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section' import { connectorSearchParam, @@ -33,6 +32,7 @@ import { } from '@/hooks/queries/kb/connectors' import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' +import { useMemberAccessAvailable } from '@/hooks/use-member-access' import { CONNECTABLE_MEMBERSHIPS, describeMembership, @@ -123,13 +123,11 @@ export function Search() { const params = useParams() const workspaceId = (params?.workspaceId as string) || '' const { integrationAvailability } = usePermissionConfig() - const { features } = useWorkspaceHostContext() /** - * Judged by the workspace, as the server judges it: with per-member access - * off, every connect is refused, so the rows say so instead of offering - * one and the memberships are not fetched. + * With per-member access off, every connect is refused, so the rows say so + * and the memberships are not fetched. */ - const memberAccessAvailable = features?.knowledgeMemberAccess === true + const memberAccessAvailable = useMemberAccessAvailable() const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId) /** The first connect of a source turns it on for the workspace, which takes an admin. */ const canCreate = workspacePermissions?.viewer?.isAdmin ?? false diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx index 0a44d8c377c..93352443aa1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx @@ -185,33 +185,6 @@ vi.mock( }) ) -// The modal's own picker logic is covered by import-modal.test.tsx; here it is -// reduced to "open?" plus a way to confirm the chosen profile. -vi.mock( - '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal', - () => ({ - ImportModal: ({ - open, - profiles, - pending, - onImport, - }: { - open: boolean - profiles: BrowserImportProfile[] - pending: boolean - onImport: (profile: BrowserImportProfile) => void - }) => - open ? ( -
    - {`${profiles.length} profiles`} - -
    - ) : null, - }) -) - import { Browser } from '@/app/workspace/[workspaceId]/settings/components/browser/browser' const PROFILES: BrowserImportProfile[] = [ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx index e9f84b01fbc..7ba13927b8d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx @@ -273,6 +273,22 @@ describe('PasswordDetail', () => { expect(container.querySelector('img')).toBeNull() }) + it('falls back when an imported icon fails and retries when the icon changes', async () => { + await render({ ...CREDENTIAL, icon: 'data:image/png;base64,broken' }) + act(() => container.querySelector('img')?.dispatchEvent(new Event('error'))) + expect(container.querySelector('img')).toBeNull() + + await render({ ...CREDENTIAL, icon: 'data:image/png;base64,replacement' }) + expect(container.querySelector('img')?.getAttribute('src')).toBe( + 'data:image/png;base64,replacement' + ) + }) + + it('does not load remote logos for saved-password sites', async () => { + await render({ ...CREDENTIAL, icon: 'https://example.com/favicon.png' }) + expect(container.querySelector('img')).toBeNull() + }) + it('returns to the list', async () => { await render() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx index 4ebca111de6..8bcc7e97f5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx @@ -12,10 +12,10 @@ import { Duplicate, Eye, EyeOff, - Key, Tooltip, toast, } from '@sim/emcn' +import { BrowserCredentialIcon } from '@/components/browser-credential-icon' import { getDesktopBridge } from '@/lib/desktop' import { RESOURCE_TILE_BASE, @@ -143,14 +143,7 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta
    - {credential.icon ? ( - // A `data:` URL copied from the source browser at import - // time — never a network request, which would disclose - // which sites the user has passwords for. - - ) : ( - - )} +
    diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx index 88c7238866b..e93a9efa44d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx @@ -85,27 +85,16 @@ vi.mock( () => ({ SettingsEmptyState: ({ children }: { children: ReactNode }) =>

    {children}

    }) ) -vi.mock( - '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal', - () => ({ - ImportModal: ({ - open, - profiles, - onImport, - }: { - open: boolean - profiles: Array<{ id: string; label: string }> - onImport: (profile: { id: string; label: string }) => void - }) => - open ? ( -
    - -
    - ) : null, - }) -) +vi.mock('@/components/browser-import/browser-import-dialog', () => ({ + BrowserImportDialog: ({ open, onImported }: { open: boolean; onImported: () => void }) => + open ? ( +
    + +
    + ) : null, +})) vi.mock( '@/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail', @@ -276,23 +265,17 @@ describe('PasswordsView', () => { ).not.toContain('Delete all') }) - it('imports the chosen profile and tells the browser page to refresh', async () => { + it('opens the shared import dialog and refreshes the password list after import', async () => { await render() await click(buttonLabelled('Import')) await click(buttonLabelled('Confirm import')) - // 'replace' so a password rotated in the other browser actually lands here. - expect(bridge().browserImport.importFromChrome).toHaveBeenCalledWith( - 'chrome:Default', - 'replace' - ) - expect(mockToast.success).toHaveBeenCalledWith('Imported 4 cookies and 3 passwords from Chrome') expect(onImported).toHaveBeenCalled() }) - it('hides import when no other browser was found', async () => { - mockBridge.current = createBridge({ profiles: [] }) + it('hides import on a shell without an importer', async () => { + mockBridge.current = { browserCredentials: createBridge().browserCredentials } await render() expect( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx index a9b34d74470..05a645d0f3c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx @@ -1,15 +1,11 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' -import type { - BrowserChromeImportResult, - BrowserCredentialMetadata, - BrowserImportError, - BrowserImportProfile, -} from '@sim/desktop-bridge' -import { ArrowLeft, ChipConfirmModal, Key, Plus, toast } from '@sim/emcn' +import { useCallback, useMemo, useState } from 'react' +import type { BrowserCredentialMetadata } from '@sim/desktop-bridge' +import { ArrowLeft, ChipConfirmModal, Plus, toast } from '@sim/emcn' +import { BrowserCredentialIcon } from '@/components/browser-credential-icon' +import { BrowserImportDialog } from '@/components/browser-import/browser-import-dialog' import { getDesktopBridge } from '@/lib/desktop' -import { ImportModal } from '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal' import { PasswordDetail } from '@/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -19,20 +15,6 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -const IMPORT_ERROR_MESSAGES: Record = { - 'unsupported-platform': 'Importing from another browser is only supported on macOS.', - 'chrome-not-found': 'Could not find that browser profile.', - 'keychain-unavailable': - 'Sim needs your permission to read that browser’s saved data. Allow the Keychain prompt and try again.', - 'profile-unreadable': - 'Could not read that browser’s data. Try quitting the other browser, then import again.', - 'unsupported-schema': 'That browser stores its data in a format Sim cannot read yet.', - 'nothing-imported': 'Nothing from that profile could be imported.', - 'vault-unavailable': - 'This device cannot store passwords securely, so saved passwords were not imported.', - unknown: 'Could not import from that browser.', -} - function siteLabel(origin: string): string { return origin.replace(/^https?:\/\//, '') } @@ -41,15 +23,6 @@ function pluralize(count: number, noun: string): string { return `${count} ${count === 1 ? noun : `${noun}s`}` } -/** Describes what actually landed, without over-claiming that sites are signed in. */ -function summarize({ cookies, passwords }: BrowserChromeImportResult): string | null { - const parts: string[] = [] - if (cookies.cookiesImported > 0) parts.push(pluralize(cookies.cookiesImported, 'cookie')) - const saved = passwords.passwordsAdded + passwords.passwordsUpdated - if (saved > 0) parts.push(pluralize(saved, 'password')) - return parts.length > 0 ? `Imported ${parts.join(' and ')}` : null -} - interface PasswordsViewProps { credentials: BrowserCredentialMetadata[] initialImportOpen?: boolean @@ -75,51 +48,7 @@ export function PasswordsView({ const [selectedId, setSelectedId] = useState(null) const [confirmingDeleteAll, setConfirmingDeleteAll] = useState(false) const [deleteAllPending, setDeleteAllPending] = useState(false) - const [profiles, setProfiles] = useState([]) const [importOpen, setImportOpen] = useState(initialImportOpen) - const [importPending, setImportPending] = useState(false) - - useEffect(() => { - // Absent on platforms where the local importer cannot run. - const listProfiles = getDesktopBridge()?.browserImport?.listChromeProfiles - if (!listProfiles) return - void listProfiles() - .then(setProfiles) - .catch(() => setProfiles([])) - }, []) - - /** - * Runs straight off the modal's Import click: the shell only accepts an - * import while the page has an active user gesture, so this must not be - * deferred behind another await first. - */ - const importFromBrowser = useCallback( - async (profile: BrowserImportProfile) => { - const runImport = getDesktopBridge()?.browserImport?.importFromChrome - if (!runImport) return - setImportPending(true) - try { - // 'replace' so a password rotated in the other browser actually lands - // here. Sim cannot edit passwords itself, so the browser being - // imported from is always the more current source. - const result = await runImport(profile.id, 'replace') - const summary = summarize(result) - if (summary) { - toast.success(`${summary} from ${profile.label}`) - setImportOpen(false) - } else { - const error = result.cookies.error ?? result.passwords.error - toast.error(error ? IMPORT_ERROR_MESSAGES[error] : 'Nothing new to import') - } - await onImported() - } catch { - toast.error('Could not import from that browser') - } finally { - setImportPending(false) - } - }, - [onImported] - ) const forgetAll = useCallback(async () => { const bridge = getDesktopBridge()?.browserCredentials @@ -156,7 +85,7 @@ export function PasswordsView({ ) } - const canImport = profiles.length > 0 + const canImport = Boolean(getDesktopBridge()?.browserImport?.importFromChrome) return ( <> @@ -183,7 +112,6 @@ export function PasswordsView({ icon: Plus, variant: 'primary' as const, onSelect: () => setImportOpen(true), - disabled: importPending, }, ] : []), @@ -199,17 +127,7 @@ export function PasswordsView({ {filtered.map((credential) => ( - ) : ( - - ) - } + icon={} iconFill title={siteLabel(credential.origin)} description={credential.username || 'No username'} @@ -229,13 +147,7 @@ export function PasswordsView({ )} - void importFromBrowser(profile)} - /> + { const actualValue = wandHook.isStreaming ? localContent - : useWebhookUrl && webhookManagement.webhookUrl + : (useWebhookUrl || config.providerWebhookUrl) && webhookManagement.webhookUrl ? webhookManagement.webhookUrl : ctrlValue const actualValueString = actualValue ?? '' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index bc89baccf3f..2fd31809eac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -485,6 +485,7 @@ function SubBlockComponent({ triggerId: undefined, isPreview, useWebhookUrl: config.useWebhookUrl, + providerWebhookUrl: config.providerWebhookUrl, }) const handleMouseDown = (e: MouseEvent): void => { @@ -1242,7 +1243,9 @@ function SubBlockComponent({ canonicalToggle, Boolean(canonicalToggle?.disabled || disabled || isPreview), { - showCopyButton: Boolean(config.showCopyButton && config.useWebhookUrl), + showCopyButton: Boolean( + config.showCopyButton && (config.useWebhookUrl || config.providerWebhookUrl) + ), copied, onCopy: handleCopy, }, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 92f1988bbc6..475d610c851 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -46,6 +46,7 @@ import { useSession } from '@/lib/auth/auth-client' import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-tool' import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' import type { OAuthProvider } from '@/lib/oauth' +import { usesCredentialConfiguredOAuthClient } from '@/lib/oauth/utils' import { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' import { DEFAULT_HORIZONTAL_SPACING, @@ -343,6 +344,9 @@ const WorkflowContent = React.memo( requiredScopes: string[] newScopes?: string[] } | null>(null) + const oauthModalRequiresDraft = oauthModal + ? usesCredentialConfiguredOAuthClient(oauthModal.provider) + : false const params = useParams() const router = useRouter() @@ -589,16 +593,18 @@ const WorkflowContent = React.memo( const detail = (event as CustomEvent).detail if (!detail) return - writeOAuthReturnContext({ - origin: 'workflow', - workflowId: workflowIdParam, - displayName: detail.providerName, - providerId: detail.providerId, - preCount: 0, - workspaceId, - reconnect: true, - requestedAt: Date.now(), - }) + if (!usesCredentialConfiguredOAuthClient(detail.providerId)) { + writeOAuthReturnContext({ + origin: 'workflow', + workflowId: workflowIdParam, + displayName: detail.providerName, + providerId: detail.providerId, + preCount: 0, + workspaceId, + reconnect: true, + requestedAt: Date.now(), + }) + } setOauthModal({ provider: detail.providerId as OAuthProvider, @@ -5342,7 +5348,26 @@ const WorkflowContent = React.memo( {!embedded && } - {!embedded && oauthModal && ( + {!embedded && oauthModal && oauthModalRequiresDraft && ( + { + if (!open) { + setOauthModal(null) + } + }} + provider={oauthModal.provider} + providerId={oauthModal.provider} + serviceId={oauthModal.serviceId} + requiredScopes={oauthModal.requiredScopes} + workspaceId={workspaceId} + workflowId={workflowIdParam} + /> + )} + + {!embedded && oauthModal && !oauthModalRequiresDraft && ( ({ + mockDispatch: vi.fn(), + mockEnqueue: vi.fn(), + mockFindWebhooks: vi.fn(), +})) +vi.mock('@trigger.dev/sdk', () => ({ + task: vi.fn((config: unknown) => config), +})) +vi.mock('@/lib/webhooks/processor', () => ({ + dispatchResolvedWebhookTarget: mockDispatch, + findWebhooksByRoutingKey: mockFindWebhooks, +})) +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn(async () => ({ enqueue: mockEnqueue })), +})) + +import { + enqueueQuickBooksWebhookIngress, + executeQuickBooksWebhookIngress, + type QuickBooksWebhookIngressPayload, +} from '@/background/quickbooks-webhook-ingress' + +const event = { + specversion: '1.0', + id: 'event-1', + source: 'quickbooks-online', + type: 'qbo.invoice.created.v1', + time: '2026-08-03T12:00:00Z', + intuitentityid: '123', + intuitaccountid: '456', +} +const payload: QuickBooksWebhookIngressPayload = { + appKey: 'a'.repeat(43), + events: [event, { ...event, id: 'event-2', intuitaccountid: '789' }], + headers: { 'content-type': 'application/json' }, + requestId: 'request-1', + receivedAt: 1, +} + +describe('QuickBooks webhook ingress job', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnqueue.mockResolvedValue('job-1') + }) + + it('routes the batch by company and dispatches targets sequentially', async () => { + const order: string[] = [] + mockFindWebhooks + .mockResolvedValueOnce([ + { webhook: { id: 'w1' }, workflow: { id: 'wf1' } }, + { webhook: { id: 'w2' }, workflow: { id: 'wf2' } }, + ]) + .mockResolvedValueOnce([{ webhook: { id: 'w3' }, workflow: { id: 'wf3' } }]) + mockDispatch.mockImplementation(async (webhook: { id: string }) => { + order.push(webhook.id) + return { outcome: 'queued' } + }) + await expect(executeQuickBooksWebhookIngress(payload)).resolves.toEqual({ + failed: 0, + ignored: 0, + processed: 3, + targetCount: 3, + }) + expect(mockFindWebhooks).toHaveBeenNthCalledWith( + 1, + `${payload.appKey}:456`, + 'request-1', + 'quickbooks' + ) + expect(mockFindWebhooks).toHaveBeenNthCalledWith( + 2, + `${payload.appKey}:789`, + 'request-1', + 'quickbooks' + ) + expect(order).toEqual(['w1', 'w2', 'w3']) + }) + + it('enqueues the bounded delivery once without copying it into continuation jobs', async () => { + mockFindWebhooks.mockResolvedValue([]) + await enqueueQuickBooksWebhookIngress(payload) + const options = mockEnqueue.mock.calls[0][2] as { + runner: () => Promise + } + await options.runner() + expect(mockEnqueue).toHaveBeenCalledOnce() + expect(mockEnqueue).toHaveBeenCalledWith( + 'quickbooks-webhook-ingress', + payload, + expect.objectContaining({ + jobId: expect.stringMatching(/^quickbooks-webhook-ingress:[A-Za-z0-9_-]{43}$/), + }) + ) + }) + + it('continues later events before retrying a delivery with failed targets', async () => { + mockFindWebhooks + .mockResolvedValueOnce([ + { webhook: { id: 'w1' }, workflow: { id: 'wf1' } }, + { webhook: { id: 'w2' }, workflow: { id: 'wf2' } }, + ]) + .mockResolvedValueOnce([{ webhook: { id: 'w3' }, workflow: { id: 'wf3' } }]) + mockDispatch + .mockRejectedValueOnce(new Error('dispatch unavailable')) + .mockResolvedValueOnce({ outcome: 'failed' }) + .mockResolvedValueOnce({ outcome: 'queued' }) + + await enqueueQuickBooksWebhookIngress(payload) + const options = mockEnqueue.mock.calls[0][2] as { + runner: () => Promise + } + await expect(options.runner()).rejects.toThrow( + 'QuickBooks webhook delivery completed with 2 failures' + ) + expect(mockFindWebhooks).toHaveBeenCalledWith( + `${payload.appKey}:789`, + 'request-1', + 'quickbooks' + ) + expect(mockDispatch).toHaveBeenCalledTimes(3) + expect(mockEnqueue).toHaveBeenCalledOnce() + }) + + it('continues later events when targets cannot be resolved', async () => { + mockFindWebhooks + .mockRejectedValueOnce(new Error('database unavailable')) + .mockResolvedValueOnce([]) + + await expect(executeQuickBooksWebhookIngress(payload)).resolves.toEqual({ + failed: 1, + ignored: 0, + processed: 0, + targetCount: 0, + }) + expect(mockFindWebhooks).toHaveBeenCalledWith( + `${payload.appKey}:789`, + 'request-1', + 'quickbooks' + ) + }) +}) diff --git a/apps/sim/background/quickbooks-webhook-ingress.ts b/apps/sim/background/quickbooks-webhook-ingress.ts new file mode 100644 index 00000000000..ae697aa7c1d --- /dev/null +++ b/apps/sim/background/quickbooks-webhook-ingress.ts @@ -0,0 +1,140 @@ +import { createHash } from 'node:crypto' +import { createLogger } from '@sim/logger' +import { task } from '@trigger.dev/sdk' +import { NextRequest } from 'next/server' +import type { QuickBooksWebhookEvent } from '@/lib/api/contracts/webhooks' +import { getJobQueue } from '@/lib/core/async-jobs' +import { dispatchResolvedWebhookTarget, findWebhooksByRoutingKey } from '@/lib/webhooks/processor' +import { buildQuickBooksWebhookRoutingKey } from '@/lib/webhooks/quickbooks-credentials' + +const logger = createLogger('QuickBooksWebhookIngressTask') + +export const QUICKBOOKS_WEBHOOK_INGRESS_CONCURRENCY_LIMIT = 50 +export const QUICKBOOKS_WEBHOOK_INGRESS_MAX_ATTEMPTS = 3 + +export interface QuickBooksWebhookIngressPayload { + appKey: string + events: QuickBooksWebhookEvent[] + headers: { 'content-type': string } + requestId: string + receivedAt: number +} + +export interface QuickBooksWebhookIngressResult { + failed: number + ignored: number + processed: number + targetCount: number +} + +/** Process the bounded delivery sequentially through the shared routing-key dispatcher. */ +export async function executeQuickBooksWebhookIngress( + payload: QuickBooksWebhookIngressPayload +): Promise { + let ignored = 0 + let processed = 0 + let failed = 0 + let targetCount = 0 + + for (const [eventIndex, event] of payload.events.entries()) { + const request = new NextRequest( + `http://internal/api/webhooks/quickbooks/${encodeURIComponent(payload.appKey)}`, + { + method: 'POST', + headers: payload.headers, + body: JSON.stringify(event), + } + ) + + try { + const routingKey = buildQuickBooksWebhookRoutingKey(payload.appKey, event.intuitaccountid) + const targets = await findWebhooksByRoutingKey(routingKey, payload.requestId, 'quickbooks') + targetCount += targets.length + + for (const { webhook, workflow } of targets) { + try { + const result = await dispatchResolvedWebhookTarget(webhook, workflow, event, request, { + requestId: payload.requestId, + path: webhook.path ?? undefined, + receivedAt: payload.receivedAt, + triggerTimestampMs: Date.parse(event.time), + }) + if (result.outcome === 'queued') processed += 1 + else if (result.outcome === 'ignored') ignored += 1 + else failed += 1 + } catch (error) { + failed += 1 + logger.error(`[${payload.requestId}] QuickBooks webhook target dispatch failed`, { + error, + eventId: event.id, + eventIndex, + webhookId: webhook.id, + }) + } + } + + logger.info(`[${payload.requestId}] QuickBooks webhook event completed`, { + eventId: event.id, + eventIndex, + ignored, + processed, + targetCount: targets.length, + }) + } catch (error) { + failed += 1 + logger.error(`[${payload.requestId}] QuickBooks webhook target lookup failed`, { + error, + eventId: event.id, + eventIndex, + }) + } + } + + logger.info(`[${payload.requestId}] QuickBooks webhook delivery completed`, { + eventCount: payload.events.length, + failed, + ignored, + processed, + targetCount, + }) + return { failed, ignored, processed, targetCount } +} + +async function runQuickBooksWebhookIngressJob( + payload: QuickBooksWebhookIngressPayload +): Promise { + const result = await executeQuickBooksWebhookIngress(payload) + if (result.failed > 0) { + throw new Error(`QuickBooks webhook delivery completed with ${result.failed} failures`) + } +} + +export async function enqueueQuickBooksWebhookIngress( + payload: QuickBooksWebhookIngressPayload +): Promise { + const jobQueue = await getJobQueue() + const deliveryHash = createHash('sha256').update(payload.appKey).update('\0') + for (const event of payload.events) { + deliveryHash.update(event.id).update('\0') + } + return jobQueue.enqueue('quickbooks-webhook-ingress', payload, { + jobId: `quickbooks-webhook-ingress:${deliveryHash.digest('base64url')}`, + maxAttempts: QUICKBOOKS_WEBHOOK_INGRESS_MAX_ATTEMPTS, + concurrencyKey: 'quickbooks-webhook-ingress', + concurrencyLimit: QUICKBOOKS_WEBHOOK_INGRESS_CONCURRENCY_LIMIT, + runner: async () => runQuickBooksWebhookIngressJob(payload), + }) +} + +export const quickBooksWebhookIngressTask = task({ + id: 'quickbooks-webhook-ingress', + machine: 'small-1x', + retry: { + maxAttempts: QUICKBOOKS_WEBHOOK_INGRESS_MAX_ATTEMPTS, + factor: 2, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10_000, + }, + queue: { concurrencyLimit: QUICKBOOKS_WEBHOOK_INGRESS_CONCURRENCY_LIMIT }, + run: async (payload: QuickBooksWebhookIngressPayload) => runQuickBooksWebhookIngressJob(payload), +}) diff --git a/apps/sim/background/webhook-execution.test.ts b/apps/sim/background/webhook-execution.test.ts index a8643254eb1..9fc447461f9 100644 --- a/apps/sim/background/webhook-execution.test.ts +++ b/apps/sim/background/webhook-execution.test.ts @@ -11,7 +11,10 @@ import { loggerMock, loggingSessionMock, loggingSessionMockFns, + redisConfigMockFns, + resetEnvFlagsMock, resetEnvironmentUtilsMock, + setEnvFlags, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -20,8 +23,6 @@ const { mockExecuteWorkflowCore, mockWasExecutionFinalizedByCore, mockExecuteWithIdempotency, - mockRefreshExecutionSlotExpiry, - mockReleaseExecutionSlot, mockLoadDeploymentVersionState, mockGetProviderHandler, mockSetResolvedSecretTraceRegistry, @@ -35,8 +36,6 @@ const { mockExecuteWorkflowCore: vi.fn(), mockWasExecutionFinalizedByCore: vi.fn(), mockExecuteWithIdempotency: vi.fn(), - mockRefreshExecutionSlotExpiry: vi.fn().mockResolvedValue(true), - mockReleaseExecutionSlot: vi.fn(), mockGetProviderHandler: vi.fn(() => ({})), mockSetResolvedSecretTraceRegistry: vi.fn(), mockExecutionSnapshot: vi.fn(), @@ -75,11 +74,6 @@ vi.mock('@/lib/workflows/executor/execution-core', () => ({ wasExecutionFinalizedByCore: mockWasExecutionFinalizedByCore, })) -vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ - refreshExecutionSlotExpiry: mockRefreshExecutionSlotExpiry, - releaseExecutionSlot: mockReleaseExecutionSlot, -})) - vi.mock('@/lib/core/idempotency', () => ({ IdempotencyService: { createWebhookIdempotencyKey: vi.fn(() => 'idempotency-key') }, webhookIdempotency: { @@ -108,8 +102,8 @@ vi.mock('@/lib/core/execution-limits', () => ({ capExecutionTimeoutMs: vi.fn((policyTimeoutMs, requestedTimeoutMs) => requestedTimeoutMs === undefined ? policyTimeoutMs : requestedTimeoutMs ), - createTimeoutAbortController: vi.fn(() => ({ - signal: new AbortController().signal, + createTimeoutAbortController: vi.fn((_timeoutMs: number, signal?: AbortSignal) => ({ + signal: signal ?? new AbortController().signal, cleanup: vi.fn(), isTimedOut: () => false, timeoutMs: 120_000, @@ -150,12 +144,19 @@ vi.mock('@/triggers', () => ({ isTriggerValid: vi.fn(() => false), })) +import * as usageReservation from '@/lib/billing/calculations/usage-reservation' import { isRetryableSetupError } from '@/lib/core/errors/retryable-infrastructure' import { executeWebhookJob, resolveWebhookExecutionProviderConfig, type WebhookExecutionPayload, -} from './webhook-execution' +} from '@/background/webhook-execution' + +const actualRefreshExecutionSlotExpiry = usageReservation.refreshExecutionSlotExpiry +const mockRefreshExecutionSlotExpiry = vi.spyOn(usageReservation, 'refreshExecutionSlotExpiry') +const mockReleaseExecutionSlot = vi.spyOn(usageReservation, 'releaseExecutionSlot') + +afterAll(resetEnvFlagsMock) const webhookExecutionLoggerCallIndex = loggerMock.createLogger.mock.calls.findIndex( ([name]) => name === 'TriggerWebhookExecution' @@ -284,8 +285,10 @@ describe('executeWebhookJob fault vs error handling', () => { projectDiagnosticError: loggingSessionMockFns.mockProjectDiagnosticError, } }) + mockRefreshExecutionSlotExpiry.mockReset().mockResolvedValue(true) + mockReleaseExecutionSlot.mockReset().mockResolvedValue(undefined) mockGetProviderHandler.mockReturnValue({}) - mockEnqueue.mockResolvedValue('run_retry') + mockEnqueue.mockReset().mockResolvedValue('run_retry') mockExecuteWithIdempotency.mockImplementation( (_provider: string, _key: string, operation: () => Promise) => operation() ) @@ -733,6 +736,126 @@ describe('executeWebhookJob fault vs error handling', () => { expect(loggingSessionMockFns.mockSafeCompleteWithError).not.toHaveBeenCalled() }) + it('recovers from the uncoded Redis command timeout without running the first attempt', async () => { + setEnvFlags({ isHosted: true, isBillingEnabled: true }) + const timeoutError = new Error('Command timed out') + const redisGet = vi.fn().mockRejectedValueOnce(timeoutError) + redisConfigMockFns.mockGetRedisClient.mockReturnValue({ get: redisGet }) + mockRefreshExecutionSlotExpiry.mockImplementationOnce(actualRefreshExecutionSlotExpiry) + + const result = await executeWebhookJob(payload) + + expect(redisGet).toHaveBeenCalledWith('usage:reservation:execution-1') + expect(result).toMatchObject({ success: false, requeued: true }) + expect(mockExecuteWithIdempotency).not.toHaveBeenCalled() + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(loggingSessionMockFns.mockSafeCompleteWithError).not.toHaveBeenCalled() + expect(mockReleaseExecutionSlot).toHaveBeenCalledExactlyOnceWith('execution-1') + expect(mockEnqueue).toHaveBeenCalledTimes(1) + expect(mockReleaseExecutionSlot.mock.invocationCallOrder[0]).toBeLessThan( + mockEnqueue.mock.invocationCallOrder[0] + ) + const [jobType, retryPayload, options] = mockEnqueue.mock.calls[0] + expect(jobType).toBe('webhook-execution') + expect(retryPayload).toMatchObject({ ...payload, infraRetryCount: 1 }) + expect(options.delayMs).toBeGreaterThan(0) + expect(options.delayMs).toBeLessThanOrEqual(300_000) + + mockRefreshExecutionSlotExpiry.mockResolvedValueOnce(false) + mockExecuteWorkflowCore.mockResolvedValueOnce({ + success: true, + status: 'completed', + output: {}, + logs: [], + }) + + await expect(executeWebhookJob(retryPayload)).resolves.toMatchObject({ success: true }) + + expect(executionPreprocessingMockFns.mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ executionId: 'execution-1', skipUsageLimits: false }) + ) + expect(mockExecuteWorkflowCore).toHaveBeenCalledTimes(1) + expect(mockEnqueue).toHaveBeenCalledTimes(1) + }) + + it('records a terminal refresh failure when the retry budget is exhausted', async () => { + const cause = new Error('Command timed out') + const error = new usageReservation.UsageReservationUnavailableError( + 'Usage reservation refresh is temporarily unavailable. Please retry.', + cause + ) + mockRefreshExecutionSlotExpiry.mockRejectedValueOnce(error) + + await expect(executeWebhookJob({ ...payload, infraRetryCount: 5 })).rejects.toMatchObject({ + name: 'RetryableSetupError', + cause: error, + }) + + expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockExecuteWithIdempotency).not.toHaveBeenCalled() + expect(mockReleaseExecutionSlot).toHaveBeenCalledExactlyOnceWith('execution-1') + expect(loggingSessionMockFns.mockSafeStart).toHaveBeenCalledTimes(1) + expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ error: expect.objectContaining({ message: error.message }) }) + ) + }) + + it('records the original refresh failure when enqueueing its replacement fails', async () => { + const error = new usageReservation.UsageReservationUnavailableError( + 'Usage reservation refresh is temporarily unavailable. Please retry.', + new Error('Command timed out') + ) + mockRefreshExecutionSlotExpiry.mockRejectedValueOnce(error) + mockEnqueue.mockRejectedValueOnce(new Error('trigger api unavailable')) + + await expect(executeWebhookJob(payload)).rejects.toMatchObject({ cause: error }) + + expect(mockEnqueue).toHaveBeenCalledTimes(1) + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ error: expect.objectContaining({ message: error.message }) }) + ) + }) + + it('does not requeue a refresh failure when the attempt was cancelled', async () => { + const controller = new AbortController() + const error = new usageReservation.UsageReservationUnavailableError('Redis unavailable') + mockRefreshExecutionSlotExpiry.mockImplementationOnce(async () => { + controller.abort() + throw error + }) + + await expect(executeWebhookJob(payload, controller.signal)).rejects.toMatchObject({ + cause: error, + }) + + expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(mockReleaseExecutionSlot).toHaveBeenCalledExactlyOnceWith('execution-1') + expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalledTimes(1) + }) + + it('does not requeue a non-transient refresh failure', async () => { + const error = new TypeError('Invalid Redis configuration') + mockRefreshExecutionSlotExpiry.mockRejectedValueOnce(error) + + await expect(executeWebhookJob(payload)).rejects.toBe(error) + + expect(mockReleaseExecutionSlot).toHaveBeenCalledExactlyOnceWith('execution-1') + expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockExecuteWithIdempotency).not.toHaveBeenCalled() + }) + + it('does not treat an ambiguous idempotency claim timeout as a safe setup retry', async () => { + const error = new Error('Command timed out') + mockExecuteWithIdempotency.mockRejectedValueOnce(error) + + await expect(executeWebhookJob(payload)).rejects.toBe(error) + + expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + }) + it('requeues on retryable infrastructure errors thrown by setup reads', async () => { dbChainMockFns.limit.mockRejectedValueOnce( Object.assign(new Error('write CONNECT_TIMEOUT'), { code: 'CONNECT_TIMEOUT' }) @@ -781,19 +904,23 @@ describe('executeWebhookJob fault vs error handling', () => { expect(mockEnqueue).not.toHaveBeenCalled() }) - it('never reclassifies infrastructure errors after the workflow core started', async () => { - const infraError = Object.assign(new Error('Connection terminated unexpectedly'), { + it.each([ + new Error('Command timed out'), + Object.assign(new Error('Connection terminated unexpectedly'), { code: 'CONNECTION_CLOSED', - }) - mockExecuteWorkflowCore.mockRejectedValue(infraError) - mockWasExecutionFinalizedByCore.mockReturnValue(false) + }), + ])( + 'never reclassifies infrastructure errors after the workflow core started: %s', + async (infraError) => { + mockExecuteWorkflowCore.mockRejectedValue(infraError) + mockWasExecutionFinalizedByCore.mockReturnValue(false) - await expect(executeWebhookJob(payload)).rejects.toBe(infraError) + await expect(executeWebhookJob(payload)).rejects.toBe(infraError) - expect(mockEnqueue).not.toHaveBeenCalled() - // Post-core failures keep recording the terminal row. - expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalled() - }) + expect(mockEnqueue).not.toHaveBeenCalled() + expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalled() + } + ) it('faults the run and restores the terminal log row when the requeue enqueue itself fails', async () => { executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValueOnce({ diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index fa82e7b244d..6ebcac7d17a 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -434,11 +434,9 @@ async function requeueWebhookExecutionAfterSetupFailure( } /** - * Restores the terminal failed execution-log row for a setup failure whose - * replacement enqueue failed. Attempts headed for a requeue suppress their - * failure row so the retry can reuse the execution id; once the requeue is - * known to have failed, no retry will run, so the row must be written here or - * the delivery faults without any execution record. Best-effort by design: + * Records a terminal setup failure when no replacement will run and setup + * either never reached preprocessing or suppressed its failure row for a retry. + * Best-effort by design: * the same infrastructure outage that broke setup may also break this write, * in which case the faulted run remains the only signal — matching how * preprocessing's own error logging degrades. @@ -470,14 +468,11 @@ async function recordSetupFailureWithoutRequeue( skipCost: true, }) } catch (loggingError) { - logger.error( - `[${correlation.requestId}] Failed to record webhook setup failure after requeue failure`, - { - workflowId: payload.workflowId, - executionId: correlation.executionId, - error: loggingError, - } - ) + logger.error(`[${correlation.requestId}] Failed to record terminal webhook setup failure`, { + workflowId: payload.workflowId, + executionId: correlation.executionId, + error: loggingError, + }) } } @@ -536,16 +531,23 @@ export async function executeWebhookJob( ), externalAbortSignal ) + let operationStarted = false try { const executionDeadlineAt = getExecutionDeadlineAt(timeoutController.signal)?.getTime() - const admissionCompleted = - executionDeadlineAt === undefined - ? true - : await refreshExecutionSlotExpiry( - executionId, - executionDeadlineAt + RESERVATION_TTL_BUFFER_MS - ) + let admissionCompleted = true + if (executionDeadlineAt !== undefined) { + try { + admissionCompleted = await refreshExecutionSlotExpiry( + executionId, + executionDeadlineAt + RESERVATION_TTL_BUFFER_MS + ) + } catch (error) { + if (!isRetryableInfrastructureError(error)) throw error + /** No idempotency claim or workflow block exists yet; only the usage lease was refreshed. */ + throw new RetryableSetupError(toError(error).message, { cause: error }) + } + } if (!admissionCompleted) { logger.warn('Queued webhook reservation expired; repeating usage admission', { workflowId: authenticatedPayload.workflowId, @@ -569,7 +571,6 @@ export async function executeWebhookJob( authenticatedPayload.provider ) - let operationStarted = false const runOperation = async () => { operationStarted = true return await executeWebhookJobInternal( @@ -581,53 +582,53 @@ export async function executeWebhookJob( ) } - try { - const result = await webhookIdempotency.executeWithIdempotency( - authenticatedPayload.provider, - idempotencyKey, - runOperation, - undefined, - { - inProgressExpiresAt: - executionDeadlineAt === undefined - ? Date.now() + WEBHOOK_IN_PROGRESS_LEASE_SECONDS * 1000 - : executionDeadlineAt + RESERVATION_TTL_BUFFER_MS, - } - ) - if (!operationStarted) { - await releaseExecutionSlot(executionId) + const result = await webhookIdempotency.executeWithIdempotency( + authenticatedPayload.provider, + idempotencyKey, + runOperation, + undefined, + { + inProgressExpiresAt: + executionDeadlineAt === undefined + ? Date.now() + WEBHOOK_IN_PROGRESS_LEASE_SECONDS * 1000 + : executionDeadlineAt + RESERVATION_TTL_BUFFER_MS, } - return result - } catch (error) { + ) + if (!operationStarted) { await releaseExecutionSlot(executionId) + } + return result + }) + } catch (error) { + await releaseExecutionSlot(executionId) - /** - * A typed setup failure certifies no block ran and the idempotency - * claim was released, so requeueing the same delivery cannot double - * run it; the retry re-admits usage and re-claims from scratch. When - * the requeue enqueue itself fails, restore the terminal failure row - * the retry-bound attempt suppressed, then fall through to the throw - * so the run fails loudly rather than dropping the delivery silently. - */ - if (isRetryableSetupError(error) && hasRemainingWebhookInfraRetry(authenticatedPayload)) { - if ( - await requeueWebhookExecutionAfterSetupFailure(authenticatedPayload, correlation, error) - ) { - return { - success: false, - requeued: true, - workflowId: authenticatedPayload.workflowId, - executionId, - output: {}, - executedAt: new Date().toISOString(), - provider: authenticatedPayload.provider, - } - } - await recordSetupFailureWithoutRequeue(authenticatedPayload, correlation, error) + /** + * Only typed setup failures certify that no block ran and any idempotency + * claim was released (or never acquired). The replacement re-admits usage + * and reclaims the same delivery; arbitrary execution errors must not replay. + */ + if (isRetryableSetupError(error)) { + const hasRemainingRetry = hasRemainingWebhookInfraRetry(authenticatedPayload) + if ( + hasRemainingRetry && + !timeoutController.signal.aborted && + (await requeueWebhookExecutionAfterSetupFailure(authenticatedPayload, correlation, error)) + ) { + return { + success: false, + requeued: true, + workflowId: authenticatedPayload.workflowId, + executionId, + output: {}, + executedAt: new Date().toISOString(), + provider: authenticatedPayload.provider, } - throw error } - }) + if (!operationStarted || hasRemainingRetry) { + await recordSetupFailureWithoutRequeue(authenticatedPayload, correlation, error) + } + } + throw error } finally { timeoutController.cleanup() } diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index 1f8aeb28cbd..693b2d00bdf 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -943,7 +943,7 @@ describe.concurrent('Blocks Module', () => { expect(providerSubBlock?.commandSearchable).toBe(true) expect(providerSubBlock?.value?.()).toBe('openai') - expect(providerIds).toEqual(['openai', 'gemini', 'cohere', 'mistral', 'openrouter']) + expect(providerIds).toEqual(['openai', 'gemini', 'cohere', 'mistral', 'openrouter', 'ollama']) for (const provider of providerIds) { // Each provider routes to its own registered tool... @@ -953,7 +953,9 @@ describe.concurrent('Blocks Module', () => { const modelSubBlock = block?.subBlocks.find( (sb) => sb.id === 'model' && sb.condition?.value === provider ) - if (provider === 'openrouter') { + // OpenRouter's catalog is remote and Ollama's is the deployment's own, + // so neither can be enumerated here. + if (provider === 'openrouter' || provider === 'ollama') { expect(modelSubBlock?.selectorKey).toBeTypeOf('string') } else { expect( diff --git a/apps/sim/blocks/blocks/cloudtrail.test.ts b/apps/sim/blocks/blocks/cloudtrail.test.ts new file mode 100644 index 00000000000..fa6527863b9 --- /dev/null +++ b/apps/sim/blocks/blocks/cloudtrail.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + * + * A dropdown subBlock with no `value()` seeds and persists its first selectable option, + * so a block's *default* configuration is not necessarily one that runs. These tests + * exercise the default the user actually gets on drop, which no other suite covers. + */ +import { describe, expect, it } from 'vitest' +import { CloudTrailBlock } from '@/blocks/blocks/cloudtrail' + +type SubBlock = (typeof CloudTrailBlock.subBlocks)[number] + +function subBlock(id: string): SubBlock { + const found = CloudTrailBlock.subBlocks.find((block) => block.id === id) + if (!found) throw new Error(`missing subBlock ${id}`) + return found +} + +/** Mirrors the dropdown's seeding rule: an explicit `value()` wins, else the first option. */ +function seededValue(block: SubBlock): unknown { + if (typeof block.value === 'function') return block.value() + const options = block.options + if (!Array.isArray(options)) return undefined + const first = options[0] as { id?: unknown } | undefined + return first?.id +} + +describe('CloudTrail block defaults', () => { + it('seeds no lookup filter attribute, so the default run is unfiltered', () => { + expect(seededValue(subBlock('attributeKey'))).toBe('') + }) + + it('offers a selectable no-filter option so the choice can be undone', () => { + const options = subBlock('attributeKey').options as Array<{ id: string; label: string }> + expect(options[0]).toMatchObject({ id: '' }) + expect(options.filter((option) => option.id === '')).toHaveLength(1) + }) + + it('does not throw on the configuration a freshly dropped block produces', () => { + const params = { + operation: 'lookup_events', + awsRegion: 'us-east-1', + awsAccessKeyId: 'AKIAIOSFODNN7EXAMPLE', + awsSecretAccessKey: 'secret', + attributeKey: seededValue(subBlock('attributeKey')), + attributeValue: '', + } + + expect(() => CloudTrailBlock.tools.config?.params?.(params)).not.toThrow() + }) + + it('still rejects a half-supplied filter', () => { + const params = { + operation: 'lookup_events', + awsRegion: 'us-east-1', + awsAccessKeyId: 'AKIAIOSFODNN7EXAMPLE', + awsSecretAccessKey: 'secret', + attributeKey: 'Username', + attributeValue: '', + } + + expect(() => CloudTrailBlock.tools.config?.params?.(params)).toThrow(/filter/i) + }) +}) diff --git a/apps/sim/blocks/blocks/cloudtrail.ts b/apps/sim/blocks/blocks/cloudtrail.ts new file mode 100644 index 00000000000..c34c8fcc450 --- /dev/null +++ b/apps/sim/blocks/blocks/cloudtrail.ts @@ -0,0 +1,894 @@ +import { CloudTrailIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { + CloudTrailCancelQueryResponse, + CloudTrailDescribeQueryResponse, + CloudTrailDescribeTrailsResponse, + CloudTrailGetEventDataStoreResponse, + CloudTrailGetEventSelectorsResponse, + CloudTrailGetInsightSelectorsResponse, + CloudTrailGetQueryResultsResponse, + CloudTrailGetTrailResponse, + CloudTrailGetTrailStatusResponse, + CloudTrailListEventDataStoresResponse, + CloudTrailListTagsResponse, + CloudTrailListTrailsResponse, + CloudTrailLookupEventsResponse, + CloudTrailStartQueryResponse, +} from '@/tools/cloudtrail/types' + +/** Operations that accept an opaque AWS pagination token. */ +const PAGINATED_OPERATIONS = [ + 'lookup_events', + 'list_trails', + 'get_query_results', + 'list_event_data_stores', + 'list_tags', +] + +/** Operations addressed by a single trail name or trail ARN. */ +const TRAIL_SCOPED_OPERATIONS = [ + 'get_trail', + 'get_trail_status', + 'get_event_selectors', + 'get_insight_selectors', +] + +function parseBoundedInt(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') return undefined + const parsed = Number.parseInt(String(value), 10) + return Number.isNaN(parsed) ? undefined : parsed +} + +export const CloudTrailBlock: BlockConfig< + | CloudTrailLookupEventsResponse + | CloudTrailDescribeTrailsResponse + | CloudTrailGetTrailResponse + | CloudTrailGetTrailStatusResponse + | CloudTrailListTrailsResponse + | CloudTrailGetEventSelectorsResponse + | CloudTrailGetInsightSelectorsResponse + | CloudTrailStartQueryResponse + | CloudTrailDescribeQueryResponse + | CloudTrailGetQueryResultsResponse + | CloudTrailCancelQueryResponse + | CloudTrailListEventDataStoresResponse + | CloudTrailGetEventDataStoreResponse + | CloudTrailListTagsResponse +> = { + type: 'cloudtrail', + name: 'CloudTrail', + description: 'Audit who did what in AWS with CloudTrail event history and Lake queries', + longDescription: + 'Integrate AWS CloudTrail into workflows. Look up the last 90 days of management and Insights events by user, event name, resource, or access key; inspect trail configuration, logging status, and event selectors; and run SQL queries against CloudTrail Lake event data stores. This block never changes trail or event data store configuration, and never starts or stops logging. Starting and cancelling a Lake query are the only actions that are not reads, and AWS bills Lake queries on the data they scan. Requires AWS access key and secret access key.', + docsLink: 'https://docs.sim.ai/integrations/cloudtrail', + category: 'tools', + integrationType: IntegrationType.Security, + authMode: AuthMode.ApiKey, + bgColor: 'linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)', + iconColor: '#FF4F8B', + icon: CloudTrailIcon, + canvasPresentation: { + defaultTitle: 'CloudTrail', + sentences: { + byOperation: { + lookup_events: [ + 'Look up CloudTrail events', + { text: 'where', field: 'attributeKey' }, + { text: 'is', field: 'attributeValue', core: true }, + { text: ', since', field: 'startTime' }, + { text: ', up to', field: 'lookupMaxResults', after: 'events' }, + ], + describe_trails: [ + 'Describe trails', + { text: ', limited to', field: 'trailNameList', core: true }, + ], + get_trail: [{ text: 'Read the settings of trail', field: 'trailName', core: true }], + get_trail_status: [ + { text: 'Check the logging status of trail', field: 'trailName', core: true }, + ], + list_trails: ['List every CloudTrail trail'], + get_event_selectors: [ + { text: 'Read the event selectors of trail', field: 'trailName', core: true }, + ], + get_insight_selectors: [ + 'Read Insights selectors', + { text: 'for trail', field: 'trailName', core: true }, + { text: 'for event data store', field: 'eventDataStore' }, + ], + start_query: [ + { text: 'Run the CloudTrail Lake query', field: 'queryStatement', core: true }, + { text: 'using template', field: 'queryAlias' }, + ], + describe_query: [ + { text: 'Check the status of Lake query', field: 'queryId', core: true }, + { text: 'for template', field: 'queryAlias' }, + ], + get_query_results: [ + { text: 'Fetch the results of Lake query', field: 'queryId', core: true }, + { text: ', up to', field: 'maxQueryResults', after: 'rows' }, + ], + cancel_query: [{ text: 'Cancel Lake query', field: 'queryId', core: true }], + list_event_data_stores: [ + 'List CloudTrail Lake event data stores', + { text: ', up to', field: 'eventDataStoreMaxResults' }, + ], + get_event_data_store: [ + { text: 'Read the event data store', field: 'eventDataStore', core: true }, + ], + list_tags: [{ text: 'List the tags on', field: 'resourceIdList', core: true }], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Look Up Events', id: 'lookup_events' }, + { label: 'Describe Trails', id: 'describe_trails' }, + { label: 'Get Trail', id: 'get_trail' }, + { label: 'Get Trail Status', id: 'get_trail_status' }, + { label: 'List Trails', id: 'list_trails' }, + { label: 'Get Event Selectors', id: 'get_event_selectors' }, + { label: 'Get Insight Selectors', id: 'get_insight_selectors' }, + { label: 'Start Lake Query', id: 'start_query' }, + { label: 'Describe Lake Query', id: 'describe_query' }, + { label: 'Get Lake Query Results', id: 'get_query_results' }, + { label: 'Cancel Lake Query', id: 'cancel_query' }, + { label: 'List Event Data Stores', id: 'list_event_data_stores' }, + { label: 'Get Event Data Store', id: 'get_event_data_store' }, + { label: 'List Tags', id: 'list_tags' }, + ], + value: () => 'lookup_events', + }, + { + id: 'awsRegion', + title: 'AWS Region', + type: 'short-input', + placeholder: 'us-east-1', + required: true, + }, + { + id: 'awsAccessKeyId', + title: 'AWS Access Key ID', + type: 'short-input', + placeholder: 'AKIA...', + password: true, + required: true, + }, + { + id: 'awsSecretAccessKey', + title: 'AWS Secret Access Key', + type: 'short-input', + placeholder: 'Your secret access key', + password: true, + required: true, + }, + { + id: 'attributeKey', + title: 'Filter By', + type: 'dropdown', + /** + * Every LookupEvents attribute is optional, so an unfiltered Region-wide lookup is + * the correct default. A dropdown with no `value()` seeds and persists its first + * selectable option, which would pair an attribute with an empty value and trip the + * both-or-neither guard before any AWS call — so the no-filter sentinel has to be a + * real, selectable option the user can also return to. + */ + value: () => '', + options: [ + { label: 'No filter', id: '' }, + { label: 'User Name', id: 'Username' }, + { label: 'Event Name', id: 'EventName' }, + { label: 'Event Source', id: 'EventSource' }, + { label: 'Event ID', id: 'EventId' }, + { label: 'Resource Name', id: 'ResourceName' }, + { label: 'Resource Type', id: 'ResourceType' }, + { label: 'Access Key ID', id: 'AccessKeyId' }, + { label: 'Read Only', id: 'ReadOnly' }, + ], + condition: { field: 'operation', value: 'lookup_events' }, + }, + { + id: 'attributeValue', + title: 'Filter Value', + type: 'short-input', + placeholder: 'e.g., ConsoleLogin, alice, arn:aws:s3:::my-bucket', + condition: { field: 'operation', value: 'lookup_events' }, + }, + { + id: 'startTime', + title: 'Start Time', + type: 'short-input', + placeholder: '2026-09-01T00:00:00Z', + condition: { field: 'operation', value: 'lookup_events' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp with a UTC offset for the start of the requested CloudTrail lookup window. CloudTrail event history only covers the last 90 days. Return ONLY the timestamp string.', + placeholder: 'Describe the start of the time window...', + generationType: 'timestamp', + }, + }, + { + id: 'endTime', + title: 'End Time', + type: 'short-input', + placeholder: '2026-09-04T00:00:00Z', + condition: { field: 'operation', value: 'lookup_events' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp with a UTC offset for the end of the requested CloudTrail lookup window. Return ONLY the timestamp string.', + placeholder: 'Describe the end of the time window...', + generationType: 'timestamp', + }, + }, + { + id: 'eventCategory', + title: 'Event Category', + type: 'dropdown', + options: [ + { label: 'Management events', id: 'management' }, + { label: 'Insights events', id: 'insight' }, + ], + condition: { field: 'operation', value: 'lookup_events' }, + mode: 'advanced', + value: () => 'management', + }, + { + id: 'lookupMaxResults', + title: 'Max Events', + type: 'short-input', + placeholder: '50 (AWS caps Look Up Events at 50 per page)', + condition: { field: 'operation', value: 'lookup_events' }, + mode: 'advanced', + }, + { + id: 'trailNameList', + title: 'Trail Names or ARNs', + type: 'long-input', + placeholder: 'Comma-separated names or ARNs. Leave empty for every trail in the Region', + condition: { field: 'operation', value: 'describe_trails' }, + }, + { + id: 'includeShadowTrails', + title: 'Shadow Trails', + type: 'dropdown', + options: [ + { label: 'Include (AWS default)', id: 'true' }, + { label: 'Exclude', id: 'false' }, + ], + condition: { field: 'operation', value: 'describe_trails' }, + mode: 'advanced', + value: () => 'true', + }, + { + id: 'trailName', + title: 'Trail Name or ARN', + type: 'short-input', + placeholder: 'my-org-trail, or arn:aws:cloudtrail:us-east-2:123456789012:trail/my-org-trail', + condition: { field: 'operation', value: TRAIL_SCOPED_OPERATIONS }, + required: { + field: 'operation', + value: ['get_trail', 'get_trail_status', 'get_event_selectors'], + }, + }, + { + id: 'eventDataStore', + title: 'Event Data Store', + type: 'short-input', + placeholder: 'ARN, or the ID suffix of the ARN', + condition: { + field: 'operation', + value: ['get_insight_selectors', 'get_event_data_store'], + }, + required: { field: 'operation', value: 'get_event_data_store' }, + }, + { + id: 'queryStatement', + title: 'Lake SQL Query', + type: 'code', + placeholder: + "SELECT eventTime, eventName, userIdentity.arn FROM WHERE eventName = 'ConsoleLogin' LIMIT 100", + condition: { field: 'operation', value: 'start_query' }, + wandConfig: { + enabled: true, + prompt: `Generate a CloudTrail Lake SQL query from the user's description. +CloudTrail Lake uses a Presto-based SQL dialect. The FROM clause names the event data store ID (not a table name). +Common columns: eventTime, eventName, eventSource, awsRegion, sourceIPAddress, userAgent, errorCode, errorMessage, +readOnly, recipientAccountId, requestParameters, responseElements, and the userIdentity struct +(userIdentity.type, userIdentity.arn, userIdentity.principalId, userIdentity.accountId, +userIdentity.sessionContext.sessionIssuer.userName). + +Examples: +- SELECT eventTime, eventName, userIdentity.arn FROM WHERE eventTime > '2026-08-01 00:00:00' LIMIT 100 +- SELECT userIdentity.arn, count(*) AS calls FROM WHERE errorCode IS NOT NULL GROUP BY userIdentity.arn +- SELECT eventName, sourceIPAddress FROM WHERE eventSource = 'iam.amazonaws.com' AND readOnly = false + +Return ONLY the SQL query — no explanations, no markdown code blocks.`, + placeholder: 'Describe the audit question you want answered...', + generationType: 'sql-query', + }, + }, + { + id: 'queryAlias', + title: 'Query Template Alias', + type: 'short-input', + placeholder: 'Alias of a CloudTrail Lake dashboard query template', + condition: { field: 'operation', value: ['start_query', 'describe_query'] }, + mode: 'advanced', + }, + { + id: 'queryParameters', + title: 'Query Template Parameters', + type: 'long-input', + placeholder: 'Comma-separated values for the query template, up to 10', + condition: { field: 'operation', value: 'start_query' }, + mode: 'advanced', + }, + { + id: 'deliveryS3Uri', + title: 'Results S3 URI', + type: 'short-input', + placeholder: 's3://my-cloudtrail-lake-results/', + condition: { field: 'operation', value: 'start_query' }, + mode: 'advanced', + }, + { + id: 'queryId', + title: 'Query ID', + type: 'short-input', + placeholder: 'e.g., a1b2c3d4-5678-90ab-cdef-example11111', + condition: { + field: 'operation', + value: ['describe_query', 'get_query_results', 'cancel_query'], + }, + required: { field: 'operation', value: ['get_query_results', 'cancel_query'] }, + }, + { + id: 'refreshId', + title: 'Dashboard Refresh ID', + type: 'short-input', + placeholder: 'Numeric refresh ID, used with a query template alias', + condition: { field: 'operation', value: 'describe_query' }, + mode: 'advanced', + }, + { + id: 'maxQueryResults', + title: 'Max Rows', + type: 'short-input', + placeholder: '100 (AWS caps Lake query results at 1000 per page)', + condition: { field: 'operation', value: 'get_query_results' }, + mode: 'advanced', + }, + { + id: 'eventDataStoreMaxResults', + title: 'Max Event Data Stores', + type: 'short-input', + placeholder: '50 (AWS caps this at 1000 per page)', + condition: { field: 'operation', value: 'list_event_data_stores' }, + mode: 'advanced', + }, + { + id: 'eventDataStoreOwnerAccountId', + title: 'Event Data Store Owner Account ID', + type: 'short-input', + placeholder: '123456789012', + condition: { + field: 'operation', + value: ['start_query', 'describe_query', 'get_query_results', 'cancel_query'], + }, + mode: 'advanced', + }, + { + id: 'resourceIdList', + title: 'Resource ARNs', + type: 'long-input', + placeholder: 'Comma-separated trail, event data store, dashboard, or channel ARNs (up to 20)', + condition: { field: 'operation', value: 'list_tags' }, + required: { field: 'operation', value: 'list_tags' }, + }, + { + id: 'nextToken', + title: 'Pagination Token', + type: 'short-input', + placeholder: 'Token from a previous request', + condition: { field: 'operation', value: PAGINATED_OPERATIONS }, + mode: 'advanced', + }, + ], + tools: { + access: [ + 'cloudtrail_lookup_events', + 'cloudtrail_describe_trails', + 'cloudtrail_get_trail', + 'cloudtrail_get_trail_status', + 'cloudtrail_list_trails', + 'cloudtrail_get_event_selectors', + 'cloudtrail_get_insight_selectors', + 'cloudtrail_start_query', + 'cloudtrail_describe_query', + 'cloudtrail_get_query_results', + 'cloudtrail_cancel_query', + 'cloudtrail_list_event_data_stores', + 'cloudtrail_get_event_data_store', + 'cloudtrail_list_tags', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'lookup_events': + return 'cloudtrail_lookup_events' + case 'describe_trails': + return 'cloudtrail_describe_trails' + case 'get_trail': + return 'cloudtrail_get_trail' + case 'get_trail_status': + return 'cloudtrail_get_trail_status' + case 'list_trails': + return 'cloudtrail_list_trails' + case 'get_event_selectors': + return 'cloudtrail_get_event_selectors' + case 'get_insight_selectors': + return 'cloudtrail_get_insight_selectors' + case 'start_query': + return 'cloudtrail_start_query' + case 'describe_query': + return 'cloudtrail_describe_query' + case 'get_query_results': + return 'cloudtrail_get_query_results' + case 'cancel_query': + return 'cloudtrail_cancel_query' + case 'list_event_data_stores': + return 'cloudtrail_list_event_data_stores' + case 'get_event_data_store': + return 'cloudtrail_get_event_data_store' + case 'list_tags': + return 'cloudtrail_list_tags' + default: + throw new Error(`Invalid CloudTrail operation: ${params.operation}`) + } + }, + params: (params) => { + const { operation, ...rest } = params + + const awsRegion = rest.awsRegion + const awsAccessKeyId = rest.awsAccessKeyId + const awsSecretAccessKey = rest.awsSecretAccessKey + const credentials = { awsRegion, awsAccessKeyId, awsSecretAccessKey } + + switch (operation) { + case 'lookup_events': { + const maxResults = parseBoundedInt(rest.lookupMaxResults) + if (Boolean(rest.attributeKey) !== Boolean(rest.attributeValue)) { + throw new Error('Provide both a filter attribute and a filter value, or neither') + } + return { + ...credentials, + ...(rest.attributeKey && { + attributeKey: rest.attributeKey, + attributeValue: rest.attributeValue, + }), + ...(rest.startTime && { startTime: rest.startTime }), + ...(rest.endTime && { endTime: rest.endTime }), + ...(rest.eventCategory === 'insight' && { eventCategory: 'insight' }), + ...(maxResults !== undefined && { maxResults }), + ...(rest.nextToken && { nextToken: rest.nextToken }), + } + } + + case 'describe_trails': + return { + ...credentials, + ...(rest.trailNameList && { trailNameList: rest.trailNameList }), + ...(rest.includeShadowTrails !== undefined && + rest.includeShadowTrails !== '' && { + includeShadowTrails: String(rest.includeShadowTrails) !== 'false', + }), + } + + case 'get_trail': + case 'get_trail_status': + if (!rest.trailName) { + throw new Error('Trail name or ARN is required') + } + return { ...credentials, name: rest.trailName } + + case 'list_trails': + return { + ...credentials, + ...(rest.nextToken && { nextToken: rest.nextToken }), + } + + case 'get_event_selectors': + if (!rest.trailName) { + throw new Error('Trail name or ARN is required') + } + return { ...credentials, trailName: rest.trailName } + + case 'get_insight_selectors': + if (Boolean(rest.trailName) === Boolean(rest.eventDataStore)) { + throw new Error( + 'Specify exactly one of trail name or event data store for Insights selectors' + ) + } + return { + ...credentials, + ...(rest.trailName && { trailName: rest.trailName }), + ...(rest.eventDataStore && { eventDataStore: rest.eventDataStore }), + } + + case 'start_query': + if (Boolean(rest.queryStatement) === Boolean(rest.queryAlias)) { + throw new Error('Specify exactly one of Lake SQL query or query template alias') + } + return { + ...credentials, + ...(rest.queryStatement && { queryStatement: rest.queryStatement }), + ...(rest.queryAlias && { queryAlias: rest.queryAlias }), + ...(rest.queryParameters && { queryParameters: rest.queryParameters }), + ...(rest.deliveryS3Uri && { deliveryS3Uri: rest.deliveryS3Uri }), + ...(rest.eventDataStoreOwnerAccountId && { + eventDataStoreOwnerAccountId: rest.eventDataStoreOwnerAccountId, + }), + } + + case 'describe_query': + if (Boolean(rest.queryId) === Boolean(rest.queryAlias)) { + throw new Error('Specify exactly one of query ID or query template alias') + } + return { + ...credentials, + ...(rest.queryId && { queryId: rest.queryId }), + ...(rest.queryAlias && { queryAlias: rest.queryAlias }), + ...(rest.refreshId && { refreshId: rest.refreshId }), + ...(rest.eventDataStoreOwnerAccountId && { + eventDataStoreOwnerAccountId: rest.eventDataStoreOwnerAccountId, + }), + } + + case 'get_query_results': { + if (!rest.queryId) { + throw new Error('Query ID is required') + } + const maxQueryResults = parseBoundedInt(rest.maxQueryResults) + return { + ...credentials, + queryId: rest.queryId, + ...(maxQueryResults !== undefined && { maxQueryResults }), + ...(rest.nextToken && { nextToken: rest.nextToken }), + ...(rest.eventDataStoreOwnerAccountId && { + eventDataStoreOwnerAccountId: rest.eventDataStoreOwnerAccountId, + }), + } + } + + case 'cancel_query': + if (!rest.queryId) { + throw new Error('Query ID is required') + } + return { + ...credentials, + queryId: rest.queryId, + ...(rest.eventDataStoreOwnerAccountId && { + eventDataStoreOwnerAccountId: rest.eventDataStoreOwnerAccountId, + }), + } + + case 'list_event_data_stores': { + const maxResults = parseBoundedInt(rest.eventDataStoreMaxResults) + return { + ...credentials, + ...(maxResults !== undefined && { maxResults }), + ...(rest.nextToken && { nextToken: rest.nextToken }), + } + } + + case 'get_event_data_store': + if (!rest.eventDataStore) { + throw new Error('Event data store ARN or ID is required') + } + return { ...credentials, eventDataStore: rest.eventDataStore } + + case 'list_tags': + if (!rest.resourceIdList) { + throw new Error('At least one resource ARN is required') + } + return { + ...credentials, + resourceIdList: rest.resourceIdList, + ...(rest.nextToken && { nextToken: rest.nextToken }), + } + + default: + throw new Error(`Invalid CloudTrail operation: ${operation}`) + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'CloudTrail operation to perform' }, + awsRegion: { type: 'string', description: 'AWS region' }, + awsAccessKeyId: { type: 'string', description: 'AWS access key ID' }, + awsSecretAccessKey: { type: 'string', description: 'AWS secret access key' }, + attributeKey: { type: 'string', description: 'Lookup attribute to filter events on' }, + attributeValue: { type: 'string', description: 'Value the lookup attribute must equal' }, + startTime: { type: 'string', description: 'Start of the lookup window (ISO 8601)' }, + endTime: { type: 'string', description: 'End of the lookup window (ISO 8601)' }, + eventCategory: { type: 'string', description: 'Management or Insights event category' }, + lookupMaxResults: { type: 'number', description: 'Maximum events to look up (1-50)' }, + trailNameList: { type: 'string', description: 'Comma-separated trail names or ARNs' }, + includeShadowTrails: { type: 'string', description: 'Whether to include shadow trails' }, + trailName: { type: 'string', description: 'Trail name or trail ARN' }, + eventDataStore: { type: 'string', description: 'Event data store ARN or ID suffix' }, + queryStatement: { type: 'string', description: 'CloudTrail Lake SQL query' }, + queryAlias: { type: 'string', description: 'CloudTrail Lake query template alias' }, + queryParameters: { + type: 'string', + description: 'Comma-separated parameter values for a query template', + }, + deliveryS3Uri: { type: 'string', description: 'S3 URI for delivered query results' }, + queryId: { type: 'string', description: 'CloudTrail Lake query ID' }, + refreshId: { type: 'string', description: 'CloudTrail Lake dashboard refresh ID' }, + maxQueryResults: { type: 'number', description: 'Maximum Lake result rows per page (1-1000)' }, + eventDataStoreMaxResults: { + type: 'number', + description: 'Maximum event data stores per page (1-1000)', + }, + eventDataStoreOwnerAccountId: { + type: 'string', + description: 'Account ID of the event data store owner', + }, + resourceIdList: { + type: 'string', + description: 'Comma-separated CloudTrail resource ARNs (up to 20)', + }, + nextToken: { type: 'string', description: 'Pagination token' }, + }, + outputs: { + events: { + type: 'array', + description: + 'Matching CloudTrail events, most recent first, each with the parsed cloudTrailEvent record', + }, + nextToken: { type: 'string', description: 'Pagination token for the next page' }, + trails: { type: 'array', description: 'Trail configurations or trail summaries' }, + name: { type: 'string', description: 'Trail or event data store name' }, + s3BucketName: { type: 'string', description: 'S3 bucket that receives log files' }, + s3KeyPrefix: { type: 'string', description: 'S3 key prefix for delivered log files' }, + snsTopicName: { type: 'string', description: 'SNS topic notified on log delivery' }, + snsTopicArn: { type: 'string', description: 'ARN of the SNS topic notified on log delivery' }, + includeGlobalServiceEvents: { + type: 'boolean', + description: 'Whether the trail records global service events', + }, + isMultiRegionTrail: { + type: 'boolean', + description: 'Whether the trail records events in all Regions', + }, + homeRegion: { type: 'string', description: 'Region in which the trail was created' }, + trailArn: { type: 'string', description: 'ARN of the trail' }, + logFileValidationEnabled: { + type: 'boolean', + description: 'Whether log file integrity validation is enabled', + }, + cloudWatchLogsLogGroupArn: { + type: 'string', + description: 'CloudWatch Logs log group receiving events', + }, + cloudWatchLogsRoleArn: { + type: 'string', + description: 'Role CloudTrail assumes to write to CloudWatch Logs', + }, + kmsKeyId: { type: 'string', description: 'KMS key used for encryption' }, + hasCustomEventSelectors: { + type: 'boolean', + description: 'Whether the trail has custom event selectors', + }, + hasInsightSelectors: { + type: 'boolean', + description: 'Whether the trail has Insights event selectors', + }, + isOrganizationTrail: { + type: 'boolean', + description: 'Whether the trail is an organization trail', + }, + isLogging: { type: 'boolean', description: 'Whether the trail is currently logging' }, + latestDeliveryError: { type: 'string', description: 'Most recent S3 log delivery error' }, + latestDeliveryTime: { type: 'string', description: 'When log files were last delivered to S3' }, + latestNotificationError: { type: 'string', description: 'Most recent SNS notification error' }, + latestNotificationTime: { + type: 'string', + description: 'When the last SNS notification was sent', + }, + latestCloudWatchLogsDeliveryError: { + type: 'string', + description: 'Most recent CloudWatch Logs delivery error', + }, + latestCloudWatchLogsDeliveryTime: { + type: 'string', + description: 'When events were last delivered to CloudWatch Logs', + }, + latestDigestDeliveryError: { type: 'string', description: 'Most recent digest delivery error' }, + latestDigestDeliveryTime: { + type: 'string', + description: 'When a digest file was last delivered', + }, + startLoggingTime: { type: 'string', description: 'When logging was most recently started' }, + stopLoggingTime: { type: 'string', description: 'When logging was most recently stopped' }, + eventSelectors: { type: 'array', description: 'Basic event selectors configured on the trail' }, + advancedEventSelectors: { + type: 'array', + description: 'Advanced event selectors configured on the trail or event data store', + }, + insightSelectors: { type: 'array', description: 'Enabled CloudTrail Insights types' }, + eventDataStoreArn: { type: 'string', description: 'ARN of the event data store' }, + insightsDestination: { + type: 'string', + description: 'Destination event data store that logs Insights events', + }, + queryId: { type: 'string', description: 'CloudTrail Lake query ID' }, + queryString: { type: 'string', description: 'SQL body of the Lake query' }, + queryStatus: { + type: 'string', + description: 'QUEUED, RUNNING, FINISHED, FAILED, CANCELLED, or TIMED_OUT', + }, + errorMessage: { type: 'string', description: 'Error message returned if the query failed' }, + deliveryS3Uri: { type: 'string', description: 'S3 URI the query results were delivered to' }, + deliveryStatus: { type: 'string', description: 'Delivery status of the S3 query results' }, + prompt: { type: 'string', description: 'Prompt used to generate the query, if generated' }, + eventDataStoreOwnerAccountId: { + type: 'string', + description: 'Account ID of the event data store owner', + }, + eventsMatched: { type: 'number', description: 'Number of events that matched the query' }, + eventsScanned: { type: 'number', description: 'Number of events scanned by the query' }, + bytesScanned: { type: 'number', description: 'Bytes scanned by the query' }, + executionTimeInMillis: { type: 'number', description: 'Query run time in milliseconds' }, + creationTime: { type: 'string', description: 'When the query was created' }, + rows: { + type: 'array', + description: 'Lake query result rows, each flattened into a column-to-value object', + }, + resultsCount: { type: 'number', description: 'Number of result rows on this page' }, + totalResultsCount: { type: 'number', description: 'Total rows the query produced' }, + eventDataStores: { type: 'array', description: 'CloudTrail Lake event data stores' }, + status: { type: 'string', description: 'Status of the event data store' }, + multiRegionEnabled: { + type: 'boolean', + description: 'Whether the event data store collects events from all Regions', + }, + organizationEnabled: { + type: 'boolean', + description: 'Whether the event data store collects organization events', + }, + retentionPeriod: { type: 'number', description: 'Event data store retention period in days' }, + terminationProtectionEnabled: { + type: 'boolean', + description: 'Whether termination protection is enabled', + }, + createdTimestamp: { type: 'string', description: 'When the event data store was created' }, + updatedTimestamp: { type: 'string', description: 'When the event data store was last updated' }, + billingMode: { type: 'string', description: 'Event data store billing mode' }, + federationStatus: { type: 'string', description: 'Lake Formation federation status' }, + federationRoleArn: { type: 'string', description: 'Role used for Lake Formation federation' }, + partitionKeys: { type: 'array', description: 'Partition keys of the event data store' }, + resourceTags: { type: 'array', description: 'Tags on each requested CloudTrail resource' }, + }, +} + +export const CloudTrailBlockMeta = { + tags: ['cloud', 'monitoring', 'identity'], + url: 'https://aws.amazon.com/cloudtrail', + templates: [ + { + icon: CloudTrailIcon, + title: 'CloudTrail root login alerter', + prompt: + 'Create a scheduled workflow that looks up AWS CloudTrail ConsoleLogin events every 15 minutes, flags any sign-in by the root user or a login without MFA, and posts the actor, source IP, and time to a Slack security channel.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['security', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: CloudTrailIcon, + title: 'CloudTrail IAM change review', + prompt: + 'Build a daily workflow that looks up AWS CloudTrail events from iam.amazonaws.com, summarizes every policy attach, role creation, and access key change with the principal who made it, and writes the review to a compliance table.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['security', 'compliance'], + alsoIntegrations: ['iam'], + }, + { + icon: CloudTrailIcon, + title: 'CloudTrail trail health monitor', + prompt: + 'Create a scheduled workflow that lists every AWS CloudTrail trail, checks each trail status for logging stopped or recent delivery errors, and opens a PagerDuty incident when a trail stops recording.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'monitoring'], + alsoIntegrations: ['pagerduty'], + }, + { + icon: CloudTrailIcon, + title: 'CloudTrail access key forensics', + prompt: + 'Build a workflow that takes an AWS access key ID, looks up every CloudTrail event made with it in the last 90 days, groups the calls by service and source IP, and returns a timeline of what that credential did.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['security', 'analysis'], + alsoIntegrations: ['slack'], + }, + { + icon: CloudTrailIcon, + title: 'CloudTrail Lake audit agent', + prompt: + 'Build a Slack agent that turns natural-language audit questions into CloudTrail Lake SQL, starts the query, polls until it finishes, and returns the result rows with the SQL it ran for review.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['security', 'analysis'], + alsoIntegrations: ['slack'], + }, + { + icon: CloudTrailIcon, + title: 'CloudTrail data-event coverage audit', + prompt: + 'Create a weekly workflow that reads the event selectors of every AWS CloudTrail trail, reports which trails are missing management event logging or S3 data events, and writes the gaps to a compliance table.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['compliance', 'devops'], + alsoIntegrations: ['s3'], + }, + { + icon: CloudTrailIcon, + title: 'CloudTrail offboarding evidence pack', + prompt: + 'Build a workflow that, given a departing employee username, looks up all their AWS CloudTrail activity from the last 90 days, summarizes the resources they touched, and emails an evidence pack to the security team.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['security', 'compliance'], + alsoIntegrations: ['gmail'], + }, + { + icon: CloudTrailIcon, + title: 'CloudTrail Insights anomaly digest', + prompt: + 'Create a daily workflow that looks up AWS CloudTrail Insights events, correlates each API call-rate or error-rate anomaly with the principals active at that time, and posts a digest to Slack.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['monitoring', 'devops'], + alsoIntegrations: ['slack'], + }, + ], + skills: [ + { + name: 'investigate-aws-actor', + description: + 'Trace everything a specific AWS user, role, or access key did in the last 90 days using CloudTrail event history. Use for incident response and offboarding reviews.', + content: + '# Investigate AWS Actor\n\nBuild a timeline of what one principal did in AWS.\n\n## Steps\n1. Choose the lookup attribute that matches what you were given: Username for an IAM user or role session, AccessKeyId for a credential.\n2. Look up events for that value, setting the start and end time to the window under investigation. CloudTrail event history only covers the last 90 days.\n3. Page through with the returned pagination token until no token comes back. Look up events is limited to 50 events per page and two requests per second per Region, so pace the paging.\n4. Read the parsed cloudTrailEvent record on each event for the source IP, user agent, request parameters, and any error code.\n5. Group the calls by service and by source IP, and call out any write action or permission change.\n\n## Output\nA chronological timeline of the calls, plus a short summary naming the services touched, the source IPs used, and any failed authorization attempts.', + }, + { + name: 'audit-trail-coverage', + description: + 'Verify that CloudTrail trails exist, are logging, and are configured to capture the events an audit requires. Use for SOC 2 and ISO evidence gathering.', + content: + '# Audit Trail Coverage\n\nProve that AWS API activity is actually being recorded.\n\n## Steps\n1. List trails to enumerate every trail visible to the account, noting each home Region.\n2. Describe trails to read the full configuration, including whether each is multi-Region, an organization trail, and whether log file validation is enabled.\n3. Get trail status for each trail and flag any where logging is stopped or a recent delivery error is present.\n4. Get event selectors for each trail to confirm management events are recorded and check which data resources are covered.\n5. Note that trails outside the current Region must be addressed by ARN.\n\n## Output\nA per-trail coverage report: logging state, multi-Region and organization scope, log file validation, delivery errors, and any gap in management or data event coverage.', + }, + { + name: 'run-lake-query', + description: + 'Run a SQL query against a CloudTrail Lake event data store, wait for it to finish, and return the rows. Use for aggregate audit questions that span more than 90 days.', + content: + '# Run Lake Query\n\nAnswer an aggregate audit question with CloudTrail Lake.\n\n## Steps\n1. List event data stores, or get one by ARN, to find the store ID to query and confirm its retention period.\n2. Compose the SQL, naming the event data store ID in the FROM clause.\n3. Start the query to obtain a query ID.\n4. Poll describe query with that query ID until the status is FINISHED, FAILED, CANCELLED, or TIMED_OUT.\n5. On FINISHED, fetch the query results with the same query ID, paging with the returned token until no token comes back.\n6. Cancel the query if it is no longer needed while still RUNNING.\n\n## Output\nThe result rows, plus the query ID, events matched, events scanned, and run time. On failure, surface the error message and the SQL that produced it.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/embeddings.test.ts b/apps/sim/blocks/blocks/embeddings.test.ts index 370c0f21a73..fb341b78f73 100644 --- a/apps/sim/blocks/blocks/embeddings.test.ts +++ b/apps/sim/blocks/blocks/embeddings.test.ts @@ -351,4 +351,63 @@ describe('Embeddings block', () => { EmbeddingsBlock.tools.config?.params?.({ provider: 'openai', apiKey: 'k' }) ).toThrow('Input text is required') }) + + describe('Ollama', () => { + it('reads its models from the server rather than the catalog, with no default', () => { + const subBlock = subBlocksById('model').find( + (candidate) => conditionProvider(candidate) === 'ollama' + ) + expect(subBlock).toBeDefined() + expect(subBlock?.type).toBe('combobox') + expect(subBlock?.selectorKey).toBe('providers.ollamaEmbeddingModels') + /** Whatever the operator pulled; Sim cannot pre-select one it cannot enumerate. */ + expect(subBlock?.value).toBeUndefined() + expect(subBlock?.required).toBe(true) + }) + + it('offers neither task types nor dimensions, which the adapter cannot send', () => { + for (const id of ['taskType', 'dimensions']) { + expect( + subBlocksById(id).some((candidate) => conditionProvider(candidate) === 'ollama'), + `${id} must not be offered for ollama` + ).toBe(false) + } + }) + + it('hides the API key field, since Ollama authenticates with nothing', () => { + const condition = subBlocksById('apiKey')[0].condition as { + value: string[] + not?: boolean + } + expect(condition.not).toBe(true) + expect(condition.value).toContain('ollama') + expect(condition.value).toContain('openrouter') + }) + + it('forwards the bare model name and drops every credential-bearing field', () => { + expect( + EmbeddingsBlock.tools.config?.params?.({ + provider: 'ollama', + model: 'nomic-embed-text:latest', + input: 'hello', + apiKey: 'stale-key', + taskType: 'query', + dimensions: '768', + }) + ).toEqual({ + input: 'hello', + model: 'nomic-embed-text:latest', + taskType: undefined, + dimensions: undefined, + }) + }) + + it('routes to its own tool and requires a model', () => { + expect(TOOL_ID_BY_PROVIDER.ollama).toBe('embeddings_ollama') + expect(EmbeddingsBlock.tools.access).toContain('embeddings_ollama') + expect(() => + EmbeddingsBlock.tools.config?.params?.({ provider: 'ollama', input: 'hello' }) + ).toThrow('An Ollama embedding model is required') + }) + }) }) diff --git a/apps/sim/blocks/blocks/embeddings.ts b/apps/sim/blocks/blocks/embeddings.ts index 8ab60bbe859..c058614e434 100644 --- a/apps/sim/blocks/blocks/embeddings.ts +++ b/apps/sim/blocks/blocks/embeddings.ts @@ -20,7 +20,11 @@ import type { BlockConfig, BlockMeta, SubBlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { EmbeddingsResponse } from '@/tools/embeddings/types' -export const EMBEDDING_BLOCK_PROVIDERS = [...EMBEDDING_CATALOG_PROVIDERS, 'openrouter'] as const +export const EMBEDDING_BLOCK_PROVIDERS = [ + ...EMBEDDING_CATALOG_PROVIDERS, + 'openrouter', + 'ollama', +] as const type EmbeddingBlockProvider = (typeof EMBEDDING_BLOCK_PROVIDERS)[number] @@ -30,6 +34,7 @@ const TOOL_ID_BY_PROVIDER: Record = { gemini: 'embeddings_gemini', cohere: 'embeddings_cohere', mistral: 'embeddings_mistral', + ollama: 'embeddings_ollama', } const PROVIDER_LABELS: Record = { @@ -38,8 +43,12 @@ const PROVIDER_LABELS: Record = { gemini: 'Google Gemini', cohere: 'Cohere', mistral: 'Mistral', + ollama: 'Ollama', } +/** Providers whose models are the deployment's to install, not Sim's to catalogue. */ +const KEYLESS_PROVIDERS = ['ollama'] as const satisfies readonly EmbeddingBlockProvider[] + const TASK_TYPE_LABELS: Record = { document: 'Document', query: 'Query', @@ -78,6 +87,32 @@ MODEL_SUB_BLOCKS.push({ dependsOn: ['provider'], }) +/** + * Ollama's catalog is whatever the operator pulled onto their own server, so the + * list is read from it at open time rather than declared here, and there is no + * default to pre-select. Each option carries the width the model emits, because + * that is the one thing a user has to match when the same base is also indexed + * by a knowledge base. + */ +MODEL_SUB_BLOCKS.push({ + id: 'model', + title: 'Model', + type: 'combobox', + selectorKey: 'providers.ollamaEmbeddingModels', + placeholder: 'Select a model on your Ollama server', + required: true, + /** + * Suppresses the combobox's auto-select-first-option behaviour. Without it, + * opening the block persists whichever model the server happens to list + * first — and since each one emits a different width, that is a silent wrong + * answer rather than a harmless default. `required` still stands: the user + * must choose, they just are not chosen for. + */ + emptyIsValid: true, + condition: { field: 'provider', value: 'ollama' }, + dependsOn: ['provider'], +}) + /** * Task-type and dimension dropdowns, which are per-model rather than * per-provider: the `condition` names both, and a model contributes a dropdown @@ -130,7 +165,7 @@ export const EmbeddingsBlock: BlockConfig = { description: 'Generate embeddings', authMode: AuthMode.ApiKey, longDescription: - 'Turn text into embedding vectors for semantic search, clustering, and similarity. Supports OpenAI, OpenRouter, Google Gemini, Cohere, and Mistral embedding models.', + 'Turn text into embedding vectors for semantic search, clustering, and similarity. Supports OpenAI, OpenRouter, Google Gemini, Cohere, and Mistral embedding models, plus embedding models on a self-hosted Ollama.', category: 'tools', integrationType: IntegrationType.AI, docsLink: 'https://docs.sim.ai/integrations/embeddings', @@ -181,7 +216,7 @@ export const EmbeddingsBlock: BlockConfig = { placeholder: 'Enter your provider API key', password: true, required: true, - condition: { field: 'provider', value: 'openrouter', not: true }, + condition: { field: 'provider', value: [...KEYLESS_PROVIDERS, 'openrouter'], not: true }, connectionDroppable: false, hideWhenHosted: true, }, @@ -203,6 +238,7 @@ export const EmbeddingsBlock: BlockConfig = { 'embeddings_gemini', 'embeddings_cohere', 'embeddings_mistral', + 'embeddings_ollama', ], config: { /** @@ -234,6 +270,33 @@ export const EmbeddingsBlock: BlockConfig = { throw new Error('Input text is required') } + /** + * Ollama takes no credential and offers no task or dimension + * conditioning, so a value stored under a previous provider is cleared + * rather than forwarded. The model is passed as the bare name the + * server lists; the routing prefix is added server-side. + */ + if (provider === 'ollama') { + const model = typeof params.model === 'string' ? params.model.trim() : '' + if (!model) { + throw new Error('An Ollama embedding model is required') + } + return { + input: params.input, + model, + /** + * Explicitly cleared, not omitted: the executor merges this over the + * saved inputs, so an omitted key leaves the previous provider's + * credential in place and serializes it into a request that has no + * use for one. + */ + apiKey: undefined, + openRouterApiKey: undefined, + taskType: undefined, + dimensions: undefined, + } + } + if (provider === 'openrouter') { if (typeof params.openRouterApiKey !== 'string' || !params.openRouterApiKey.trim()) { throw new Error('OpenRouter API key is required') diff --git a/apps/sim/blocks/blocks/iam.test.ts b/apps/sim/blocks/blocks/iam.test.ts new file mode 100644 index 00000000000..3f58ce9bd84 --- /dev/null +++ b/apps/sim/blocks/blocks/iam.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { IAMBlock } from '@/blocks/blocks/iam' + +const SHAPE_ERROR = + 'Condition Context Keys must be a JSON array of { contextKeyName, contextKeyValues, contextKeyType }' + +const CONTEXT_ENTRY = { + contextKeyName: 'aws:SourceIp', + contextKeyValues: ['203.0.113.10'], + contextKeyType: 'ip', +} + +function simulateParams(extra: Record) { + return IAMBlock.tools.config!.params!({ + operation: 'simulate_principal_policy', + region: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'secret', + policySourceArn: 'arn:aws:iam::000000000000:role/example', + actionNames: 's3:GetObject', + ...extra, + }) +} + +describe('IAMBlock contextEntries parsing', () => { + it('passes a JSON array string through as parsed context entries', () => { + const result = simulateParams({ contextEntries: JSON.stringify([CONTEXT_ENTRY]) }) + expect(result.contextEntries).toEqual([CONTEXT_ENTRY]) + }) + + it('passes an already-parsed array through unchanged', () => { + const result = simulateParams({ contextEntries: [CONTEXT_ENTRY] }) + expect(result.contextEntries).toEqual([CONTEXT_ENTRY]) + }) + + it('rejects a JSON object rather than silently simulating without the context keys', () => { + expect(() => simulateParams({ contextEntries: JSON.stringify(CONTEXT_ENTRY) })).toThrow( + SHAPE_ERROR + ) + }) + + it('rejects a JSON scalar rather than silently dropping it', () => { + expect(() => simulateParams({ contextEntries: '"aws:SourceIp"' })).toThrow(SHAPE_ERROR) + expect(() => simulateParams({ contextEntries: '42' })).toThrow(SHAPE_ERROR) + }) + + it('rejects a non-array object supplied directly', () => { + expect(() => simulateParams({ contextEntries: CONTEXT_ENTRY })).toThrow(SHAPE_ERROR) + }) + + it('rejects malformed JSON with the same shape message', () => { + expect(() => simulateParams({ contextEntries: '{not json' })).toThrow(SHAPE_ERROR) + }) + + it('omits contextEntries when the field is blank', () => { + const result = simulateParams({ contextEntries: '' }) + expect(result.contextEntries).toBeUndefined() + }) + + it('omits contextEntries for an empty JSON array without throwing', () => { + const result = simulateParams({ contextEntries: '[]' }) + expect(result.contextEntries).toBeUndefined() + }) +}) + +describe('IAMBlock contextEntries wand config', () => { + it('generates a JSON array, matching the prompt and the tool contract', () => { + const subBlock = IAMBlock.subBlocks.find((block) => block.id === 'contextEntries') + expect(subBlock?.wandConfig?.generationType).toBe('json-array') + }) +}) diff --git a/apps/sim/blocks/blocks/iam.ts b/apps/sim/blocks/blocks/iam.ts index 4cc7fa5d16d..2cf3dbfedd7 100644 --- a/apps/sim/blocks/blocks/iam.ts +++ b/apps/sim/blocks/blocks/iam.ts @@ -3,6 +3,16 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { IAMBaseResponse } from '@/tools/iam/types' +/** + * Raised when `contextEntries` is not a JSON array of context entries. + * + * A simulation that quietly drops the caller's condition context keys returns a + * *wrong* permission answer — `aws:SourceIp` never applies, so a policy that should + * have denied reports `allowed`. Rejecting the input is the only safe outcome. + */ +const CONTEXT_ENTRIES_SHAPE_ERROR = + 'Condition Context Keys must be a JSON array of { contextKeyName, contextKeyValues, contextKeyType }' + export const IAMBlock: BlockConfig = { type: 'iam', name: 'AWS IAM', @@ -57,6 +67,13 @@ export const IAMBlock: BlockConfig = { { text: 'Detach policy', field: 'policyArn', core: true }, { text: 'from role', field: 'roleName', core: true }, ], + get_policy: [{ text: 'Fetch policy', field: 'policyArn', core: true }], + list_access_keys: ['List access keys', { text: ', for user', field: 'userName' }], + update_access_key: [ + { text: 'Set access key', field: 'accessKeyIdToUpdate', core: true }, + { text: 'to', field: 'accessKeyStatus', core: true }, + { text: 'for user', field: 'userName' }, + ], list_policies: [ 'List managed policies', { text: ', under path', field: 'pathPrefix' }, @@ -117,8 +134,11 @@ export const IAMBlock: BlockConfig = { { label: 'Attach Role Policy', id: 'attach_role_policy' }, { label: 'Detach Role Policy', id: 'detach_role_policy' }, { label: 'List Policies', id: 'list_policies' }, + { label: 'Get Policy', id: 'get_policy' }, { label: 'Create Access Key', id: 'create_access_key' }, { label: 'Delete Access Key', id: 'delete_access_key' }, + { label: 'List Access Keys', id: 'list_access_keys' }, + { label: 'Update Access Key', id: 'update_access_key' }, { label: 'List Groups', id: 'list_groups' }, { label: 'Add User to Group', id: 'add_user_to_group' }, { label: 'Remove User from Group', id: 'remove_user_from_group' }, @@ -166,6 +186,8 @@ export const IAMBlock: BlockConfig = { 'detach_user_policy', 'create_access_key', 'delete_access_key', + 'list_access_keys', + 'update_access_key', 'add_user_to_group', 'remove_user_from_group', 'list_attached_user_policies', @@ -174,7 +196,6 @@ export const IAMBlock: BlockConfig = { required: { field: 'operation', value: [ - 'get_user', 'create_user', 'delete_user', 'attach_user_policy', @@ -225,6 +246,7 @@ export const IAMBlock: BlockConfig = { 'detach_user_policy', 'attach_role_policy', 'detach_role_policy', + 'get_policy', ], }, required: { @@ -234,6 +256,7 @@ export const IAMBlock: BlockConfig = { 'detach_user_policy', 'attach_role_policy', 'detach_role_policy', + 'get_policy', ], }, }, @@ -275,6 +298,27 @@ export const IAMBlock: BlockConfig = { condition: { field: 'operation', value: 'delete_access_key' }, required: { field: 'operation', value: 'delete_access_key' }, }, + { + id: 'accessKeyIdToUpdate', + title: 'Access Key ID to Update', + canvasNoun: 'an access key ID', + type: 'short-input', + placeholder: 'AKIA...', + condition: { field: 'operation', value: 'update_access_key' }, + required: { field: 'operation', value: 'update_access_key' }, + }, + { + id: 'accessKeyStatus', + title: 'Access Key Status', + type: 'dropdown', + options: [ + { label: 'Active', id: 'Active' }, + { label: 'Inactive', id: 'Inactive' }, + ], + value: () => 'Inactive', + condition: { field: 'operation', value: 'update_access_key' }, + required: { field: 'operation', value: 'update_access_key' }, + }, { id: 'path', title: 'Path', @@ -360,6 +404,23 @@ export const IAMBlock: BlockConfig = { required: false, mode: 'advanced', }, + { + id: 'contextEntries', + title: 'Condition Context Keys (JSON)', + type: 'code', + placeholder: + '[{"contextKeyName":"aws:SourceIp","contextKeyValues":["203.0.113.10"],"contextKeyType":"ip"}]', + condition: { field: 'operation', value: 'simulate_principal_policy' }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON array of AWS IAM simulation context entries. Each element must have contextKeyName (a full condition context key such as aws:SourceIp), contextKeyValues (an array of strings), and contextKeyType (one of string, stringList, numeric, numericList, boolean, booleanList, ip, ipList, binary, binaryList, date, dateList). Return ONLY the JSON array - no explanations, no extra text.', + generationType: 'json-array', + placeholder: 'Describe the request conditions to simulate, e.g. "from IP 203.0.113.10"', + }, + }, { id: 'pathPrefix', title: 'Path Prefix', @@ -393,6 +454,7 @@ export const IAMBlock: BlockConfig = { 'list_groups', 'list_attached_role_policies', 'list_attached_user_policies', + 'list_access_keys', 'simulate_principal_policy', ], }, @@ -413,6 +475,7 @@ export const IAMBlock: BlockConfig = { 'list_groups', 'list_attached_role_policies', 'list_attached_user_policies', + 'list_access_keys', 'simulate_principal_policy', ], }, @@ -435,8 +498,11 @@ export const IAMBlock: BlockConfig = { 'iam_attach_role_policy', 'iam_detach_role_policy', 'iam_list_policies', + 'iam_get_policy', 'iam_create_access_key', 'iam_delete_access_key', + 'iam_list_access_keys', + 'iam_update_access_key', 'iam_list_groups', 'iam_add_user_to_group', 'iam_remove_user_from_group', @@ -473,10 +539,16 @@ export const IAMBlock: BlockConfig = { return 'iam_detach_role_policy' case 'list_policies': return 'iam_list_policies' + case 'get_policy': + return 'iam_get_policy' case 'create_access_key': return 'iam_create_access_key' case 'delete_access_key': return 'iam_delete_access_key' + case 'list_access_keys': + return 'iam_list_access_keys' + case 'update_access_key': + return 'iam_update_access_key' case 'list_groups': return 'iam_list_groups' case 'add_user_to_group': @@ -494,8 +566,15 @@ export const IAMBlock: BlockConfig = { } }, params: (params) => { - const { operation, maxItems, maxSessionDuration, onlyAttached, resourceArns, ...rest } = - params + const { + operation, + maxItems, + maxSessionDuration, + onlyAttached, + resourceArns, + contextEntries, + ...rest + } = params const connectionConfig = { region: rest.region, @@ -517,6 +596,8 @@ export const IAMBlock: BlockConfig = { if (rest.marker) result.marker = rest.marker break case 'get_user': + if (rest.userName) result.userName = rest.userName + break case 'delete_user': result.userName = rest.userName break @@ -558,6 +639,9 @@ export const IAMBlock: BlockConfig = { } if (rest.marker) result.marker = rest.marker break + case 'get_policy': + result.policyArn = rest.policyArn + break case 'create_access_key': if (rest.userName) result.userName = rest.userName break @@ -565,6 +649,19 @@ export const IAMBlock: BlockConfig = { result.accessKeyIdToDelete = rest.accessKeyIdToDelete if (rest.userName) result.userName = rest.userName break + case 'list_access_keys': + if (rest.userName) result.userName = rest.userName + if (maxItems) { + const parsed = Number.parseInt(String(maxItems), 10) + if (!Number.isNaN(parsed)) result.maxItems = parsed + } + if (rest.marker) result.marker = rest.marker + break + case 'update_access_key': + result.accessKeyIdToUpdate = rest.accessKeyIdToUpdate + result.status = rest.accessKeyStatus + if (rest.userName) result.userName = rest.userName + break case 'add_user_to_group': case 'remove_user_from_group': result.userName = rest.userName @@ -592,6 +689,20 @@ export const IAMBlock: BlockConfig = { result.policySourceArn = rest.policySourceArn result.actionNames = rest.actionNames if (resourceArns) result.resourceArns = resourceArns + if (contextEntries) { + let parsed: unknown = contextEntries + if (typeof contextEntries === 'string') { + try { + parsed = JSON.parse(contextEntries) + } catch { + throw new Error(CONTEXT_ENTRIES_SHAPE_ERROR) + } + } + if (!Array.isArray(parsed)) { + throw new Error(CONTEXT_ENTRIES_SHAPE_ERROR) + } + if (parsed.length > 0) result.contextEntries = parsed + } if (maxItems) { const parsed = Number.parseInt(String(maxItems), 10) if (!Number.isNaN(parsed)) result.maxResults = parsed @@ -615,6 +726,8 @@ export const IAMBlock: BlockConfig = { assumeRolePolicyDocument: { type: 'string', description: 'Trust policy JSON' }, groupName: { type: 'string', description: 'IAM group name' }, accessKeyIdToDelete: { type: 'string', description: 'Access key ID to delete' }, + accessKeyIdToUpdate: { type: 'string', description: 'Access key ID to activate or deactivate' }, + accessKeyStatus: { type: 'string', description: 'Access key status to set (Active, Inactive)' }, path: { type: 'string', description: 'Resource path' }, description: { type: 'string', description: 'Role description' }, maxSessionDuration: { type: 'number', description: 'Max session duration in seconds' }, @@ -629,6 +742,11 @@ export const IAMBlock: BlockConfig = { type: 'string', description: 'Comma-separated resource ARNs to simulate against', }, + contextEntries: { + type: 'json', + description: + 'Condition context keys supplied to the simulation, as a JSON array of { contextKeyName, contextKeyValues, contextKeyType }', + }, }, outputs: { message: { @@ -719,7 +837,9 @@ export const IAMBlock: BlockConfig = { }, secretAccessKey: { type: 'string', - description: 'Secret access key (only shown once)', + description: + 'Secret access key, returned only when the key is created. Hidden from logs and the trace; reference it downstream rather than printing it.', + hiddenFromDisplay: true, }, status: { type: 'string', @@ -741,10 +861,43 @@ export const IAMBlock: BlockConfig = { type: 'json', description: 'List of attached managed policies with policyName and policyArn', }, + accessKeys: { + type: 'json', + description: + "An IAM user's access key metadata (accessKeyId, userName, status, createDate). The secret is never returned by this operation.", + }, + policyName: { + type: 'string', + description: 'Policy name', + }, + policyId: { + type: 'string', + description: 'Policy ID', + }, + attachmentCount: { + type: 'number', + description: 'Number of entities the policy is attached to', + }, + isAttachable: { + type: 'boolean', + description: 'Whether the policy can be attached to an entity', + }, + updateDate: { + type: 'string', + description: 'Date the resource was last updated', + }, + defaultVersionId: { + type: 'string', + description: 'Identifier of the default policy version', + }, + permissionsBoundaryUsageCount: { + type: 'number', + description: 'Number of entities using the policy as a permissions boundary', + }, evaluationResults: { type: 'json', description: - 'Policy simulation results per action: evalActionName, evalResourceName, evalDecision (allowed/explicitDeny/implicitDeny), matchedStatements (sourcePolicyId, sourcePolicyType), missingContextValues', + 'One result per simulated action. evalDecision is the AGGREGATE, most-restrictive decision across every resource ARN (any explicitDeny makes the whole result explicitDeny), and evalResourceName is the resource-type ARN template AWS echoes back, not a customer ARN. Read the verdict for an individual ARN from resourceSpecificResults[]: evalResourceName, evalResourceDecision, matchedStatements, missingContextValues, permissionsBoundaryAllowed.', }, }, } @@ -829,14 +982,14 @@ export const IAMBlockMeta = { description: 'List IAM users, roles, and their attached policies to produce an access audit. Use for security reviews and least-privilege checks.', content: - '# Audit IAM Permissions\n\nReport who and what has access in IAM.\n\n## Steps\n1. List users and roles to establish the inventory.\n2. For each principal of interest, list attached user or role policies.\n3. Optionally simulate principal policy to confirm whether a principal can perform sensitive actions.\n4. Flag overly broad policies, unused principals, or access keys that should be rotated.\n\n## Output\nAn audit summary: principals and their attached policies, with risky or excessive grants called out. Do not expose secret values.', + '# Audit IAM Permissions\n\nReport who and what has access in IAM.\n\n## Steps\n1. List users and roles to establish the inventory.\n2. For each principal of interest, list attached user or role policies. Get a policy by ARN when you need its description to judge intent — list policies never returns one.\n3. List each user’s access keys to find keys that are stale or still Active but unused.\n4. Optionally simulate principal policy to confirm whether a principal can perform sensitive actions, reading the per-resource verdict from resourceSpecificResults.\n5. Flag overly broad policies, unused principals, or access keys that should be rotated.\n\n## Output\nAn audit summary: principals, their attached policies, and their access key inventory, with risky or excessive grants called out. Do not expose secret values.', }, { name: 'check-effective-permissions', description: 'Use IAM policy simulation to verify whether a user or role can perform specific actions on resources. Use for troubleshooting access and validating changes.', content: - '# Check Effective Permissions\n\nDetermine whether a principal is actually allowed to do something.\n\n## Steps\n1. Identify the principal (user or role) and the actions and resource ARNs to test.\n2. Run simulate principal policy for those actions against the resources.\n3. Read the allowed or denied decision for each action, noting which statement governs it.\n4. If denied unexpectedly, inspect the attached policies to explain why.\n\n## Output\nA per-action allow/deny verdict with the governing policy, and a plain-language explanation of any denial.', + '# Check Effective Permissions\n\nDetermine whether a principal is actually allowed to do something.\n\n## Steps\n1. Identify the principal (user or role) and the actions and resource ARNs to test.\n2. Run simulate principal policy for those actions against the resources. If any policy is gated by a condition, supply the condition context keys so it does not simulate as denied for missing context.\n3. Read the per-resource verdict from resourceSpecificResults — evalResourceName plus evalResourceDecision. The top-level evalDecision is the aggregate across every ARN you passed, so one explicitly denied bucket makes the whole action read as explicitDeny; do not report that as a denial on the other resources. The top-level evalResourceName is a resource-type ARN template, not one of your ARNs.\n4. If a resource is denied unexpectedly, read its matchedStatements and missingContextValues, then inspect the attached policies to explain why.\n\n## Output\nA verdict per action AND per resource ARN, with the governing policy for each, and a plain-language explanation of any denial.', }, { name: 'provision-iam-principal', @@ -850,7 +1003,7 @@ export const IAMBlockMeta = { description: 'Create a fresh IAM access key for a user and delete the old one to complete a safe rotation. Use for scheduled key rotation and remediating aged keys.', content: - '# Rotate Access Keys\n\nReplace a user’s access key following the two-step rotation pattern.\n\n## Steps\n1. Create a new access key for the target user so two keys exist briefly.\n2. Hand the new key to its consumer securely and let dependents switch over and verify they still work.\n3. Once the new key is confirmed in use, delete the old access key by its ID.\n4. Confirm only the intended key remains for the user.\n\n## Output\nReport the user, that a new key was issued, and the old key ID that was deleted. Never print the secret access key value — reference keys only by their access key ID.', + '# Rotate Access Keys\n\nReplace a user’s access key following the two-step rotation pattern.\n\n## Steps\n1. List the user’s access keys to see which keys exist, their status, and their age.\n2. Create a new access key for the target user so two keys exist briefly.\n3. Hand the new key to its consumer securely and let dependents switch over and verify they still work.\n4. Deactivate the old key by updating its status to Inactive, and leave it that way long enough to prove nothing still depends on it. Reactivate it if something breaks.\n5. Once the new key is confirmed in use, delete the old access key by its ID.\n6. List the access keys again to confirm only the intended key remains for the user.\n\n## Output\nReport the user, that a new key was issued, and the old key ID that was deactivated and then deleted. Never print the secret access key value — reference keys only by their access key ID.', }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/identity_center.ts b/apps/sim/blocks/blocks/identity_center.ts index 5541891e6ff..0e02644cfe5 100644 --- a/apps/sim/blocks/blocks/identity_center.ts +++ b/apps/sim/blocks/blocks/identity_center.ts @@ -19,7 +19,7 @@ export const IdentityCenterBlock: BlockConfig = { sentences: { byOperation: { list_instances: ['List all instances', { text: 'in', field: 'region' }], - list_accounts: ['List organization accounts', { text: 'in', field: 'region' }], + list_accounts: ['List every account in the organization'], describe_account: [{ text: 'Read details of account', field: 'accountId', core: true }], list_permission_sets: ['List permission sets', { text: 'in', field: 'region' }], get_user: [{ text: 'Look up the user with email', field: 'email', core: true }], @@ -41,7 +41,16 @@ export const IdentityCenterBlock: BlockConfig = { ], list_account_assignments: [ 'List account assignments', - { text: 'for', field: 'principalId' }, + { text: 'for principal', field: 'principalId' }, + ], + list_assignments_for_account: [ + { text: 'List assignments on account', field: 'accountId', core: true }, + { text: 'for permission set', field: 'permissionSetArn', core: true }, + ], + describe_user: [{ text: 'Look up the user with ID', field: 'userId', core: true }], + describe_group: [{ text: 'Look up the group with ID', field: 'groupId', core: true }], + list_group_memberships: [ + { text: 'List the members of group', field: 'groupId', core: true }, ], }, }, @@ -58,13 +67,17 @@ export const IdentityCenterBlock: BlockConfig = { { label: 'Describe Account', id: 'describe_account' }, { label: 'List Permission Sets', id: 'list_permission_sets' }, { label: 'Get User', id: 'get_user' }, + { label: 'Describe User', id: 'describe_user' }, { label: 'Get Group', id: 'get_group' }, + { label: 'Describe Group', id: 'describe_group' }, { label: 'List Groups', id: 'list_groups' }, + { label: 'List Group Memberships', id: 'list_group_memberships' }, { label: 'Create Account Assignment', id: 'create_account_assignment' }, { label: 'Delete Account Assignment', id: 'delete_account_assignment' }, { label: 'Check Assignment Status', id: 'check_assignment_status' }, { label: 'Check Assignment Deletion Status', id: 'check_assignment_deletion_status' }, - { label: 'List Account Assignments', id: 'list_account_assignments' }, + { label: 'List Account Assignments For Principal', id: 'list_account_assignments' }, + { label: 'List Assignments For Account', id: 'list_assignments_for_account' }, ], value: () => 'list_instances', }, @@ -104,7 +117,10 @@ export const IdentityCenterBlock: BlockConfig = { 'get_user', 'get_group', 'describe_account', + 'describe_user', + 'describe_group', 'list_groups', + 'list_group_memberships', ], not: true, }, @@ -116,7 +132,10 @@ export const IdentityCenterBlock: BlockConfig = { 'get_user', 'get_group', 'describe_account', + 'describe_user', + 'describe_group', 'list_groups', + 'list_group_memberships', ], not: true, }, @@ -126,8 +145,28 @@ export const IdentityCenterBlock: BlockConfig = { title: 'Identity Store ID', type: 'short-input', placeholder: 'd-1234567890', - condition: { field: 'operation', value: ['get_user', 'get_group', 'list_groups'] }, - required: { field: 'operation', value: ['get_user', 'get_group', 'list_groups'] }, + condition: { + field: 'operation', + value: [ + 'get_user', + 'get_group', + 'list_groups', + 'describe_user', + 'describe_group', + 'list_group_memberships', + ], + }, + required: { + field: 'operation', + value: [ + 'get_user', + 'get_group', + 'list_groups', + 'describe_user', + 'describe_group', + 'list_group_memberships', + ], + }, }, { id: 'email', @@ -145,6 +184,22 @@ export const IdentityCenterBlock: BlockConfig = { condition: { field: 'operation', value: 'get_group' }, required: { field: 'operation', value: 'get_group' }, }, + { + id: 'userId', + title: 'User ID', + type: 'short-input', + placeholder: 'Identity Store user ID', + condition: { field: 'operation', value: 'describe_user' }, + required: { field: 'operation', value: 'describe_user' }, + }, + { + id: 'groupId', + title: 'Group ID', + type: 'short-input', + placeholder: 'Identity Store group ID', + condition: { field: 'operation', value: ['describe_group', 'list_group_memberships'] }, + required: { field: 'operation', value: ['describe_group', 'list_group_memberships'] }, + }, { id: 'accountId', title: 'AWS Account ID', @@ -152,11 +207,21 @@ export const IdentityCenterBlock: BlockConfig = { placeholder: '123456789012', condition: { field: 'operation', - value: ['create_account_assignment', 'delete_account_assignment', 'describe_account'], + value: [ + 'create_account_assignment', + 'delete_account_assignment', + 'describe_account', + 'list_assignments_for_account', + ], }, required: { field: 'operation', - value: ['create_account_assignment', 'delete_account_assignment', 'describe_account'], + value: [ + 'create_account_assignment', + 'delete_account_assignment', + 'describe_account', + 'list_assignments_for_account', + ], }, }, { @@ -166,11 +231,19 @@ export const IdentityCenterBlock: BlockConfig = { placeholder: 'arn:aws:sso:::permissionSet/ssoins-.../ps-...', condition: { field: 'operation', - value: ['create_account_assignment', 'delete_account_assignment'], + value: [ + 'create_account_assignment', + 'delete_account_assignment', + 'list_assignments_for_account', + ], }, required: { field: 'operation', - value: ['create_account_assignment', 'delete_account_assignment'], + value: [ + 'create_account_assignment', + 'delete_account_assignment', + 'list_assignments_for_account', + ], }, }, { @@ -239,7 +312,7 @@ export const IdentityCenterBlock: BlockConfig = { id: 'maxResults', title: 'Max Results', type: 'short-input', - placeholder: '20', + placeholder: '1-100 (List Accounts allows at most 20)', condition: { field: 'operation', value: [ @@ -247,7 +320,9 @@ export const IdentityCenterBlock: BlockConfig = { 'list_accounts', 'list_permission_sets', 'list_account_assignments', + 'list_assignments_for_account', 'list_groups', + 'list_group_memberships', ], }, required: false, @@ -265,7 +340,9 @@ export const IdentityCenterBlock: BlockConfig = { 'list_accounts', 'list_permission_sets', 'list_account_assignments', + 'list_assignments_for_account', 'list_groups', + 'list_group_memberships', ], }, required: false, @@ -279,13 +356,17 @@ export const IdentityCenterBlock: BlockConfig = { 'identity_center_describe_account', 'identity_center_list_permission_sets', 'identity_center_get_user', + 'identity_center_describe_user', 'identity_center_get_group', + 'identity_center_describe_group', 'identity_center_list_groups', + 'identity_center_list_group_memberships', 'identity_center_create_account_assignment', 'identity_center_delete_account_assignment', 'identity_center_check_assignment_status', 'identity_center_check_assignment_deletion_status', 'identity_center_list_account_assignments', + 'identity_center_list_assignments_for_account', ], config: { tool: (params) => { @@ -300,10 +381,16 @@ export const IdentityCenterBlock: BlockConfig = { return 'identity_center_list_permission_sets' case 'get_user': return 'identity_center_get_user' + case 'describe_user': + return 'identity_center_describe_user' case 'get_group': return 'identity_center_get_group' + case 'describe_group': + return 'identity_center_describe_group' case 'list_groups': return 'identity_center_list_groups' + case 'list_group_memberships': + return 'identity_center_list_group_memberships' case 'create_account_assignment': return 'identity_center_create_account_assignment' case 'delete_account_assignment': @@ -314,6 +401,8 @@ export const IdentityCenterBlock: BlockConfig = { return 'identity_center_check_assignment_deletion_status' case 'list_account_assignments': return 'identity_center_list_account_assignments' + case 'list_assignments_for_account': + return 'identity_center_list_assignments_for_account' default: throw new Error(`Invalid Identity Center operation: ${params.operation}`) } @@ -359,10 +448,27 @@ export const IdentityCenterBlock: BlockConfig = { result.identityStoreId = rest.identityStoreId result.email = rest.email break + case 'describe_user': + result.identityStoreId = rest.identityStoreId + result.userId = rest.userId + break case 'get_group': result.identityStoreId = rest.identityStoreId result.displayName = rest.displayName break + case 'describe_group': + result.identityStoreId = rest.identityStoreId + result.groupId = rest.groupId + break + case 'list_group_memberships': + result.identityStoreId = rest.identityStoreId + result.groupId = rest.groupId + if (maxResults) { + const parsed = Number.parseInt(String(maxResults), 10) + if (!Number.isNaN(parsed)) result.maxResults = parsed + } + if (rest.nextToken) result.nextToken = rest.nextToken + break case 'list_groups': result.identityStoreId = rest.identityStoreId if (maxResults) { @@ -394,6 +500,16 @@ export const IdentityCenterBlock: BlockConfig = { } if (rest.nextToken) result.nextToken = rest.nextToken break + case 'list_assignments_for_account': + result.instanceArn = rest.instanceArn + result.accountId = rest.accountId + result.permissionSetArn = rest.permissionSetArn + if (maxResults) { + const parsed = Number.parseInt(String(maxResults), 10) + if (!Number.isNaN(parsed)) result.maxResults = parsed + } + if (rest.nextToken) result.nextToken = rest.nextToken + break } return result @@ -413,8 +529,13 @@ export const IdentityCenterBlock: BlockConfig = { permissionSetArn: { type: 'string', description: 'Permission set ARN' }, principalType: { type: 'string', description: 'Principal type: USER or GROUP' }, principalId: { type: 'string', description: 'Identity Store user or group ID' }, + userId: { type: 'string', description: 'Identity Store user ID' }, + groupId: { type: 'string', description: 'Identity Store group ID' }, requestId: { type: 'string', description: 'Assignment creation/deletion request ID' }, - maxResults: { type: 'number', description: 'Maximum number of results to return' }, + maxResults: { + type: 'number', + description: 'Maximum number of results to return (1-100; List Accounts allows at most 20)', + }, nextToken: { type: 'string', description: 'Pagination token from previous request' }, }, outputs: { @@ -422,24 +543,35 @@ export const IdentityCenterBlock: BlockConfig = { instances: { type: 'json', description: - 'List of Identity Center instances (instanceArn, identityStoreId, name, status, statusReason)', + 'List of Identity Center instances (instanceArn, identityStoreId, name, status, statusReason, ownerAccountId, createdDate)', }, accounts: { type: 'json', - description: 'List of AWS accounts (id, arn, name, email, status)', + description: 'List of AWS accounts (id, arn, name, email, status, joinedTimestamp)', }, permissionSets: { type: 'json', - description: 'List of permission sets (permissionSetArn, name, description, sessionDuration)', + description: + 'List of permission sets (permissionSetArn, name, description, sessionDuration, createdDate)', }, groups: { type: 'json', - description: 'List of Identity Store groups (groupId, displayName, description)', + description: 'List of Identity Store groups (groupId, displayName, description, externalIds)', + }, + memberships: { + type: 'json', + description: 'List of group memberships (membershipId, groupId, userId)', }, userId: { type: 'string', description: 'Identity Store user ID (use as principalId)' }, userName: { type: 'string', description: 'Username in the Identity Store' }, displayName: { type: 'string', description: 'Display name of the user or group' }, email: { type: 'string', description: 'Email address of the user' }, + userStatus: { type: 'string', description: 'User account status (ENABLED or DISABLED)' }, + title: { type: 'string', description: 'Job title of the user' }, + externalIds: { + type: 'json', + description: 'External identity provider IDs on the user or group (issuer, id)', + }, groupId: { type: 'string', description: 'Identity Store group ID (use as principalId)' }, description: { type: 'string', description: 'Group description' }, id: { type: 'string', description: 'AWS account ID (from describe_account)' }, @@ -555,14 +687,14 @@ export const IdentityCenterBlockMeta = { description: 'Remove a permission set assignment from a user or group in Identity Center and confirm deletion. Use to wind down temporary or expired access.', content: - '# Revoke Access\n\nRemove an account assignment to revoke access.\n\n## Steps\n1. List account assignments to confirm the principal currently holds the permission set on the account.\n2. Delete the account assignment for that principal, permission set, and account.\n3. Poll check assignment deletion status until it reports SUCCEEDED.\n4. Re-list assignments to verify the grant is gone.\n\n## Output\nConfirm what was revoked and the final deletion status. Note if the assignment did not exist.', + '# Revoke Access\n\nRemove an account assignment to revoke access.\n\n## Steps\n1. List account assignments for principal to confirm the principal currently holds the permission set on the account.\n2. Delete the account assignment for that principal, permission set, and account.\n3. Poll check assignment deletion status until it reports SUCCEEDED.\n4. Re-list assignments to verify the grant is gone.\n\n## Output\nConfirm what was revoked and the final deletion status. Note if the assignment did not exist.', }, { name: 'access-audit-report', description: - 'Enumerate permission sets, group memberships, and account assignments in Identity Center to produce an access report. Use for compliance and periodic reviews.', + 'Enumerate permission sets, account assignments, and group memberships in Identity Center to produce an access report. Use for compliance and periodic reviews.', content: - '# Access Audit Report\n\nReport who has access to what across accounts.\n\n## Steps\n1. List instances and accounts to scope the report.\n2. List permission sets and, per account, list account assignments.\n3. Resolve users and groups behind each assignment with get user and get group.\n4. Compile assignments grouped by account and permission set.\n\n## Output\nAn access report: per account, which principals hold which permission sets, with anything unexpected flagged for review.', + '# Access Audit Report\n\nReport who has access to what across accounts.\n\n## Steps\n1. List instances and accounts to scope the report, and note the instance ARN and Identity Store ID.\n2. List permission sets for the instance.\n3. For every account and permission set pair, list assignments for account to collect the assigned principals.\n4. Resolve each principal by ID — describe user for USER principals, describe group for GROUP principals — and expand groups with list group memberships.\n5. Compile assignments grouped by account and permission set.\n\n## Output\nAn access report: per account, which principals hold which permission sets, with anything unexpected flagged for review.', }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/quickbooks.ts b/apps/sim/blocks/blocks/quickbooks.ts new file mode 100644 index 00000000000..e5594175141 --- /dev/null +++ b/apps/sim/blocks/blocks/quickbooks.ts @@ -0,0 +1,3640 @@ +import { QuickBooksIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' +import type { BlockConfig, BlockMeta, OutputCondition, SubBlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' +import { + getQuickBooksReportTypesSupporting, + QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES, + QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES, + QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES, + QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES, + type QuickBooksReportControl, +} from '@/tools/quickbooks/report-metadata' +import type { QuickBooksReportType, QuickBooksResponse } from '@/tools/quickbooks/types' +import { getTrigger } from '@/triggers' + +const MASTER_DATA_OPERATION = 'quickbooks_read_master_data' +const SALES_READ_OPERATION = 'quickbooks_read_sales_transactions' +const PURCHASING_READ_OPERATION = 'quickbooks_read_purchasing_transactions' +const ACCOUNTING_READ_OPERATION = 'quickbooks_read_accounting_transactions' +const REPORT_OPERATION = 'quickbooks_run_financial_report' +const EMAIL_TRANSACTION_OPERATION = 'quickbooks_email_transaction' +const DOWNLOAD_TRANSACTION_PDF_OPERATION = 'quickbooks_download_transaction_pdf' +const READ_ATTACHMENTS_OPERATION = 'quickbooks_read_attachments' +const ADD_ATTACHMENT_OPERATION = 'quickbooks_add_attachment' +const DOWNLOAD_ATTACHMENT_OPERATION = 'quickbooks_download_attachment' +const CUSTOMER_OPERATIONS = ['quickbooks_create_customer', 'quickbooks_update_customer'] as const +const EMPLOYEE_OPERATIONS = ['quickbooks_create_employee', 'quickbooks_update_employee'] as const +const VENDOR_OPERATIONS = ['quickbooks_create_vendor', 'quickbooks_update_vendor'] as const +const ITEM_OPERATIONS = ['quickbooks_create_item', 'quickbooks_update_item'] as const +const MASTER_DATA_CREATE_OPERATIONS = [ + 'quickbooks_create_customer', + 'quickbooks_create_employee', + 'quickbooks_create_item', + 'quickbooks_create_vendor', +] as const +const SALES_DOCUMENT_CREATE_OPERATIONS = [ + 'quickbooks_create_estimate', + 'quickbooks_create_invoice', + 'quickbooks_create_sales_receipt', + 'quickbooks_create_credit_memo', + 'quickbooks_create_refund_receipt', +] as const +const SALES_DOCUMENT_UPDATE_OPERATIONS = [ + 'quickbooks_update_estimate', + 'quickbooks_update_invoice', + 'quickbooks_update_sales_receipt', + 'quickbooks_update_credit_memo', + 'quickbooks_update_refund_receipt', +] as const +const SALES_DOCUMENT_OPERATIONS = [ + ...SALES_DOCUMENT_CREATE_OPERATIONS, + ...SALES_DOCUMENT_UPDATE_OPERATIONS, +] as const +const PAYMENT_OPERATIONS = [ + 'quickbooks_create_customer_payment', + 'quickbooks_update_customer_payment', +] as const +const SALES_CREATE_OPERATIONS = [ + ...SALES_DOCUMENT_CREATE_OPERATIONS, + 'quickbooks_create_customer_payment', +] as const +const PURCHASING_CREATE_OPERATIONS = [ + 'quickbooks_create_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_create_bill_payment', + 'quickbooks_create_vendor_credit', + 'quickbooks_create_purchase', +] as const +const ACCOUNTING_CREATE_OPERATIONS = [ + 'quickbooks_create_journal_entry', + 'quickbooks_create_deposit', +] as const +const CREATE_OPERATIONS = [ + ...MASTER_DATA_CREATE_OPERATIONS, + ...SALES_CREATE_OPERATIONS, + ...PURCHASING_CREATE_OPERATIONS, + ...ACCOUNTING_CREATE_OPERATIONS, +] as const +const SALES_UPDATE_OPERATIONS = [ + ...SALES_DOCUMENT_UPDATE_OPERATIONS, + 'quickbooks_update_customer_payment', +] as const +const SALES_VOID_OPERATIONS = [ + 'quickbooks_void_invoice', + 'quickbooks_void_customer_payment', +] as const +const MASTER_DATA_UPDATE_OPERATIONS = [ + 'quickbooks_update_customer', + 'quickbooks_update_employee', + 'quickbooks_update_item', + 'quickbooks_update_vendor', +] as const +const PURCHASING_UPDATE_OPERATIONS = [ + 'quickbooks_update_purchase_order', + 'quickbooks_update_bill', + 'quickbooks_update_bill_payment', + 'quickbooks_update_vendor_credit', + 'quickbooks_update_purchase', +] as const +const ACCOUNTING_UPDATE_OPERATIONS = [ + 'quickbooks_update_journal_entry', + 'quickbooks_update_deposit', +] as const +const SALES_MUTATION_OPERATIONS = [ + ...SALES_CREATE_OPERATIONS, + ...SALES_UPDATE_OPERATIONS, + ...SALES_VOID_OPERATIONS, +] as const +const PURCHASING_MUTATION_OPERATIONS = [ + ...PURCHASING_CREATE_OPERATIONS, + ...PURCHASING_UPDATE_OPERATIONS, +] as const +const ACCOUNTING_MUTATION_OPERATIONS = [ + ...ACCOUNTING_CREATE_OPERATIONS, + ...ACCOUNTING_UPDATE_OPERATIONS, +] as const +const UPDATE_OPERATIONS = [ + ...MASTER_DATA_UPDATE_OPERATIONS, + ...SALES_UPDATE_OPERATIONS, + ...SALES_VOID_OPERATIONS, + ...PURCHASING_UPDATE_OPERATIONS, + ...ACCOUNTING_UPDATE_OPERATIONS, +] as const +const MUTATION_OPERATIONS = [ + ...CUSTOMER_OPERATIONS, + ...EMPLOYEE_OPERATIONS, + ...ITEM_OPERATIONS, + ...VENDOR_OPERATIONS, + ...SALES_MUTATION_OPERATIONS, + ...PURCHASING_MUTATION_OPERATIONS, + ...ACCOUNTING_MUTATION_OPERATIONS, +] as const +const PAGINATED_OPERATIONS = [ + MASTER_DATA_OPERATION, + SALES_READ_OPERATION, + PURCHASING_READ_OPERATION, + ACCOUNTING_READ_OPERATION, + READ_ATTACHMENTS_OPERATION, +] as const +const LIST_OUTPUT_CONDITION: OutputCondition = { + field: 'operation', + value: [ + MASTER_DATA_OPERATION, + SALES_READ_OPERATION, + PURCHASING_READ_OPERATION, + ACCOUNTING_READ_OPERATION, + READ_ATTACHMENTS_OPERATION, + ], + and: { field: 'readMode', value: 'list' }, +} +const QUICKBOOKS_OPERATIONS = [ + 'quickbooks_get_company_info', + MASTER_DATA_OPERATION, + SALES_READ_OPERATION, + PURCHASING_READ_OPERATION, + ACCOUNTING_READ_OPERATION, + REPORT_OPERATION, + EMAIL_TRANSACTION_OPERATION, + DOWNLOAD_TRANSACTION_PDF_OPERATION, + READ_ATTACHMENTS_OPERATION, + ADD_ATTACHMENT_OPERATION, + DOWNLOAD_ATTACHMENT_OPERATION, + ...MUTATION_OPERATIONS, +] as const + +const QUICKBOOKS_TRIGGER_IDS = [ + 'quickbooks_invoice_events', + 'quickbooks_customer_events', + 'quickbooks_estimate_events', + 'quickbooks_payment_events', + 'quickbooks_credit_memo_events', + 'quickbooks_refund_receipt_events', + 'quickbooks_sales_receipt_events', + 'quickbooks_vendor_events', + 'quickbooks_bill_events', + 'quickbooks_bill_payment_events', + 'quickbooks_purchase_order_events', + 'quickbooks_purchase_events', + 'quickbooks_vendor_credit_events', + 'quickbooks_deposit_events', + 'quickbooks_journal_entry_events', + 'quickbooks_transfer_events', + 'quickbooks_item_events', + 'quickbooks_employee_events', + 'quickbooks_time_activity_events', + 'quickbooks_account_events', + 'quickbooks_budget_events', + 'quickbooks_class_events', + 'quickbooks_currency_events', + 'quickbooks_department_events', + 'quickbooks_journal_code_events', + 'quickbooks_payment_method_events', + 'quickbooks_preferences_updated', + 'quickbooks_tax_agency_events', + 'quickbooks_term_events', +] as const + +const QUICKBOOKS_SHARED_TRIGGER_FIELD_IDS = new Set([ + 'triggerCredentials', + 'quickBooksWebhookAppKey', +]) + +function getQuickBooksTriggerSubBlocks(): SubBlockConfig[] { + const sharedFields = new Set() + + return QUICKBOOKS_TRIGGER_IDS.flatMap((triggerId) => + getTrigger(triggerId).subBlocks.flatMap((subBlock) => { + if (!QUICKBOOKS_SHARED_TRIGGER_FIELD_IDS.has(subBlock.id)) return [subBlock] + if (sharedFields.has(subBlock.id)) return [] + sharedFields.add(subBlock.id) + return [ + { + ...subBlock, + condition: { + field: 'selectedTriggerId', + value: [...QUICKBOOKS_TRIGGER_IDS], + }, + }, + ] + }) + ) +} + +const REPORT_TIME_SUMMARY_OPTIONS = [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Total', id: 'total' }, + { label: 'Day', id: 'day' }, + { label: 'Week', id: 'week' }, + { label: 'Month', id: 'month' }, + { label: 'Quarter', id: 'quarter' }, + { label: 'Year', id: 'year' }, +] as const + +function parseJsonInput(value: unknown, fieldName: string): unknown { + if (value == null || value === '') return undefined + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + throw new Error(`${fieldName} must be valid JSON`) + } +} + +function parseJsonArrayInput(value: unknown, fieldName: string): unknown[] | undefined { + const parsed = parseJsonInput(value, fieldName) + if (parsed === undefined) return undefined + if (!Array.isArray(parsed)) throw new Error(`${fieldName} must be a JSON array`) + return parsed +} + +function parseJsonObjectInput( + value: unknown, + fieldName: string +): Record | undefined { + const parsed = parseJsonInput(value, fieldName) + if (parsed === undefined) return undefined + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${fieldName} must be a JSON object`) + } + return parsed as Record +} + +/** + * Wand config for a QuickBooks `YYYY-MM-DD` date field. `subject` names the + * specific date so the generated value lands in the right field. + */ +function dateWandConfig(subject: string) { + return { + enabled: true, + prompt: `Generate the ${subject} in YYYY-MM-DD format from the user description. Return ONLY the date - no explanations, no extra text.`, + generationType: 'timestamp' as const, + placeholder: 'Describe the date (e.g., "the last day of last month")...', + } +} + +function reportControlCondition(control: QuickBooksReportControl) { + return { + field: 'operation', + value: REPORT_OPERATION, + and: { + field: 'reportType', + value: getQuickBooksReportTypesSupporting(control), + }, + } +} + +function reportSupports(reportType: unknown, control: QuickBooksReportControl): boolean { + return getQuickBooksReportTypesSupporting(control).includes(reportType as QuickBooksReportType) +} + +function reportSummarizeValue(params: Record, reportType: unknown): unknown { + if ( + QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES.includes( + reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES)[number] + ) + ) { + return params.reportSummarizeBy ?? 'default' + } + if ( + QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES.includes( + reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES)[number] + ) + ) { + return params.reportCustomerSalesSummarizeBy ?? 'default' + } + if ( + QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES.includes( + reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES)[number] + ) + ) { + return params.reportVendorExpenseSummarizeBy ?? 'default' + } + if ( + QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES.includes( + reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES)[number] + ) + ) { + return params.reportTimeSummarizeBy ?? 'default' + } + return undefined +} + +function parseOptionalPositiveInteger(value: unknown, fieldName: string): number | undefined { + if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined + const parsed = typeof value === 'number' ? value : Number(value) + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${fieldName} must be a positive integer`) + } + return parsed +} + +function parsePaginationInteger( + value: unknown, + fieldName: 'startPosition' | 'maxResults', + fallback: number +): number { + if (value == null || (typeof value === 'string' && value.trim() === '')) return fallback + const parsed = typeof value === 'number' ? value : Number(value) + if (!Number.isInteger(parsed)) throw new Error(`${fieldName} must be an integer`) + if (fieldName === 'startPosition' && parsed < 1) { + throw new Error('startPosition must be a positive integer') + } + if (fieldName === 'maxResults' && (parsed < 1 || parsed > 100)) { + throw new Error('maxResults must be an integer from 1 through 100') + } + return parsed +} + +function parseOptionalNumber(value: unknown, fieldName: string): number | undefined { + if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined + const parsed = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(parsed)) throw new Error(`${fieldName} must be a finite number`) + return parsed +} + +function parseTriStateBoolean(value: unknown, fieldName: string): boolean | undefined { + if (value == null || value === '' || value === 'not_specified') return undefined + if (value === true || value === 'yes') return true + if (value === false || value === 'no') return false + throw new Error(`${fieldName} must be not specified, yes, or no`) +} + +function optionalValue(value: unknown): unknown { + if (value == null) return undefined + return typeof value === 'string' && value.trim() === '' ? undefined : value +} + +function requiredWhenNameAlternativesAreEmpty( + values: Record | undefined, + operations: readonly string[], + alternativeFields: readonly string[] +) { + const operation = typeof values?.operation === 'string' ? values.operation : '' + const alternativesAreEmpty = alternativeFields.every( + (field) => optionalValue(values?.[field]) === undefined + ) + return { + field: 'operation', + value: operations.includes(operation) && alternativesAreEmpty ? operation : [], + } +} + +function paginationCondition(values?: Record) { + if (!values) { + return { field: 'operation', value: [...PAGINATED_OPERATIONS] } + } + if (values?.operation === MASTER_DATA_OPERATION) { + return { field: 'readMode', value: 'list' } + } + if (values?.operation === SALES_READ_OPERATION) { + return { field: 'readMode', value: 'list' } + } + if (values?.operation === PURCHASING_READ_OPERATION) { + return { field: 'readMode', value: 'list' } + } + if (values?.operation === ACCOUNTING_READ_OPERATION) { + return { field: 'readMode', value: 'list' } + } + if (values?.operation === READ_ATTACHMENTS_OPERATION) { + return { field: 'readMode', value: 'list' } + } + return { field: 'operation', value: [] } +} + +function salesTransactionIdCondition(values?: Record) { + if (!values) { + return { + field: 'operation', + value: [ + SALES_READ_OPERATION, + PURCHASING_READ_OPERATION, + ACCOUNTING_READ_OPERATION, + ...SALES_UPDATE_OPERATIONS, + ...SALES_VOID_OPERATIONS, + ...PURCHASING_UPDATE_OPERATIONS, + ...ACCOUNTING_UPDATE_OPERATIONS, + ], + } + } + if ( + values?.operation === SALES_READ_OPERATION || + values?.operation === PURCHASING_READ_OPERATION || + values?.operation === ACCOUNTING_READ_OPERATION + ) { + return { field: 'readMode', value: 'by_id' } + } + return { + field: 'operation', + value: [ + ...SALES_UPDATE_OPERATIONS, + ...SALES_VOID_OPERATIONS, + ...PURCHASING_UPDATE_OPERATIONS, + ...ACCOUNTING_UPDATE_OPERATIONS, + ], + } +} + +function parseConfirmation(value: unknown, fieldName: string): boolean { + if (value === true || value === 'yes') return true + if (value === false || value === 'no' || value == null || value === '') return false + throw new Error(`${fieldName} must be yes or no`) +} + +function attachmentTargetCondition(values?: Record) { + if (!values) { + return { + field: 'operation', + value: [READ_ATTACHMENTS_OPERATION, ADD_ATTACHMENT_OPERATION], + } + } + if (values.operation === READ_ATTACHMENTS_OPERATION) { + return { field: 'readMode', value: 'list' } + } + return { field: 'operation', value: ADD_ATTACHMENT_OPERATION } +} + +function attachmentIdCondition(values?: Record) { + if (!values) { + return { + field: 'operation', + value: [READ_ATTACHMENTS_OPERATION, DOWNLOAD_ATTACHMENT_OPERATION], + } + } + if (values.operation === READ_ATTACHMENTS_OPERATION) { + return { field: 'readMode', value: 'by_id' } + } + return { field: 'operation', value: DOWNLOAD_ATTACHMENT_OPERATION } +} + +export const QuickBooksBlock: BlockConfig = { + type: 'quickbooks', + name: 'QuickBooks', + description: + 'Manage QuickBooks Online company, transactions, reports, emails, PDFs, and attachments', + authMode: AuthMode.OAuth, + longDescription: + 'Connect one QuickBooks Online company to manage bounded master-data, sales, purchasing, receivables, payables, accounting, reports, transaction delivery, and document workflows.', + docsLink: 'https://docs.sim.ai/integrations/quickbooks', + category: 'tools', + integrationType: IntegrationType.Commerce, + bgColor: '#2CA01C', + icon: QuickBooksIcon, + triggerAllowed: true, + canvasPresentation: { + defaultTitle: 'QuickBooks', + sentences: { + byOperation: { + quickbooks_get_company_info: ['Read the connected QuickBooks company'], + quickbooks_read_master_data: [ + 'Read', + { field: 'recordType', core: true }, + { text: 'using', field: 'readMode' }, + ], + quickbooks_create_customer: [ + { + text: 'Create customer', + field: ['displayName', 'givenName', 'familyName'], + core: true, + }, + ], + quickbooks_update_customer: [{ text: 'Update customer', field: 'customerId', core: true }], + quickbooks_create_employee: [ + { + text: 'Create employee', + field: ['displayName', 'givenName', 'familyName'], + core: true, + }, + ], + quickbooks_update_employee: [{ text: 'Update employee', field: 'employeeId', core: true }], + quickbooks_create_vendor: [ + { + text: 'Create vendor', + field: ['displayName', 'givenName', 'familyName'], + core: true, + }, + ], + quickbooks_update_vendor: [{ text: 'Update vendor', field: 'vendorId', core: true }], + quickbooks_create_item: [{ text: 'Create item', field: 'name', core: true }], + quickbooks_update_item: [{ text: 'Update item', field: 'itemId', core: true }], + quickbooks_read_sales_transactions: [ + 'Read sales transactions', + { text: 'of type', field: 'transactionType', core: true }, + { text: 'using', field: 'readMode' }, + ], + quickbooks_create_estimate: [ + { + text: 'Create an estimate for customer', + field: 'customerId', + core: true, + }, + ], + quickbooks_update_estimate: [ + { text: 'Update estimate', field: 'transactionId', core: true }, + ], + quickbooks_create_invoice: [ + { + text: 'Create an invoice for customer', + field: 'customerId', + core: true, + }, + ], + quickbooks_update_invoice: [{ text: 'Update invoice', field: 'transactionId', core: true }], + quickbooks_void_invoice: [{ text: 'Void invoice', field: 'transactionId', core: true }], + quickbooks_create_sales_receipt: [ + { + text: 'Create a sales receipt for customer', + field: 'customerId', + core: true, + }, + ], + quickbooks_update_sales_receipt: [ + { text: 'Update sales receipt', field: 'transactionId', core: true }, + ], + quickbooks_create_customer_payment: [ + { + text: 'Record payment from customer', + field: 'customerId', + core: true, + }, + { text: 'for', field: 'totalAmount' }, + ], + quickbooks_update_customer_payment: [ + { + text: 'Update customer payment', + field: 'transactionId', + core: true, + }, + ], + quickbooks_void_customer_payment: [ + { text: 'Void customer payment', field: 'transactionId', core: true }, + ], + quickbooks_create_credit_memo: [ + { + text: 'Create a credit memo for customer', + field: 'customerId', + core: true, + }, + ], + quickbooks_update_credit_memo: [ + { text: 'Update credit memo', field: 'transactionId', core: true }, + ], + quickbooks_create_refund_receipt: [ + { + text: 'Create a refund receipt for customer', + field: 'customerId', + core: true, + }, + ], + quickbooks_update_refund_receipt: [ + { text: 'Update refund receipt', field: 'transactionId', core: true }, + ], + quickbooks_read_purchasing_transactions: [ + 'Read purchasing transactions', + { text: 'of type', field: 'purchasingTransactionType', core: true }, + { text: 'using', field: 'readMode' }, + ], + quickbooks_create_purchase_order: [ + { + text: 'Create a purchase order for vendor', + field: 'vendorId', + core: true, + }, + ], + quickbooks_update_purchase_order: [ + { text: 'Update purchase order', field: 'transactionId', core: true }, + ], + quickbooks_create_bill: [ + { text: 'Create a bill for vendor', field: 'vendorId', core: true }, + ], + quickbooks_update_bill: [{ text: 'Update bill', field: 'transactionId', core: true }], + quickbooks_create_bill_payment: [ + { + text: 'Record bill payment for vendor', + field: 'vendorId', + core: true, + }, + { text: 'for', field: 'totalAmount' }, + ], + quickbooks_update_bill_payment: [ + { text: 'Update bill payment', field: 'transactionId', core: true }, + ], + quickbooks_create_vendor_credit: [ + { text: 'Create a credit for vendor', field: 'vendorId', core: true }, + ], + quickbooks_update_vendor_credit: [ + { text: 'Update vendor credit', field: 'transactionId', core: true }, + ], + quickbooks_create_purchase: [ + { + text: 'Record a purchase for vendor', + field: 'vendorId', + core: true, + }, + ], + quickbooks_update_purchase: [ + { text: 'Update purchase', field: 'transactionId', core: true }, + ], + quickbooks_read_accounting_transactions: [ + 'Read accounting transactions', + { text: 'of type', field: 'accountingTransactionType', core: true }, + { text: 'using', field: 'readMode' }, + ], + quickbooks_create_journal_entry: ['Create a journal entry'], + quickbooks_update_journal_entry: [ + { text: 'Update journal entry', field: 'transactionId', core: true }, + ], + quickbooks_create_deposit: [ + { + text: 'Create a deposit into account', + field: 'depositAccountId', + core: true, + }, + ], + quickbooks_update_deposit: [{ text: 'Update deposit', field: 'transactionId', core: true }], + quickbooks_run_financial_report: [{ text: 'Run report', field: 'reportType', core: true }], + quickbooks_email_transaction: [ + { text: 'Email', field: 'documentTransactionType', core: true }, + { text: 'with ID', field: 'documentTransactionId' }, + ], + quickbooks_download_transaction_pdf: [ + { + text: 'Download PDF for', + field: 'documentTransactionType', + core: true, + }, + { text: 'with ID', field: 'documentTransactionId' }, + ], + quickbooks_read_attachments: [ + 'Read QuickBooks attachments', + { text: 'for', field: 'attachmentTargetType' }, + { text: 'with ID', field: 'attachmentTargetId' }, + ], + quickbooks_add_attachment: [ + { + text: 'Add attachment to', + field: 'attachmentTargetType', + core: true, + }, + { text: 'with ID', field: 'attachmentTargetId' }, + ], + quickbooks_download_attachment: [ + { text: 'Download attachment', field: 'attachmentId', core: true }, + ], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Get Company Info', id: 'quickbooks_get_company_info' }, + { label: 'Read Master Data', id: 'quickbooks_read_master_data' }, + { label: 'Create Customer', id: 'quickbooks_create_customer' }, + { label: 'Update Customer', id: 'quickbooks_update_customer' }, + { label: 'Create Employee', id: 'quickbooks_create_employee' }, + { label: 'Update Employee', id: 'quickbooks_update_employee' }, + { label: 'Create Vendor', id: 'quickbooks_create_vendor' }, + { label: 'Update Vendor', id: 'quickbooks_update_vendor' }, + { label: 'Create Item', id: 'quickbooks_create_item' }, + { label: 'Update Item', id: 'quickbooks_update_item' }, + { + label: 'Read Sales Transactions', + id: 'quickbooks_read_sales_transactions', + }, + { label: 'Create Estimate', id: 'quickbooks_create_estimate' }, + { label: 'Update Estimate', id: 'quickbooks_update_estimate' }, + { label: 'Create Invoice', id: 'quickbooks_create_invoice' }, + { label: 'Update Invoice', id: 'quickbooks_update_invoice' }, + { label: 'Void Invoice', id: 'quickbooks_void_invoice' }, + { + label: 'Create Sales Receipt', + id: 'quickbooks_create_sales_receipt', + }, + { + label: 'Update Sales Receipt', + id: 'quickbooks_update_sales_receipt', + }, + { + label: 'Create Customer Payment', + id: 'quickbooks_create_customer_payment', + }, + { + label: 'Update Customer Payment', + id: 'quickbooks_update_customer_payment', + }, + { + label: 'Void Customer Payment', + id: 'quickbooks_void_customer_payment', + }, + { label: 'Create Credit Memo', id: 'quickbooks_create_credit_memo' }, + { label: 'Update Credit Memo', id: 'quickbooks_update_credit_memo' }, + { + label: 'Create Refund Receipt', + id: 'quickbooks_create_refund_receipt', + }, + { + label: 'Update Refund Receipt', + id: 'quickbooks_update_refund_receipt', + }, + { + label: 'Read Purchasing Transactions', + id: 'quickbooks_read_purchasing_transactions', + }, + { + label: 'Create Purchase Order', + id: 'quickbooks_create_purchase_order', + }, + { + label: 'Update Purchase Order', + id: 'quickbooks_update_purchase_order', + }, + { label: 'Create Bill', id: 'quickbooks_create_bill' }, + { label: 'Update Bill', id: 'quickbooks_update_bill' }, + { label: 'Create Bill Payment', id: 'quickbooks_create_bill_payment' }, + { label: 'Update Bill Payment', id: 'quickbooks_update_bill_payment' }, + { + label: 'Create Vendor Credit', + id: 'quickbooks_create_vendor_credit', + }, + { + label: 'Update Vendor Credit', + id: 'quickbooks_update_vendor_credit', + }, + { + label: 'Create Purchase or Expense', + id: 'quickbooks_create_purchase', + }, + { + label: 'Update Purchase or Expense', + id: 'quickbooks_update_purchase', + }, + { + label: 'Read Accounting Transactions', + id: 'quickbooks_read_accounting_transactions', + }, + { + label: 'Create Journal Entry', + id: 'quickbooks_create_journal_entry', + }, + { + label: 'Update Journal Entry', + id: 'quickbooks_update_journal_entry', + }, + { label: 'Create Deposit', id: 'quickbooks_create_deposit' }, + { label: 'Update Deposit', id: 'quickbooks_update_deposit' }, + { + label: 'Run Financial Report', + id: 'quickbooks_run_financial_report', + }, + { label: 'Email Transaction', id: 'quickbooks_email_transaction' }, + { + label: 'Download Transaction PDF', + id: 'quickbooks_download_transaction_pdf', + }, + { label: 'Read Attachments', id: 'quickbooks_read_attachments' }, + { label: 'Add Attachment', id: 'quickbooks_add_attachment' }, + { label: 'Download Attachment', id: 'quickbooks_download_attachment' }, + ], + value: () => 'quickbooks_get_company_info', + }, + { + id: 'credential', + title: 'QuickBooks Account', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + mode: 'basic', + serviceId: 'quickbooks', + requiredScopes: getScopesForService('quickbooks'), + placeholder: 'Select QuickBooks company', + required: true, + }, + { + id: 'manualCredential', + title: 'QuickBooks Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, + { + id: 'documentTransactionType', + title: 'Transaction Type', + type: 'dropdown', + options: [ + { label: 'Invoice', id: 'invoice' }, + { label: 'Customer Payment', id: 'payment' }, + { label: 'Estimate', id: 'estimate' }, + { label: 'Sales Receipt', id: 'sales_receipt' }, + { label: 'Credit Memo', id: 'credit_memo' }, + { label: 'Refund Receipt', id: 'refund_receipt' }, + { label: 'Purchase Order', id: 'purchase_order' }, + ], + condition: { + field: 'operation', + value: [EMAIL_TRANSACTION_OPERATION, DOWNLOAD_TRANSACTION_PDF_OPERATION], + }, + required: { + field: 'operation', + value: [EMAIL_TRANSACTION_OPERATION, DOWNLOAD_TRANSACTION_PDF_OPERATION], + }, + value: () => 'invoice', + }, + { + id: 'documentTransactionId', + title: 'Transaction ID', + type: 'short-input', + placeholder: 'QuickBooks transaction ID', + condition: { + field: 'operation', + value: [EMAIL_TRANSACTION_OPERATION, DOWNLOAD_TRANSACTION_PDF_OPERATION], + }, + required: { + field: 'operation', + value: [EMAIL_TRANSACTION_OPERATION, DOWNLOAD_TRANSACTION_PDF_OPERATION], + }, + }, + { + id: 'confirmSend', + title: 'Confirm Send', + type: 'dropdown', + options: [ + { label: 'No', id: 'no' }, + { label: 'Yes', id: 'yes' }, + ], + condition: { field: 'operation', value: EMAIL_TRANSACTION_OPERATION }, + required: { field: 'operation', value: EMAIL_TRANSACTION_OPERATION }, + value: () => 'no', + }, + { + id: 'recipientOverride', + title: 'Recipient', + type: 'short-input', + placeholder: 'Required for Customer Payments; otherwise optional', + condition: { field: 'operation', value: EMAIL_TRANSACTION_OPERATION }, + required: { + field: 'operation', + value: EMAIL_TRANSACTION_OPERATION, + and: { field: 'documentTransactionType', value: 'payment' }, + }, + description: + 'Required for Customer Payments. For other transactions, leave blank to use the email stored in QuickBooks or provide one override address.', + }, + { + id: 'documentFileName', + title: 'File Name', + type: 'short-input', + placeholder: 'Optional PDF filename', + condition: { + field: 'operation', + value: DOWNLOAD_TRANSACTION_PDF_OPERATION, + }, + mode: 'advanced', + }, + { + id: 'attachmentTargetType', + title: 'Target Type', + type: 'dropdown', + options: [ + { label: 'Bill', id: 'bill' }, + { label: 'Bill Payment', id: 'bill_payment' }, + { label: 'Credit Memo', id: 'credit_memo' }, + { label: 'Deposit', id: 'deposit' }, + { label: 'Estimate', id: 'estimate' }, + { label: 'Invoice', id: 'invoice' }, + { label: 'Item', id: 'item' }, + { label: 'Journal Entry', id: 'journal_entry' }, + { label: 'Customer Payment', id: 'payment' }, + { label: 'Purchase or Expense', id: 'purchase' }, + { label: 'Purchase Order', id: 'purchase_order' }, + { label: 'Refund Receipt', id: 'refund_receipt' }, + { label: 'Sales Receipt', id: 'sales_receipt' }, + { label: 'Vendor Credit', id: 'vendor_credit' }, + ], + condition: attachmentTargetCondition, + required: attachmentTargetCondition, + value: () => 'invoice', + }, + { + id: 'attachmentTargetId', + title: 'Target ID', + type: 'short-input', + placeholder: 'QuickBooks target entity ID', + condition: attachmentTargetCondition, + required: attachmentTargetCondition, + }, + { + id: 'attachmentId', + title: 'Attachment ID', + type: 'short-input', + placeholder: 'QuickBooks attachment ID', + condition: attachmentIdCondition, + required: attachmentIdCondition, + }, + { + id: 'attachmentKind', + title: 'Attachment Kind', + type: 'dropdown', + options: [ + { label: 'File', id: 'file' }, + { label: 'Note', id: 'note' }, + ], + condition: { field: 'operation', value: ADD_ATTACHMENT_OPERATION }, + required: { field: 'operation', value: ADD_ATTACHMENT_OPERATION }, + value: () => 'file', + }, + { + id: 'attachmentFileUpload', + title: 'File', + type: 'file-upload', + canonicalParamId: 'attachmentFile', + placeholder: 'Upload one supported file', + condition: { + field: 'operation', + value: ADD_ATTACHMENT_OPERATION, + and: { field: 'attachmentKind', value: 'file' }, + }, + required: { + field: 'operation', + value: ADD_ATTACHMENT_OPERATION, + and: { field: 'attachmentKind', value: 'file' }, + }, + mode: 'basic', + multiple: false, + }, + { + id: 'attachmentFileReference', + title: 'File', + type: 'short-input', + canonicalParamId: 'attachmentFile', + placeholder: 'Reference one file from a previous block', + condition: { + field: 'operation', + value: ADD_ATTACHMENT_OPERATION, + and: { field: 'attachmentKind', value: 'file' }, + }, + required: { + field: 'operation', + value: ADD_ATTACHMENT_OPERATION, + and: { field: 'attachmentKind', value: 'file' }, + }, + mode: 'advanced', + }, + { + id: 'attachmentNote', + title: 'Note', + type: 'long-input', + placeholder: 'Note to attach in QuickBooks', + condition: { + field: 'operation', + value: ADD_ATTACHMENT_OPERATION, + and: { field: 'attachmentKind', value: 'note' }, + }, + required: { + field: 'operation', + value: ADD_ATTACHMENT_OPERATION, + and: { field: 'attachmentKind', value: 'note' }, + }, + }, + { + id: 'attachmentFileName', + title: 'File Name', + type: 'short-input', + placeholder: 'Optional safe filename override', + condition: { + field: 'operation', + value: [ADD_ATTACHMENT_OPERATION, DOWNLOAD_ATTACHMENT_OPERATION], + }, + mode: 'advanced', + }, + { + id: 'attachmentContentType', + title: 'Content Type', + type: 'short-input', + placeholder: 'Optional compatible MIME type', + condition: { + field: 'operation', + value: ADD_ATTACHMENT_OPERATION, + and: { field: 'attachmentKind', value: 'file' }, + }, + mode: 'advanced', + }, + { + id: 'attachmentDescription', + title: 'Description', + type: 'long-input', + placeholder: 'Optional file attachment description', + condition: { + field: 'operation', + value: ADD_ATTACHMENT_OPERATION, + and: { field: 'attachmentKind', value: 'file' }, + }, + mode: 'advanced', + }, + { + id: 'recordType', + title: 'Record Type', + type: 'dropdown', + options: [ + { label: 'Account', id: 'account' }, + { label: 'Class', id: 'class' }, + { label: 'Customer', id: 'customer' }, + { label: 'Department', id: 'department' }, + { label: 'Vendor', id: 'vendor' }, + { label: 'Item', id: 'item' }, + { label: 'Employee', id: 'employee' }, + ], + condition: { field: 'operation', value: MASTER_DATA_OPERATION }, + required: { field: 'operation', value: MASTER_DATA_OPERATION }, + value: () => 'account', + }, + { + id: 'readMode', + title: 'Read Mode', + type: 'dropdown', + options: [ + { label: 'List', id: 'list' }, + { label: 'By ID', id: 'by_id' }, + ], + condition: { + field: 'operation', + value: [ + MASTER_DATA_OPERATION, + SALES_READ_OPERATION, + PURCHASING_READ_OPERATION, + ACCOUNTING_READ_OPERATION, + READ_ATTACHMENTS_OPERATION, + ], + }, + required: { + field: 'operation', + value: [ + MASTER_DATA_OPERATION, + SALES_READ_OPERATION, + PURCHASING_READ_OPERATION, + ACCOUNTING_READ_OPERATION, + READ_ATTACHMENTS_OPERATION, + ], + }, + value: () => 'list', + }, + { + id: 'recordId', + title: 'Record ID', + type: 'short-input', + placeholder: 'QuickBooks record ID', + condition: { + field: 'operation', + value: MASTER_DATA_OPERATION, + and: { field: 'readMode', value: 'by_id' }, + }, + required: { + field: 'operation', + value: MASTER_DATA_OPERATION, + and: { field: 'readMode', value: 'by_id' }, + }, + }, + { + id: 'readActiveStatus', + title: 'Active Status', + type: 'dropdown', + options: [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Active', id: 'active' }, + { label: 'Inactive', id: 'inactive' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: MASTER_DATA_OPERATION, + and: { field: 'readMode', value: 'list' }, + }, + value: () => 'default', + }, + { + id: 'readStartDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + mode: 'advanced', + wandConfig: dateWandConfig('transaction list start date'), + condition: { + field: 'operation', + value: [SALES_READ_OPERATION, PURCHASING_READ_OPERATION, ACCOUNTING_READ_OPERATION], + and: { field: 'readMode', value: 'list' }, + }, + }, + { + id: 'readEndDate', + title: 'End Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + mode: 'advanced', + wandConfig: dateWandConfig('transaction list end date'), + condition: { + field: 'operation', + value: [SALES_READ_OPERATION, PURCHASING_READ_OPERATION, ACCOUNTING_READ_OPERATION], + and: { field: 'readMode', value: 'list' }, + }, + }, + { + id: 'readCustomerId', + title: 'Customer ID', + type: 'short-input', + placeholder: 'Use Read Master Data to find a customer ID', + mode: 'advanced', + condition: { + field: 'operation', + value: SALES_READ_OPERATION, + and: { field: 'readMode', value: 'list' }, + }, + }, + { + id: 'readVendorId', + title: 'Vendor ID', + type: 'short-input', + placeholder: 'Use Read Master Data to find a vendor ID', + description: + 'Supported for purchase orders, bills, bill payments, and vendor credits. Purchase/Expense filtering is not exposed because its reference contract differs.', + mode: 'advanced', + condition: (values) => ({ + field: 'operation', + value: PURCHASING_READ_OPERATION, + and: + values?.purchasingTransactionType === 'purchase' + ? { + field: 'purchasingTransactionType', + value: 'purchase', + not: true, + } + : { field: 'readMode', value: 'list' }, + }), + }, + { + id: 'transactionType', + title: 'Transaction Type', + type: 'dropdown', + options: [ + { label: 'Estimate', id: 'estimate' }, + { label: 'Invoice', id: 'invoice' }, + { label: 'Sales Receipt', id: 'sales_receipt' }, + { label: 'Customer Payment', id: 'payment' }, + { label: 'Credit Memo', id: 'credit_memo' }, + { label: 'Refund Receipt', id: 'refund_receipt' }, + ], + condition: { field: 'operation', value: SALES_READ_OPERATION }, + required: { field: 'operation', value: SALES_READ_OPERATION }, + value: () => 'invoice', + }, + { + id: 'purchasingTransactionType', + title: 'Transaction Type', + type: 'dropdown', + options: [ + { label: 'Purchase Order', id: 'purchase_order' }, + { label: 'Bill', id: 'bill' }, + { label: 'Bill Payment', id: 'bill_payment' }, + { label: 'Vendor Credit', id: 'vendor_credit' }, + { label: 'Purchase/Expense', id: 'purchase' }, + ], + condition: { field: 'operation', value: PURCHASING_READ_OPERATION }, + required: { field: 'operation', value: PURCHASING_READ_OPERATION }, + value: () => 'bill', + }, + { + id: 'accountingTransactionType', + title: 'Transaction Type', + type: 'dropdown', + options: [ + { label: 'Journal Entry', id: 'journal_entry' }, + { label: 'Deposit', id: 'deposit' }, + { label: 'Transfer', id: 'transfer' }, + ], + condition: { field: 'operation', value: ACCOUNTING_READ_OPERATION }, + required: { field: 'operation', value: ACCOUNTING_READ_OPERATION }, + value: () => 'journal_entry', + }, + { + id: 'transactionId', + title: 'Transaction ID', + type: 'short-input', + placeholder: 'QuickBooks transaction ID', + condition: salesTransactionIdCondition, + required: salesTransactionIdCondition, + }, + { + id: 'reportType', + title: 'Report Type', + type: 'dropdown', + options: [ + { label: 'Balance Sheet', id: 'balance_sheet' }, + { label: 'Profit and Loss', id: 'profit_and_loss' }, + { label: 'Profit and Loss Detail', id: 'profit_and_loss_detail' }, + { label: 'Trial Balance', id: 'trial_balance' }, + { label: 'Statement of Cash Flows', id: 'cash_flow' }, + { label: 'A/P Aging Summary', id: 'ap_aging_summary' }, + { label: 'A/P Aging Detail', id: 'ap_aging_detail' }, + { label: 'A/R Aging Summary', id: 'ar_aging_summary' }, + { label: 'A/R Aging Detail', id: 'ar_aging_detail' }, + { label: 'Vendor Balance Summary', id: 'vendor_balance' }, + { label: 'Customer Balance Summary', id: 'customer_balance' }, + { label: 'Sales by Customer Summary', id: 'sales_by_customer' }, + { label: 'Sales by Product/Service Summary', id: 'sales_by_item' }, + { label: 'Expenses by Vendor', id: 'expenses_by_vendor' }, + { label: 'Transaction List', id: 'transaction_list' }, + ], + condition: { field: 'operation', value: REPORT_OPERATION }, + required: { field: 'operation', value: REPORT_OPERATION }, + value: () => 'profit_and_loss', + }, + { + id: 'reportStartDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + description: + 'Intuit recommends report periods of six months or less for performance, but longer periods remain supported.', + mode: 'advanced', + wandConfig: dateWandConfig('report start date'), + condition: reportControlCondition('startDate'), + }, + { + id: 'reportEndDate', + title: 'End or Report Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + description: 'End date for range reports or as-of date for balance and aging reports.', + mode: 'advanced', + wandConfig: dateWandConfig('report end or as-of date'), + condition: reportControlCondition('endDate'), + }, + { + id: 'reportAccountingMethod', + title: 'Accounting Method', + type: 'dropdown', + options: [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Cash', id: 'cash' }, + { label: 'Accrual', id: 'accrual' }, + ], + mode: 'advanced', + condition: reportControlCondition('accountingMethod'), + value: () => 'default', + }, + { + id: 'reportSummarizeBy', + title: 'Summarize Columns By', + type: 'dropdown', + options: [ + ...REPORT_TIME_SUMMARY_OPTIONS, + { label: 'Customer', id: 'customer' }, + { label: 'Vendor', id: 'vendor' }, + { label: 'Product/Service', id: 'item' }, + { label: 'Class', id: 'class' }, + { label: 'Department', id: 'department' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { + field: 'reportType', + value: [...QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES], + }, + }, + value: () => 'default', + }, + { + id: 'reportCustomerSalesSummarizeBy', + title: 'Summarize Columns By', + type: 'dropdown', + options: [ + ...REPORT_TIME_SUMMARY_OPTIONS, + { label: 'Customer', id: 'customer' }, + { label: 'Product/Service', id: 'item' }, + { label: 'Class', id: 'class' }, + { label: 'Department', id: 'department' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { + field: 'reportType', + value: [...QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES], + }, + }, + value: () => 'default', + }, + { + id: 'reportVendorExpenseSummarizeBy', + title: 'Summarize Columns By', + type: 'dropdown', + options: [ + ...REPORT_TIME_SUMMARY_OPTIONS, + { label: 'Customer', id: 'customer' }, + { label: 'Vendor', id: 'vendor' }, + { label: 'Class', id: 'class' }, + { label: 'Department', id: 'department' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { + field: 'reportType', + value: [...QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES], + }, + }, + value: () => 'default', + }, + { + id: 'reportTimeSummarizeBy', + title: 'Summarize Columns By', + type: 'dropdown', + options: [...REPORT_TIME_SUMMARY_OPTIONS], + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { + field: 'reportType', + value: [...QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES], + }, + }, + value: () => 'default', + }, + { + id: 'reportCustomerId', + title: 'Customer ID', + type: 'short-input', + placeholder: 'Use Read Master Data to find a customer ID', + mode: 'advanced', + condition: reportControlCondition('customerId'), + }, + { + id: 'reportVendorId', + title: 'Vendor ID', + type: 'short-input', + placeholder: 'Use Read Master Data to find a vendor ID', + mode: 'advanced', + condition: reportControlCondition('vendorId'), + }, + { + id: 'reportAccountId', + title: 'Account ID', + type: 'short-input', + placeholder: 'Use Read Master Data to find an account ID', + mode: 'advanced', + condition: reportControlCondition('accountId'), + }, + { + id: 'reportItemId', + title: 'Product/Service ID', + type: 'short-input', + placeholder: 'Use Read Master Data to find an item ID', + mode: 'advanced', + condition: reportControlCondition('itemId'), + }, + { + id: 'reportClassId', + title: 'Class ID', + type: 'short-input', + placeholder: 'Use Read Master Data to find a class ID', + mode: 'advanced', + condition: reportControlCondition('classId'), + }, + { + id: 'reportDepartmentId', + title: 'Department ID', + type: 'short-input', + placeholder: 'Use Read Master Data to find a department ID', + mode: 'advanced', + condition: reportControlCondition('departmentId'), + }, + { + id: 'reportAgingMethod', + title: 'Aging Method', + type: 'dropdown', + options: [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Report Date', id: 'report_date' }, + { label: 'Current Date', id: 'current' }, + ], + mode: 'advanced', + condition: reportControlCondition('agingMethod'), + value: () => 'default', + }, + { + id: 'reportAgingDays', + title: 'Days per Aging Period', + type: 'short-input', + placeholder: '30', + mode: 'advanced', + condition: reportControlCondition('agingPeriod'), + }, + { + id: 'reportTransactionType', + title: 'Transaction Type', + type: 'dropdown', + options: [ + { label: 'All', id: 'default' }, + { label: 'Bill', id: 'bill' }, + { label: 'Bill Payment (Check)', id: 'bill_payment_check' }, + { label: 'Bill Payment (Credit Card)', id: 'bill_payment_credit_card' }, + { label: 'Cash Purchase', id: 'cash_purchase' }, + { label: 'Check', id: 'check' }, + { label: 'Credit Card Charge', id: 'credit_card_charge' }, + { label: 'Credit Card Credit', id: 'credit_card_credit' }, + { label: 'Credit Memo', id: 'credit_memo' }, + { label: 'Deposit', id: 'deposit' }, + { label: 'Estimate', id: 'estimate' }, + { label: 'Invoice', id: 'invoice' }, + { label: 'Journal Entry', id: 'journal_entry' }, + { label: 'Customer Payment', id: 'payment' }, + { label: 'Purchase Order', id: 'purchase_order' }, + { label: 'Sales Receipt', id: 'sales_receipt' }, + { label: 'Transfer', id: 'transfer' }, + { label: 'Vendor Credit', id: 'vendor_credit' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { field: 'reportType', value: 'transaction_list' }, + }, + value: () => 'default', + }, + { + id: 'reportGroupBy', + title: 'Group By', + type: 'dropdown', + options: [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Account', id: 'account' }, + { label: 'Customer', id: 'customer' }, + { label: 'Day', id: 'day' }, + { label: 'Department', id: 'department' }, + { label: 'Employee', id: 'employee' }, + { label: 'Month', id: 'month' }, + { label: 'Name', id: 'name' }, + { label: 'None', id: 'none' }, + { label: 'Payment Method', id: 'payment_method' }, + { label: 'Quarter', id: 'quarter' }, + { label: 'Transaction Type', id: 'transaction_type' }, + { label: 'Vendor', id: 'vendor' }, + { label: 'Week', id: 'week' }, + { label: 'Year', id: 'year' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { field: 'reportType', value: 'transaction_list' }, + }, + value: () => 'default', + }, + { + id: 'reportAccountsPayablePaid', + title: 'A/P Paid Status', + type: 'dropdown', + options: [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'All', id: 'all' }, + { label: 'Paid', id: 'paid' }, + { label: 'Unpaid', id: 'unpaid' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { field: 'reportType', value: 'transaction_list' }, + }, + value: () => 'default', + }, + { + id: 'reportAccountsReceivablePaid', + title: 'A/R Paid Status', + type: 'dropdown', + options: [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'All', id: 'all' }, + { label: 'Paid', id: 'paid' }, + { label: 'Unpaid', id: 'unpaid' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { field: 'reportType', value: 'transaction_list' }, + }, + value: () => 'default', + }, + { + id: 'reportClearedStatus', + title: 'Cleared Status', + type: 'dropdown', + options: [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Cleared', id: 'cleared' }, + { label: 'Uncleared', id: 'uncleared' }, + { label: 'Reconciled', id: 'reconciled' }, + { label: 'Deposited', id: 'deposited' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { field: 'reportType', value: 'transaction_list' }, + }, + value: () => 'default', + }, + { + id: 'reportDocumentNumber', + title: 'Document Number', + type: 'short-input', + placeholder: 'Exact QuickBooks document number', + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { field: 'reportType', value: 'transaction_list' }, + }, + }, + { + id: 'reportSourceAccountType', + title: 'Source Account Type', + type: 'dropdown', + options: [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Accounts Payable', id: 'accounts_payable' }, + { label: 'Accounts Receivable', id: 'accounts_receivable' }, + { label: 'Bank', id: 'bank' }, + { label: 'Cost of Goods Sold', id: 'cost_of_goods_sold' }, + { label: 'Credit Card', id: 'credit_card' }, + { label: 'Equity', id: 'equity' }, + { label: 'Expense', id: 'expense' }, + { label: 'Fixed Asset', id: 'fixed_asset' }, + { label: 'Income', id: 'income' }, + { label: 'Long-term Liability', id: 'long_term_liability' }, + { label: 'Non-posting', id: 'non_posting' }, + { label: 'Other Asset', id: 'other_asset' }, + { label: 'Other Current Asset', id: 'other_current_asset' }, + { label: 'Other Current Liability', id: 'other_current_liability' }, + { label: 'Other Expense', id: 'other_expense' }, + { label: 'Other Income', id: 'other_income' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: REPORT_OPERATION, + and: { field: 'reportType', value: 'transaction_list' }, + }, + value: () => 'default', + }, + { + id: 'startPosition', + title: 'Start Position', + type: 'short-input', + placeholder: '1', + mode: 'advanced', + condition: paginationCondition, + value: () => '1', + }, + { + id: 'maxResults', + title: 'Max Results', + type: 'short-input', + placeholder: '25', + mode: 'advanced', + condition: paginationCondition, + value: () => '25', + }, + { + id: 'customerId', + title: 'Customer ID', + type: 'short-input', + placeholder: 'QuickBooks customer ID', + condition: { + field: 'operation', + value: ['quickbooks_update_customer', ...SALES_DOCUMENT_OPERATIONS, ...PAYMENT_OPERATIONS], + }, + required: { + field: 'operation', + value: ['quickbooks_update_customer', ...SALES_CREATE_OPERATIONS], + }, + }, + { + id: 'vendorId', + title: 'Vendor ID', + type: 'short-input', + placeholder: 'QuickBooks vendor ID', + condition: { + field: 'operation', + value: ['quickbooks_update_vendor', ...PURCHASING_MUTATION_OPERATIONS], + }, + required: { + field: 'operation', + value: [ + 'quickbooks_update_vendor', + 'quickbooks_create_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_create_bill_payment', + 'quickbooks_create_vendor_credit', + ], + }, + }, + { + id: 'itemId', + title: 'Item ID', + type: 'short-input', + placeholder: 'QuickBooks item ID', + condition: { field: 'operation', value: 'quickbooks_update_item' }, + required: { field: 'operation', value: 'quickbooks_update_item' }, + }, + { + id: 'employeeId', + title: 'Employee ID', + type: 'short-input', + placeholder: 'QuickBooks employee ID', + condition: { field: 'operation', value: 'quickbooks_update_employee' }, + required: { field: 'operation', value: 'quickbooks_update_employee' }, + }, + { + id: 'syncToken', + title: 'Sync Token', + type: 'short-input', + placeholder: 'Current QuickBooks sync token', + condition: { field: 'operation', value: [...UPDATE_OPERATIONS] }, + required: { field: 'operation', value: [...UPDATE_OPERATIONS] }, + }, + { + id: 'displayName', + title: 'Display Name', + type: 'short-input', + placeholder: 'Unique display name', + condition: { + field: 'operation', + value: [...CUSTOMER_OPERATIONS, ...EMPLOYEE_OPERATIONS, ...VENDOR_OPERATIONS], + }, + required: (values) => + requiredWhenNameAlternativesAreEmpty( + values, + ['quickbooks_create_customer', 'quickbooks_create_vendor'], + ['givenName', 'familyName'] + ), + }, + { + id: 'companyName', + title: 'Company Name', + type: 'short-input', + placeholder: 'Company name', + condition: { + field: 'operation', + value: [...CUSTOMER_OPERATIONS, ...VENDOR_OPERATIONS], + }, + }, + { + id: 'givenName', + title: 'Given Name', + type: 'short-input', + placeholder: 'Given name', + condition: { + field: 'operation', + value: [...CUSTOMER_OPERATIONS, ...EMPLOYEE_OPERATIONS, ...VENDOR_OPERATIONS], + }, + required: (values) => + requiredWhenNameAlternativesAreEmpty( + values, + ['quickbooks_create_customer', 'quickbooks_create_employee', 'quickbooks_create_vendor'], + values?.operation === 'quickbooks_create_employee' + ? ['familyName'] + : ['displayName', 'familyName'] + ), + }, + { + id: 'familyName', + title: 'Family Name', + type: 'short-input', + placeholder: 'Family name', + condition: { + field: 'operation', + value: [...CUSTOMER_OPERATIONS, ...EMPLOYEE_OPERATIONS, ...VENDOR_OPERATIONS], + }, + required: (values) => + requiredWhenNameAlternativesAreEmpty( + values, + ['quickbooks_create_customer', 'quickbooks_create_employee', 'quickbooks_create_vendor'], + values?.operation === 'quickbooks_create_employee' + ? ['givenName'] + : ['displayName', 'givenName'] + ), + }, + { + id: 'primaryEmail', + title: 'Primary Email', + type: 'short-input', + placeholder: 'name@example.com', + condition: { + field: 'operation', + value: [...CUSTOMER_OPERATIONS, ...EMPLOYEE_OPERATIONS, ...VENDOR_OPERATIONS], + }, + }, + { + id: 'primaryPhone', + title: 'Primary Phone', + type: 'short-input', + placeholder: 'Phone number', + condition: { + field: 'operation', + value: [...CUSTOMER_OPERATIONS, ...EMPLOYEE_OPERATIONS, ...VENDOR_OPERATIONS], + }, + }, + { + id: 'billingAddress', + title: 'Billing Address (JSON)', + type: 'code', + language: 'json', + placeholder: + '{"line1":"123 Main St","city":"San Francisco","countrySubDivisionCode":"CA","postalCode":"94105"}', + condition: { + field: 'operation', + value: [...CUSTOMER_OPERATIONS, ...VENDOR_OPERATIONS], + }, + mode: 'advanced', + wandConfig: { + enabled: true, + placeholder: 'Describe the customer or vendor billing address', + prompt: + 'Generate a QuickBooks address JSON object using only line1, line2, city, countrySubDivisionCode, postalCode, and country. Return ONLY the JSON object - no explanations, no extra text.', + generationType: 'json-object', + }, + }, + { + id: 'shippingAddress', + title: 'Shipping Address (JSON)', + type: 'code', + language: 'json', + placeholder: + '{"line1":"123 Main St","city":"San Francisco","countrySubDivisionCode":"CA","postalCode":"94105"}', + condition: { field: 'operation', value: [...CUSTOMER_OPERATIONS] }, + mode: 'advanced', + wandConfig: { + enabled: true, + placeholder: 'Describe the customer shipping address', + prompt: + 'Generate a QuickBooks address JSON object using only line1, line2, city, countrySubDivisionCode, postalCode, and country. Return ONLY the JSON object - no explanations, no extra text.', + generationType: 'json-object', + }, + }, + { + id: 'primaryAddress', + title: 'Primary Address (JSON)', + type: 'code', + language: 'json', + placeholder: + '{"line1":"123 Main St","city":"San Francisco","countrySubDivisionCode":"CA","postalCode":"94105"}', + condition: { field: 'operation', value: [...EMPLOYEE_OPERATIONS] }, + mode: 'advanced', + wandConfig: { + enabled: true, + placeholder: 'Describe the employee address', + prompt: + 'Generate a QuickBooks address JSON object using only line1, line2, city, countrySubDivisionCode, postalCode, and country. Return ONLY the JSON object - no explanations, no extra text.', + generationType: 'json-object', + }, + }, + { + id: 'printOnCheckName', + title: 'Print on Check Name', + type: 'short-input', + placeholder: 'Name printed on checks', + condition: { + field: 'operation', + value: [...EMPLOYEE_OPERATIONS, ...VENDOR_OPERATIONS], + }, + mode: 'advanced', + }, + { + id: 'billableTime', + title: 'Billable Time', + type: 'dropdown', + options: [ + { label: 'Not specified', id: 'not_specified' }, + { label: 'Yes', id: 'yes' }, + { label: 'No', id: 'no' }, + ], + condition: { field: 'operation', value: [...EMPLOYEE_OPERATIONS] }, + mode: 'advanced', + value: () => 'not_specified', + }, + { + id: 'accountNumber', + title: 'Vendor Account Number', + type: 'short-input', + placeholder: 'Account number', + condition: { field: 'operation', value: [...VENDOR_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'vendor1099', + title: '1099 Vendor', + type: 'dropdown', + options: [ + { label: 'Not specified', id: 'not_specified' }, + { label: 'Yes', id: 'yes' }, + { label: 'No', id: 'no' }, + ], + condition: { field: 'operation', value: [...VENDOR_OPERATIONS] }, + mode: 'advanced', + value: () => 'not_specified', + }, + { + id: 'name', + title: 'Item Name', + type: 'short-input', + placeholder: 'Unique item name', + condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, + required: { field: 'operation', value: 'quickbooks_create_item' }, + }, + { + id: 'itemType', + title: 'Item Type', + type: 'dropdown', + options: [ + { label: 'Service', id: 'service' }, + { label: 'Non-inventory', id: 'non_inventory' }, + ], + condition: { field: 'operation', value: 'quickbooks_create_item' }, + required: { field: 'operation', value: 'quickbooks_create_item' }, + value: () => 'service', + }, + { + id: 'incomeAccountId', + title: 'Income Account ID', + type: 'short-input', + placeholder: 'QuickBooks income account ID', + condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, + }, + { + id: 'description', + title: 'Sales Description', + type: 'long-input', + placeholder: 'Item sales description', + condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, + }, + { + id: 'unitPrice', + title: 'Unit Price', + type: 'short-input', + placeholder: '0.00', + condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, + }, + { + id: 'purchaseDescription', + title: 'Purchase Description', + type: 'long-input', + placeholder: 'Item purchase description', + condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'purchaseCost', + title: 'Purchase Cost', + type: 'short-input', + placeholder: '0.00', + condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'expenseAccountId', + title: 'Expense Account ID', + type: 'short-input', + placeholder: 'QuickBooks expense account ID', + condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, + }, + { + id: 'taxable', + title: 'Taxable', + type: 'dropdown', + options: [ + { label: 'Not specified', id: 'not_specified' }, + { label: 'Yes', id: 'yes' }, + { label: 'No', id: 'no' }, + ], + condition: { + field: 'operation', + value: [...CUSTOMER_OPERATIONS, ...ITEM_OPERATIONS], + }, + mode: 'advanced', + value: () => 'not_specified', + }, + { + id: 'activeStatus', + title: 'Active Status', + type: 'dropdown', + options: [ + { label: 'Unchanged', id: 'unchanged' }, + { label: 'Active', id: 'active' }, + { label: 'Inactive', id: 'inactive' }, + ], + condition: { + field: 'operation', + value: [...MASTER_DATA_UPDATE_OPERATIONS], + }, + value: () => 'unchanged', + }, + { + id: 'lines', + title: 'Lines (JSON)', + type: 'code', + language: 'json', + placeholder: '[{"lineType":"item","amount":100,"itemId":"7","description":"Consulting"}]', + condition: { field: 'operation', value: [...SALES_DOCUMENT_OPERATIONS] }, + required: { + field: 'operation', + value: [...SALES_DOCUMENT_CREATE_OPERATIONS], + }, + wandConfig: { + enabled: true, + placeholder: 'Describe the products, services, amounts, and quantities', + prompt: + 'Generate a JSON array of QuickBooks sales lines. Use item lines with lineType, positive amount, itemId, and optional description, positive quantity, positive unitPrice, and serviceDate. When quantity and unitPrice are both present, amount must equal quantity multiplied by unitPrice. Use description lines with lineType and description. Return ONLY the JSON array - no explanations, no extra text.', + }, + }, + { + id: 'purchasingLines', + title: 'Expense Lines (JSON)', + type: 'code', + language: 'json', + placeholder: '[{"lineType":"account","amount":100,"accountId":"7","description":"Supplies"}]', + condition: { + field: 'operation', + value: [ + 'quickbooks_create_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_create_vendor_credit', + 'quickbooks_create_purchase', + ], + }, + required: { + field: 'operation', + value: [ + 'quickbooks_create_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_create_vendor_credit', + 'quickbooks_create_purchase', + ], + }, + wandConfig: { + enabled: true, + placeholder: 'Describe the expense accounts or items and amounts', + prompt: + 'Generate a JSON array of QuickBooks purchasing lines. Use account lines with lineType account, positive amount, accountId, and optional description; or item lines with lineType item, positive amount, itemId, and optional description, positive quantity, and positive unitPrice. When quantity and unitPrice are both present, amount must equal their product. For Create Bill only, a line may include both purchaseOrderId and purchaseOrderLineId to request an explicit Purchase Order line link; always supply both or neither. Return ONLY the JSON array - no explanations, no extra text.', + }, + }, + { + id: 'journalLines', + title: 'Journal Lines (JSON)', + type: 'code', + language: 'json', + placeholder: + '[{"postingType":"debit","amount":100,"accountId":"7"},{"postingType":"credit","amount":100,"accountId":"35"}]', + condition: { + field: 'operation', + value: 'quickbooks_create_journal_entry', + }, + required: { + field: 'operation', + value: 'quickbooks_create_journal_entry', + }, + wandConfig: { + enabled: true, + placeholder: 'Describe the debit and credit entries', + prompt: + 'Generate a balanced JSON array of QuickBooks journal lines. Each line needs postingType debit or credit, a positive amount, and accountId. Optional fields are description and an entityType/entityId pair. Debits and credits must total the same amount. Return ONLY the JSON array - no explanations, no extra text.', + }, + }, + { + id: 'depositLines', + title: 'Deposit Lines (JSON)', + type: 'code', + language: 'json', + placeholder: '[{"amount":100,"accountId":"7","description":"Deposit source"}]', + condition: { field: 'operation', value: 'quickbooks_create_deposit' }, + required: { field: 'operation', value: 'quickbooks_create_deposit' }, + wandConfig: { + enabled: true, + placeholder: 'Describe the deposit sources and amounts', + prompt: + 'Generate a JSON array of QuickBooks deposit lines. Each line needs a positive amount and accountId, with optional description. Return ONLY the JSON array - no explanations, no extra text.', + }, + }, + { + id: 'totalAmount', + title: 'Total Amount', + type: 'short-input', + placeholder: '100.00', + condition: { + field: 'operation', + value: [...PAYMENT_OPERATIONS, 'quickbooks_create_bill_payment'], + }, + required: { + field: 'operation', + value: ['quickbooks_create_customer_payment', 'quickbooks_create_bill_payment'], + }, + }, + { + id: 'apAccountId', + title: 'Accounts Payable Account ID', + type: 'short-input', + placeholder: 'QuickBooks A/P account ID', + condition: { + field: 'operation', + value: [ + 'quickbooks_create_purchase_order', + 'quickbooks_update_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_update_bill', + 'quickbooks_create_vendor_credit', + 'quickbooks_update_vendor_credit', + ], + }, + required: { + field: 'operation', + value: 'quickbooks_create_purchase_order', + }, + }, + { + id: 'billPaymentType', + title: 'Payment Type', + type: 'dropdown', + options: [ + { label: 'Check', id: 'check' }, + { label: 'Credit Card', id: 'credit_card' }, + ], + condition: { + field: 'operation', + value: 'quickbooks_create_bill_payment', + }, + required: { field: 'operation', value: 'quickbooks_create_bill_payment' }, + value: () => 'check', + }, + { + id: 'purchasePaymentType', + title: 'Payment Type', + type: 'dropdown', + options: [ + { label: 'Cash', id: 'cash' }, + { label: 'Check', id: 'check' }, + { label: 'Credit Card', id: 'credit_card' }, + ], + condition: { field: 'operation', value: 'quickbooks_create_purchase' }, + required: { field: 'operation', value: 'quickbooks_create_purchase' }, + value: () => 'cash', + }, + { + id: 'paymentAccountId', + title: 'Payment Account ID', + type: 'short-input', + placeholder: 'QuickBooks bank or credit-card account ID', + condition: { + field: 'operation', + value: ['quickbooks_create_bill_payment', 'quickbooks_create_purchase'], + }, + required: { + field: 'operation', + value: ['quickbooks_create_bill_payment', 'quickbooks_create_purchase'], + }, + }, + { + id: 'billAllocations', + title: 'Bill Allocations (JSON)', + type: 'code', + language: 'json', + placeholder: '[{"billId":"123","amount":75}]', + condition: { + field: 'operation', + value: 'quickbooks_create_bill_payment', + }, + wandConfig: { + enabled: true, + placeholder: 'Describe how the payment should be allocated across bills', + prompt: + 'Generate a JSON array of QuickBooks Bill allocations using only billId and a positive amount. Allocation amounts must total the payment amount. Return ONLY the JSON array - no explanations, no extra text.', + }, + }, + { + id: 'transactionDate', + title: 'Transaction Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { + field: 'operation', + value: [ + ...SALES_CREATE_OPERATIONS, + ...SALES_UPDATE_OPERATIONS, + ...PURCHASING_MUTATION_OPERATIONS, + ...ACCOUNTING_MUTATION_OPERATIONS, + ], + }, + mode: 'advanced', + wandConfig: dateWandConfig('transaction date'), + }, + { + id: 'dueDate', + title: 'Due Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { + field: 'operation', + value: [ + 'quickbooks_create_invoice', + 'quickbooks_update_invoice', + 'quickbooks_create_bill', + 'quickbooks_update_bill', + ], + }, + mode: 'advanced', + wandConfig: dateWandConfig('due date'), + }, + { + id: 'expirationDate', + title: 'Expiration Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { + field: 'operation', + value: ['quickbooks_create_estimate', 'quickbooks_update_estimate'], + }, + mode: 'advanced', + wandConfig: dateWandConfig('estimate expiration date'), + }, + { + id: 'documentNumber', + title: 'Document Number', + type: 'short-input', + placeholder: 'Optional QuickBooks document number', + condition: { + field: 'operation', + value: [ + ...SALES_DOCUMENT_OPERATIONS, + 'quickbooks_create_purchase_order', + 'quickbooks_update_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_update_bill', + 'quickbooks_create_vendor_credit', + 'quickbooks_update_vendor_credit', + 'quickbooks_create_journal_entry', + 'quickbooks_update_journal_entry', + ], + }, + mode: 'advanced', + }, + { + id: 'privateNote', + title: 'Private Note', + type: 'long-input', + placeholder: 'Internal note', + condition: { + field: 'operation', + value: [ + ...SALES_CREATE_OPERATIONS, + ...SALES_UPDATE_OPERATIONS, + ...PURCHASING_MUTATION_OPERATIONS, + ...ACCOUNTING_MUTATION_OPERATIONS, + ], + }, + mode: 'advanced', + }, + { + id: 'customerMemo', + title: 'Customer Memo', + type: 'long-input', + placeholder: 'Customer-facing memo', + condition: { field: 'operation', value: [...SALES_DOCUMENT_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'paymentMethodId', + title: 'Payment Method ID', + type: 'short-input', + placeholder: 'QuickBooks payment method ID', + condition: { + field: 'operation', + value: [ + 'quickbooks_create_sales_receipt', + 'quickbooks_update_sales_receipt', + 'quickbooks_create_refund_receipt', + 'quickbooks_update_refund_receipt', + ...PAYMENT_OPERATIONS, + ], + }, + mode: 'advanced', + }, + { + id: 'paymentReferenceNumber', + title: 'Payment Reference Number', + type: 'short-input', + placeholder: 'Check or payment reference', + condition: { + field: 'operation', + value: [ + 'quickbooks_create_sales_receipt', + 'quickbooks_update_sales_receipt', + 'quickbooks_create_refund_receipt', + 'quickbooks_update_refund_receipt', + ...PAYMENT_OPERATIONS, + ], + }, + mode: 'advanced', + }, + { + id: 'depositAccountId', + title: 'Deposit Account ID', + type: 'short-input', + placeholder: 'QuickBooks deposit account ID', + condition: { + field: 'operation', + value: [ + 'quickbooks_create_sales_receipt', + 'quickbooks_update_sales_receipt', + 'quickbooks_create_refund_receipt', + 'quickbooks_update_refund_receipt', + ...PAYMENT_OPERATIONS, + 'quickbooks_create_deposit', + 'quickbooks_update_deposit', + ], + }, + required: { + field: 'operation', + value: [ + 'quickbooks_create_refund_receipt', + 'quickbooks_create_deposit', + 'quickbooks_update_deposit', + ], + }, + }, + { + id: 'invoiceAllocations', + title: 'Invoice Allocations (JSON)', + type: 'code', + language: 'json', + placeholder: '[{"invoiceId":"42","amount":75}]', + condition: { field: 'operation', value: [...PAYMENT_OPERATIONS] }, + mode: 'advanced', + wandConfig: { + enabled: true, + placeholder: 'Describe how the payment should be allocated across invoices', + prompt: + 'Generate a JSON array of QuickBooks invoice allocations using only invoiceId and a positive amount. On an update these are merged into the allocations the payment already has, so list only the invoices whose applied amount should change; any invoice already applied and not listed keeps its current amount. Return ONLY the JSON array - no explanations, no extra text.', + }, + }, + { + id: 'unapplyOmittedInvoices', + title: 'Replace Invoice Allocations', + type: 'dropdown', + options: [ + { label: 'No', id: 'no' }, + { label: 'Yes', id: 'yes' }, + ], + description: + 'Yes requires a non-empty Invoice Allocations list and removes every unlisted invoice from this payment, returning it to open.', + condition: { + field: 'operation', + value: 'quickbooks_update_customer_payment', + }, + mode: 'advanced', + value: () => 'no', + }, + { + id: 'paymentReference', + title: 'Payment Reference', + type: 'short-input', + placeholder: 'Optional check or payment reference', + condition: { + field: 'operation', + value: ['quickbooks_create_purchase', 'quickbooks_update_purchase'], + }, + mode: 'advanced', + }, + { + id: 'requestId', + title: 'Request ID', + type: 'short-input', + placeholder: 'Optional idempotency key (max 50 characters)', + condition: { field: 'operation', value: [...CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'confirmVoid', + title: 'Confirm Void', + type: 'dropdown', + options: [ + { label: 'No', id: 'no' }, + { label: 'Yes', id: 'yes' }, + ], + condition: { field: 'operation', value: [...SALES_VOID_OPERATIONS] }, + required: { field: 'operation', value: [...SALES_VOID_OPERATIONS] }, + value: () => 'no', + }, + { + id: 'confirmPosting', + title: 'Confirm Posting', + type: 'dropdown', + options: [ + { label: 'No', id: 'no' }, + { label: 'Yes', id: 'yes' }, + ], + condition: { + field: 'operation', + value: ['quickbooks_create_journal_entry', 'quickbooks_update_journal_entry'], + }, + required: { + field: 'operation', + value: ['quickbooks_create_journal_entry', 'quickbooks_update_journal_entry'], + }, + value: () => 'no', + }, + ...getQuickBooksTriggerSubBlocks(), + ], + triggers: { + enabled: true, + available: [...QUICKBOOKS_TRIGGER_IDS], + }, + tools: { + access: [ + 'quickbooks_get_company_info', + 'quickbooks_read_master_data', + 'quickbooks_create_customer', + 'quickbooks_update_customer', + 'quickbooks_create_employee', + 'quickbooks_update_employee', + 'quickbooks_create_vendor', + 'quickbooks_update_vendor', + 'quickbooks_create_item', + 'quickbooks_update_item', + 'quickbooks_read_sales_transactions', + 'quickbooks_create_estimate', + 'quickbooks_update_estimate', + 'quickbooks_create_invoice', + 'quickbooks_update_invoice', + 'quickbooks_void_invoice', + 'quickbooks_create_sales_receipt', + 'quickbooks_update_sales_receipt', + 'quickbooks_create_customer_payment', + 'quickbooks_update_customer_payment', + 'quickbooks_void_customer_payment', + 'quickbooks_create_credit_memo', + 'quickbooks_update_credit_memo', + 'quickbooks_create_refund_receipt', + 'quickbooks_update_refund_receipt', + 'quickbooks_read_purchasing_transactions', + 'quickbooks_create_purchase_order', + 'quickbooks_update_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_update_bill', + 'quickbooks_create_bill_payment', + 'quickbooks_update_bill_payment', + 'quickbooks_create_vendor_credit', + 'quickbooks_update_vendor_credit', + 'quickbooks_create_purchase', + 'quickbooks_update_purchase', + 'quickbooks_read_accounting_transactions', + 'quickbooks_create_journal_entry', + 'quickbooks_update_journal_entry', + 'quickbooks_create_deposit', + 'quickbooks_update_deposit', + 'quickbooks_run_financial_report', + 'quickbooks_email_transaction', + 'quickbooks_download_transaction_pdf', + 'quickbooks_read_attachments', + 'quickbooks_add_attachment', + 'quickbooks_download_attachment', + ], + config: { + tool: (params) => { + const operation = String(params.operation) + if (!QUICKBOOKS_OPERATIONS.includes(operation as (typeof QUICKBOOKS_OPERATIONS)[number])) { + throw new Error(`Unknown QuickBooks operation: ${operation}`) + } + return operation + }, + params: (params) => { + const operation = String(params.operation) + const oauthCredentialValue = params.oauthCredential + + if (operation === EMAIL_TRANSACTION_OPERATION) { + return { + credential: oauthCredentialValue, + transactionType: params.documentTransactionType, + transactionId: optionalValue(params.documentTransactionId), + recipient: optionalValue(params.recipientOverride), + confirmSend: parseConfirmation(params.confirmSend, 'confirmSend'), + } + } + if (operation === DOWNLOAD_TRANSACTION_PDF_OPERATION) { + return { + credential: oauthCredentialValue, + transactionType: params.documentTransactionType, + transactionId: optionalValue(params.documentTransactionId), + fileName: optionalValue(params.documentFileName), + } + } + if (operation === READ_ATTACHMENTS_OPERATION) { + if (params.readMode === 'by_id') { + return { + credential: oauthCredentialValue, + readMode: 'by_id', + attachmentId: optionalValue(params.attachmentId), + } + } + return { + credential: oauthCredentialValue, + readMode: 'list', + targetType: params.attachmentTargetType, + targetId: optionalValue(params.attachmentTargetId), + startPosition: parsePaginationInteger(params.startPosition, 'startPosition', 1), + maxResults: parsePaginationInteger(params.maxResults, 'maxResults', 25), + } + } + if (operation === ADD_ATTACHMENT_OPERATION) { + const attachmentKind = params.attachmentKind + return { + credential: oauthCredentialValue, + attachmentKind, + targetType: params.attachmentTargetType, + targetId: optionalValue(params.attachmentTargetId), + file: + attachmentKind === 'file' + ? normalizeFileInput(params.attachmentFile, { single: true }) + : undefined, + fileName: + attachmentKind === 'file' ? optionalValue(params.attachmentFileName) : undefined, + contentType: + attachmentKind === 'file' ? optionalValue(params.attachmentContentType) : undefined, + description: + attachmentKind === 'file' ? optionalValue(params.attachmentDescription) : undefined, + note: attachmentKind === 'note' ? optionalValue(params.attachmentNote) : undefined, + } + } + if (operation === DOWNLOAD_ATTACHMENT_OPERATION) { + return { + credential: oauthCredentialValue, + attachmentId: optionalValue(params.attachmentId), + fileName: optionalValue(params.attachmentFileName), + } + } + + if (operation === MASTER_DATA_OPERATION) { + if (params.readMode === 'by_id') { + return { + credential: oauthCredentialValue, + recordType: params.recordType, + readMode: params.readMode, + recordId: optionalValue(params.recordId), + } + } + return { + credential: oauthCredentialValue, + recordType: params.recordType, + readMode: params.readMode, + activeStatus: params.readActiveStatus ?? 'default', + startPosition: parsePaginationInteger(params.startPosition, 'startPosition', 1), + maxResults: parsePaginationInteger(params.maxResults, 'maxResults', 25), + } + } + if (operation === SALES_READ_OPERATION) { + if (params.readMode === 'by_id') { + return { + credential: oauthCredentialValue, + transactionType: params.transactionType, + readMode: params.readMode, + transactionId: optionalValue(params.transactionId), + } + } + return { + credential: oauthCredentialValue, + transactionType: params.transactionType, + readMode: params.readMode, + startDate: optionalValue(params.readStartDate), + endDate: optionalValue(params.readEndDate), + customerId: optionalValue(params.readCustomerId), + startPosition: parsePaginationInteger(params.startPosition, 'startPosition', 1), + maxResults: parsePaginationInteger(params.maxResults, 'maxResults', 25), + } + } + if (operation === PURCHASING_READ_OPERATION) { + if (params.readMode === 'by_id') { + return { + credential: oauthCredentialValue, + transactionType: params.purchasingTransactionType, + readMode: params.readMode, + transactionId: optionalValue(params.transactionId), + } + } + return { + credential: oauthCredentialValue, + transactionType: params.purchasingTransactionType, + readMode: params.readMode, + startDate: optionalValue(params.readStartDate), + endDate: optionalValue(params.readEndDate), + vendorId: + params.purchasingTransactionType === 'purchase' + ? undefined + : optionalValue(params.readVendorId), + startPosition: parsePaginationInteger(params.startPosition, 'startPosition', 1), + maxResults: parsePaginationInteger(params.maxResults, 'maxResults', 25), + } + } + if (operation === ACCOUNTING_READ_OPERATION) { + if (params.readMode === 'by_id') { + return { + credential: oauthCredentialValue, + transactionType: params.accountingTransactionType, + readMode: params.readMode, + transactionId: optionalValue(params.transactionId), + } + } + return { + credential: oauthCredentialValue, + transactionType: params.accountingTransactionType, + readMode: params.readMode, + startDate: optionalValue(params.readStartDate), + endDate: optionalValue(params.readEndDate), + startPosition: parsePaginationInteger(params.startPosition, 'startPosition', 1), + maxResults: parsePaginationInteger(params.maxResults, 'maxResults', 25), + } + } + if (operation === REPORT_OPERATION) { + const reportType = params.reportType + return { + credential: oauthCredentialValue, + reportType, + startDate: reportSupports(reportType, 'startDate') + ? optionalValue(params.reportStartDate) + : undefined, + endDate: optionalValue(params.reportEndDate), + accountingMethod: reportSupports(reportType, 'accountingMethod') + ? (params.reportAccountingMethod ?? 'default') + : undefined, + summarizeBy: reportSupports(reportType, 'summarizeBy') + ? reportSummarizeValue(params, reportType) + : undefined, + customerId: reportSupports(reportType, 'customerId') + ? optionalValue(params.reportCustomerId) + : undefined, + vendorId: reportSupports(reportType, 'vendorId') + ? optionalValue(params.reportVendorId) + : undefined, + accountId: reportSupports(reportType, 'accountId') + ? optionalValue(params.reportAccountId) + : undefined, + itemId: reportSupports(reportType, 'itemId') + ? optionalValue(params.reportItemId) + : undefined, + classId: reportSupports(reportType, 'classId') + ? optionalValue(params.reportClassId) + : undefined, + departmentId: reportSupports(reportType, 'departmentId') + ? optionalValue(params.reportDepartmentId) + : undefined, + agingMethod: reportSupports(reportType, 'agingMethod') + ? (params.reportAgingMethod ?? 'default') + : undefined, + agingDays: reportSupports(reportType, 'agingPeriod') + ? parseOptionalPositiveInteger(params.reportAgingDays, 'agingDays') + : undefined, + transactionType: + reportType === 'transaction_list' && params.reportTransactionType !== 'default' + ? params.reportTransactionType + : undefined, + groupBy: + reportType === 'transaction_list' && params.reportGroupBy !== 'default' + ? params.reportGroupBy + : undefined, + accountsPayablePaid: + reportType === 'transaction_list' && params.reportAccountsPayablePaid !== 'default' + ? params.reportAccountsPayablePaid + : undefined, + accountsReceivablePaid: + reportType === 'transaction_list' && params.reportAccountsReceivablePaid !== 'default' + ? params.reportAccountsReceivablePaid + : undefined, + clearedStatus: + reportType === 'transaction_list' && params.reportClearedStatus !== 'default' + ? params.reportClearedStatus + : undefined, + documentNumber: + reportType === 'transaction_list' + ? optionalValue(params.reportDocumentNumber) + : undefined, + sourceAccountType: + reportType === 'transaction_list' && params.reportSourceAccountType !== 'default' + ? params.reportSourceAccountType + : undefined, + } + } + if (SALES_VOID_OPERATIONS.includes(operation as (typeof SALES_VOID_OPERATIONS)[number])) { + return { + credential: oauthCredentialValue, + transactionId: optionalValue(params.transactionId), + syncToken: optionalValue(params.syncToken), + confirmVoid: parseConfirmation(params.confirmVoid, 'confirmVoid'), + } + } + if ( + SALES_DOCUMENT_OPERATIONS.includes( + operation as (typeof SALES_DOCUMENT_OPERATIONS)[number] + ) + ) { + const isCreate = SALES_DOCUMENT_CREATE_OPERATIONS.includes( + operation as (typeof SALES_DOCUMENT_CREATE_OPERATIONS)[number] + ) + const isInvoice = + operation === 'quickbooks_create_invoice' || operation === 'quickbooks_update_invoice' + const isEstimate = + operation === 'quickbooks_create_estimate' || operation === 'quickbooks_update_estimate' + const isReceipt = + operation === 'quickbooks_create_sales_receipt' || + operation === 'quickbooks_update_sales_receipt' || + operation === 'quickbooks_create_refund_receipt' || + operation === 'quickbooks_update_refund_receipt' + return { + credential: oauthCredentialValue, + transactionId: isCreate ? undefined : optionalValue(params.transactionId), + syncToken: isCreate ? undefined : optionalValue(params.syncToken), + customerId: optionalValue(params.customerId), + lines: parseJsonArrayInput(params.lines, 'lines'), + transactionDate: optionalValue(params.transactionDate), + dueDate: isInvoice ? optionalValue(params.dueDate) : undefined, + expirationDate: isEstimate ? optionalValue(params.expirationDate) : undefined, + documentNumber: optionalValue(params.documentNumber), + privateNote: optionalValue(params.privateNote), + customerMemo: optionalValue(params.customerMemo), + paymentMethodId: isReceipt ? optionalValue(params.paymentMethodId) : undefined, + paymentReferenceNumber: isReceipt + ? optionalValue(params.paymentReferenceNumber) + : undefined, + depositAccountId: isReceipt ? optionalValue(params.depositAccountId) : undefined, + requestId: isCreate ? optionalValue(params.requestId) : undefined, + } + } + if (PAYMENT_OPERATIONS.includes(operation as (typeof PAYMENT_OPERATIONS)[number])) { + const isCreate = operation === 'quickbooks_create_customer_payment' + return { + credential: oauthCredentialValue, + paymentId: isCreate ? undefined : optionalValue(params.transactionId), + syncToken: isCreate ? undefined : optionalValue(params.syncToken), + customerId: optionalValue(params.customerId), + totalAmount: parseOptionalNumber(params.totalAmount, 'totalAmount'), + transactionDate: optionalValue(params.transactionDate), + privateNote: optionalValue(params.privateNote), + paymentReferenceNumber: optionalValue(params.paymentReferenceNumber), + paymentMethodId: optionalValue(params.paymentMethodId), + depositAccountId: optionalValue(params.depositAccountId), + invoiceAllocations: parseJsonArrayInput( + params.invoiceAllocations, + 'invoiceAllocations' + ), + unapplyOmittedInvoices: isCreate + ? undefined + : parseConfirmation(params.unapplyOmittedInvoices, 'unapplyOmittedInvoices'), + requestId: isCreate ? optionalValue(params.requestId) : undefined, + } + } + if ( + PURCHASING_MUTATION_OPERATIONS.includes( + operation as (typeof PURCHASING_MUTATION_OPERATIONS)[number] + ) + ) { + const isCreate = PURCHASING_CREATE_OPERATIONS.includes( + operation as (typeof PURCHASING_CREATE_OPERATIONS)[number] + ) + const isPurchaseOrder = + operation === 'quickbooks_create_purchase_order' || + operation === 'quickbooks_update_purchase_order' + const isBill = + operation === 'quickbooks_create_bill' || operation === 'quickbooks_update_bill' + const isBillPayment = + operation === 'quickbooks_create_bill_payment' || + operation === 'quickbooks_update_bill_payment' + const isVendorCredit = + operation === 'quickbooks_create_vendor_credit' || + operation === 'quickbooks_update_vendor_credit' + const isPurchase = + operation === 'quickbooks_create_purchase' || operation === 'quickbooks_update_purchase' + return { + credential: oauthCredentialValue, + purchaseOrderId: + !isCreate && isPurchaseOrder ? optionalValue(params.transactionId) : undefined, + billId: !isCreate && isBill ? optionalValue(params.transactionId) : undefined, + billPaymentId: + !isCreate && isBillPayment ? optionalValue(params.transactionId) : undefined, + vendorCreditId: + !isCreate && isVendorCredit ? optionalValue(params.transactionId) : undefined, + purchaseId: !isCreate && isPurchase ? optionalValue(params.transactionId) : undefined, + syncToken: isCreate ? undefined : optionalValue(params.syncToken), + vendorId: optionalValue(params.vendorId), + apAccountId: + isPurchaseOrder || isBill || isVendorCredit + ? optionalValue(params.apAccountId) + : undefined, + lines: + isCreate && (isPurchaseOrder || isBill || isVendorCredit || isPurchase) + ? parseJsonArrayInput(params.purchasingLines, 'lines') + : undefined, + totalAmount: + isCreate && isBillPayment + ? parseOptionalNumber(params.totalAmount, 'totalAmount') + : undefined, + paymentType: + isCreate && isBillPayment + ? optionalValue(params.billPaymentType) + : isCreate && isPurchase + ? optionalValue(params.purchasePaymentType) + : undefined, + paymentAccountId: + isCreate && (isBillPayment || isPurchase) + ? optionalValue(params.paymentAccountId) + : undefined, + billAllocations: + isCreate && isBillPayment + ? parseJsonArrayInput(params.billAllocations, 'billAllocations') + : undefined, + transactionDate: optionalValue(params.transactionDate), + dueDate: isBill ? optionalValue(params.dueDate) : undefined, + documentNumber: + isPurchaseOrder || isBill || isVendorCredit + ? optionalValue(params.documentNumber) + : undefined, + paymentReference: isPurchase ? optionalValue(params.paymentReference) : undefined, + privateNote: optionalValue(params.privateNote), + requestId: isCreate ? optionalValue(params.requestId) : undefined, + } + } + if ( + ACCOUNTING_MUTATION_OPERATIONS.includes( + operation as (typeof ACCOUNTING_MUTATION_OPERATIONS)[number] + ) + ) { + const isJournalEntry = + operation === 'quickbooks_create_journal_entry' || + operation === 'quickbooks_update_journal_entry' + const isCreate = ACCOUNTING_CREATE_OPERATIONS.includes( + operation as (typeof ACCOUNTING_CREATE_OPERATIONS)[number] + ) + return { + credential: oauthCredentialValue, + journalEntryId: + !isCreate && isJournalEntry ? optionalValue(params.transactionId) : undefined, + depositId: + !isCreate && !isJournalEntry ? optionalValue(params.transactionId) : undefined, + syncToken: isCreate ? undefined : optionalValue(params.syncToken), + lines: + isCreate && isJournalEntry + ? parseJsonArrayInput(params.journalLines, 'lines') + : isCreate + ? parseJsonArrayInput(params.depositLines, 'lines') + : undefined, + confirmPosting: isJournalEntry + ? parseConfirmation(params.confirmPosting, 'confirmPosting') + : undefined, + depositAccountId: !isJournalEntry ? optionalValue(params.depositAccountId) : undefined, + transactionDate: optionalValue(params.transactionDate), + documentNumber: isJournalEntry ? optionalValue(params.documentNumber) : undefined, + privateNote: optionalValue(params.privateNote), + requestId: isCreate ? optionalValue(params.requestId) : undefined, + } + } + if ( + operation === 'quickbooks_create_customer' || + operation === 'quickbooks_update_customer' + ) { + const isCreate = operation === 'quickbooks_create_customer' + return { + credential: oauthCredentialValue, + customerId: isCreate ? undefined : optionalValue(params.customerId), + syncToken: isCreate ? undefined : optionalValue(params.syncToken), + displayName: optionalValue(params.displayName), + companyName: optionalValue(params.companyName), + givenName: optionalValue(params.givenName), + familyName: optionalValue(params.familyName), + primaryEmail: optionalValue(params.primaryEmail), + primaryPhone: optionalValue(params.primaryPhone), + billingAddress: parseJsonObjectInput(params.billingAddress, 'billingAddress'), + shippingAddress: parseJsonObjectInput(params.shippingAddress, 'shippingAddress'), + taxable: parseTriStateBoolean(params.taxable, 'taxable'), + activeStatus: isCreate ? undefined : (params.activeStatus ?? 'unchanged'), + requestId: isCreate ? optionalValue(params.requestId) : undefined, + } + } + if ( + operation === 'quickbooks_create_employee' || + operation === 'quickbooks_update_employee' + ) { + const isCreate = operation === 'quickbooks_create_employee' + return { + credential: oauthCredentialValue, + employeeId: isCreate ? undefined : optionalValue(params.employeeId), + syncToken: isCreate ? undefined : optionalValue(params.syncToken), + displayName: optionalValue(params.displayName), + givenName: optionalValue(params.givenName), + familyName: optionalValue(params.familyName), + primaryEmail: optionalValue(params.primaryEmail), + primaryPhone: optionalValue(params.primaryPhone), + primaryAddress: parseJsonObjectInput(params.primaryAddress, 'primaryAddress'), + printOnCheckName: optionalValue(params.printOnCheckName), + billableTime: parseTriStateBoolean(params.billableTime, 'billableTime'), + activeStatus: isCreate ? undefined : (params.activeStatus ?? 'unchanged'), + requestId: isCreate ? optionalValue(params.requestId) : undefined, + } + } + if (operation === 'quickbooks_create_vendor' || operation === 'quickbooks_update_vendor') { + const isCreate = operation === 'quickbooks_create_vendor' + return { + credential: oauthCredentialValue, + vendorId: isCreate ? undefined : optionalValue(params.vendorId), + syncToken: isCreate ? undefined : optionalValue(params.syncToken), + displayName: optionalValue(params.displayName), + companyName: optionalValue(params.companyName), + givenName: optionalValue(params.givenName), + familyName: optionalValue(params.familyName), + primaryEmail: optionalValue(params.primaryEmail), + primaryPhone: optionalValue(params.primaryPhone), + billingAddress: parseJsonObjectInput(params.billingAddress, 'billingAddress'), + printOnCheckName: optionalValue(params.printOnCheckName), + accountNumber: optionalValue(params.accountNumber), + vendor1099: parseTriStateBoolean(params.vendor1099, 'vendor1099'), + activeStatus: isCreate ? undefined : (params.activeStatus ?? 'unchanged'), + requestId: isCreate ? optionalValue(params.requestId) : undefined, + } + } + if (operation === 'quickbooks_create_item' || operation === 'quickbooks_update_item') { + const isCreate = operation === 'quickbooks_create_item' + return { + credential: oauthCredentialValue, + itemId: isCreate ? undefined : optionalValue(params.itemId), + syncToken: isCreate ? undefined : optionalValue(params.syncToken), + name: optionalValue(params.name), + itemType: isCreate ? optionalValue(params.itemType) : undefined, + incomeAccountId: optionalValue(params.incomeAccountId), + description: optionalValue(params.description), + unitPrice: parseOptionalNumber(params.unitPrice, 'unitPrice'), + purchaseDescription: optionalValue(params.purchaseDescription), + purchaseCost: parseOptionalNumber(params.purchaseCost, 'purchaseCost'), + expenseAccountId: optionalValue(params.expenseAccountId), + taxable: parseTriStateBoolean(params.taxable, 'taxable'), + activeStatus: isCreate ? undefined : (params.activeStatus ?? 'unchanged'), + requestId: isCreate ? optionalValue(params.requestId) : undefined, + } + } + return { credential: oauthCredentialValue } + }, + }, + }, + inputs: { + operation: { + type: 'string', + description: 'QuickBooks operation to perform', + }, + oauthCredential: { + type: 'string', + description: 'OAuth credential bound to one QuickBooks company', + }, + recordType: { type: 'string', description: 'Master-data entity type' }, + readMode: { type: 'string', description: 'List or by-ID read mode' }, + recordId: { type: 'string', description: 'Master-data record ID' }, + transactionType: { + type: 'string', + description: 'Sales transaction entity type', + }, + purchasingTransactionType: { + type: 'string', + description: 'Purchasing transaction entity type', + }, + accountingTransactionType: { + type: 'string', + description: 'Accounting transaction entity type', + }, + reportType: { type: 'string', description: 'Financial report type' }, + reportStartDate: { type: 'string', description: 'Report start date' }, + reportEndDate: { type: 'string', description: 'Report end or as-of date' }, + reportAccountingMethod: { + type: 'string', + description: 'Cash or accrual report basis', + }, + reportSummarizeBy: { + type: 'string', + description: 'Report column summarization', + }, + reportCustomerSalesSummarizeBy: { + type: 'string', + description: 'Sales report column summarization', + }, + reportVendorExpenseSummarizeBy: { + type: 'string', + description: 'Vendor expense report column summarization', + }, + reportTimeSummarizeBy: { + type: 'string', + description: 'Time-based report column summarization', + }, + reportCustomerId: { + type: 'string', + description: 'Customer report filter ID', + }, + reportVendorId: { type: 'string', description: 'Vendor report filter ID' }, + reportAccountId: { + type: 'string', + description: 'Account report filter ID', + }, + reportItemId: { + type: 'string', + description: 'Product or service report filter ID', + }, + reportClassId: { type: 'string', description: 'Class report filter ID' }, + reportDepartmentId: { + type: 'string', + description: 'Department report filter ID', + }, + reportAgingMethod: { + type: 'string', + description: 'Aging report calculation date', + }, + reportAgingDays: { + type: 'number', + description: 'Days in each aging period', + }, + reportTransactionType: { + type: 'string', + description: 'Transaction List type filter', + }, + reportGroupBy: { type: 'string', description: 'Transaction List grouping' }, + reportAccountsPayablePaid: { + type: 'string', + description: 'Transaction List A/P status', + }, + reportAccountsReceivablePaid: { + type: 'string', + description: 'Transaction List A/R status', + }, + reportClearedStatus: { + type: 'string', + description: 'Transaction List cleared status', + }, + reportDocumentNumber: { + type: 'string', + description: 'Transaction List document number', + }, + reportSourceAccountType: { + type: 'string', + description: 'Transaction List source account type', + }, + readActiveStatus: { + type: 'string', + description: 'Master-data active-status filter', + }, + readStartDate: { + type: 'string', + description: 'Transaction list start date', + }, + readEndDate: { type: 'string', description: 'Transaction list end date' }, + readCustomerId: { + type: 'string', + description: 'Sales list customer filter', + }, + readVendorId: { + type: 'string', + description: 'Purchasing list vendor filter', + }, + transactionId: { type: 'string', description: 'QuickBooks transaction ID' }, + startPosition: { + type: 'number', + description: 'One-based position of the first list item to request', + }, + maxResults: { + type: 'number', + description: 'Number of list items to request, from 1 through 100', + }, + customerId: { type: 'string', description: 'QuickBooks customer ID' }, + vendorId: { type: 'string', description: 'QuickBooks vendor ID' }, + itemId: { type: 'string', description: 'Item ID for an update' }, + employeeId: { type: 'string', description: 'Employee ID for an update' }, + syncToken: { type: 'string', description: 'Current entity sync token' }, + displayName: { + type: 'string', + description: 'Customer, employee, or vendor display name', + }, + companyName: { + type: 'string', + description: 'Customer or vendor company name', + }, + givenName: { + type: 'string', + description: 'Customer, employee, or vendor given name', + }, + familyName: { + type: 'string', + description: 'Customer, employee, or vendor family name', + }, + primaryEmail: { type: 'string', description: 'Primary email address' }, + primaryPhone: { type: 'string', description: 'Primary phone number' }, + billingAddress: { + type: 'json', + description: 'Allowlisted billing address object', + }, + shippingAddress: { + type: 'json', + description: 'Allowlisted shipping address object', + }, + primaryAddress: { + type: 'json', + description: 'Allowlisted employee address object', + }, + taxable: { type: 'boolean', description: 'Optional taxable value' }, + printOnCheckName: { + type: 'string', + description: 'Employee or vendor name printed on checks', + }, + billableTime: { + type: 'boolean', + description: 'Optional employee billable-time value', + }, + accountNumber: { type: 'string', description: 'Vendor account number' }, + vendor1099: { type: 'boolean', description: 'Optional vendor 1099 value' }, + name: { type: 'string', description: 'Item name' }, + itemType: { + type: 'string', + description: 'Service or Non-inventory item type', + }, + incomeAccountId: { type: 'string', description: 'Item income account ID' }, + description: { type: 'string', description: 'Item sales description' }, + unitPrice: { type: 'number', description: 'Item sales price' }, + purchaseDescription: { + type: 'string', + description: 'Item purchase description', + }, + purchaseCost: { type: 'number', description: 'Item purchase cost' }, + expenseAccountId: { + type: 'string', + description: 'Item expense account ID', + }, + activeStatus: { + type: 'string', + description: 'Entity active-status change', + }, + lines: { + type: 'json', + description: 'Bounded item and description sales lines', + }, + purchasingLines: { + type: 'json', + description: + 'Bounded purchasing expense lines; Create Bill lines may include paired Purchase Order and line IDs', + }, + journalLines: { + type: 'json', + description: 'Bounded balanced journal-entry lines', + }, + depositLines: { + type: 'json', + description: 'Bounded account-based deposit lines', + }, + totalAmount: { + type: 'number', + description: 'Customer or Bill payment total', + }, + apAccountId: { + type: 'string', + description: 'QuickBooks accounts-payable account ID', + }, + billPaymentType: { + type: 'string', + description: 'Check or credit-card BillPayment type', + }, + purchasePaymentType: { + type: 'string', + description: 'Cash, check, or credit-card Purchase type', + }, + paymentAccountId: { + type: 'string', + description: 'QuickBooks payment account ID', + }, + billAllocations: { + type: 'json', + description: 'Bounded BillPayment allocations to Bills', + }, + transactionDate: { + type: 'string', + description: 'Transaction date in YYYY-MM-DD format', + }, + dueDate: { + type: 'string', + description: 'Invoice due date in YYYY-MM-DD format', + }, + expirationDate: { + type: 'string', + description: 'Estimate expiration date in YYYY-MM-DD format', + }, + documentNumber: { + type: 'string', + description: 'QuickBooks document number', + }, + privateNote: { type: 'string', description: 'Internal transaction note' }, + customerMemo: { + type: 'string', + description: 'Customer-facing transaction memo', + }, + paymentMethodId: { + type: 'string', + description: 'QuickBooks payment method ID', + }, + paymentReferenceNumber: { + type: 'string', + description: 'Payment reference number', + }, + paymentReference: { + type: 'string', + description: 'Purchase payment reference number', + }, + depositAccountId: { + type: 'string', + description: 'QuickBooks deposit account ID', + }, + invoiceAllocations: { + type: 'json', + description: 'Bounded customer-payment allocations to invoices', + }, + unapplyOmittedInvoices: { + type: 'boolean', + description: + 'Replace payment allocations outright from a required non-empty list, unapplying every invoice not listed', + }, + requestId: { + type: 'string', + description: 'Optional Intuit idempotency request ID', + }, + confirmVoid: { + type: 'boolean', + description: 'Explicit confirmation for a void operation', + }, + confirmPosting: { + type: 'boolean', + description: 'Explicit confirmation before posting a journal entry', + }, + documentTransactionType: { + type: 'string', + description: 'Supported transaction type for email or PDF download', + }, + documentTransactionId: { + type: 'string', + description: 'QuickBooks transaction ID for email or PDF download', + }, + confirmSend: { + type: 'boolean', + description: 'Explicit confirmation before sending email', + }, + recipientOverride: { + type: 'string', + description: 'Optional single email recipient override', + }, + documentFileName: { + type: 'string', + description: 'Optional PDF filename override', + }, + attachmentTargetType: { + type: 'string', + description: 'QuickBooks attachment target type', + }, + attachmentTargetId: { + type: 'string', + description: 'QuickBooks attachment target ID', + }, + attachmentId: { type: 'string', description: 'QuickBooks attachment ID' }, + attachmentKind: { + type: 'string', + description: 'File or Note attachment kind', + }, + attachmentFile: { + type: 'file', + description: 'Single file to attach to QuickBooks', + }, + attachmentNote: { + type: 'string', + description: 'Note text to attach to QuickBooks', + }, + attachmentFileName: { + type: 'string', + description: 'Optional attachment filename override', + }, + attachmentContentType: { + type: 'string', + description: 'Optional compatible MIME type', + }, + attachmentDescription: { + type: 'string', + description: 'Optional file attachment description', + }, + }, + outputs: { + company: { + type: 'json', + description: + 'CompanyInfo with Id, CompanyName, LegalName, addresses, contact details, company settings, and MetaData', + condition: { field: 'operation', value: 'quickbooks_get_company_info' }, + }, + recordType: { + type: 'string', + description: 'Master-data record type returned by the read', + condition: { field: 'operation', value: MASTER_DATA_OPERATION }, + }, + transactionType: { + type: 'string', + description: 'Sales, purchasing, or accounting transaction type returned by the read', + condition: { + field: 'operation', + value: [ + SALES_READ_OPERATION, + PURCHASING_READ_OPERATION, + ACCOUNTING_READ_OPERATION, + EMAIL_TRANSACTION_OPERATION, + DOWNLOAD_TRANSACTION_PDF_OPERATION, + ], + }, + }, + transactionId: { + type: 'string', + description: 'QuickBooks transaction ID used by the document operation', + condition: { + field: 'operation', + value: [EMAIL_TRANSACTION_OPERATION, DOWNLOAD_TRANSACTION_PDF_OPERATION], + }, + }, + reportType: { + type: 'string', + description: 'Financial report type that was run', + condition: { field: 'operation', value: REPORT_OPERATION }, + }, + header: { + type: 'json', + description: 'Native QuickBooks report header, periods, basis, filters, and options', + condition: { field: 'operation', value: REPORT_OPERATION }, + }, + columns: { + type: 'json', + description: 'Native QuickBooks report column definitions', + condition: { field: 'operation', value: REPORT_OPERATION }, + }, + rows: { + type: 'json', + description: 'Native hierarchical QuickBooks report rows and summaries', + condition: { field: 'operation', value: REPORT_OPERATION }, + }, + item: { + type: 'json', + description: + 'Single master-data, transaction, or attachment record with native QuickBooks fields', + condition: { + field: 'operation', + value: [ + MASTER_DATA_OPERATION, + SALES_READ_OPERATION, + PURCHASING_READ_OPERATION, + ACCOUNTING_READ_OPERATION, + READ_ATTACHMENTS_OPERATION, + ], + and: { field: 'readMode', value: 'by_id' }, + }, + }, + items: { + type: 'array', + description: 'Master-data, transaction, or attachment objects with native QuickBooks fields', + condition: LIST_OUTPUT_CONDITION, + }, + startPosition: { + type: 'number', + description: 'One-based position of the first returned list item', + condition: LIST_OUTPUT_CONDITION, + }, + maxResults: { + type: 'number', + description: 'Actual number of items reported for the list response', + condition: LIST_OUTPUT_CONDITION, + }, + nextStartPosition: { + type: 'number', + description: 'Position to pass into an explicit next-page request', + condition: LIST_OUTPUT_CONDITION, + }, + hasMore: { + type: 'boolean', + description: 'Conservative indication that another list page may exist', + condition: LIST_OUTPUT_CONDITION, + }, + record: { + type: 'json', + description: + 'Created, updated, voided, or emailed record with native QuickBooks fields when QuickBooks returns one', + condition: { + field: 'operation', + value: [...MUTATION_OPERATIONS, EMAIL_TRANSACTION_OPERATION], + }, + }, + recordId: { + type: 'string', + description: 'ID of the created, updated, or voided QuickBooks record', + condition: { field: 'operation', value: [...MUTATION_OPERATIONS] }, + }, + syncToken: { + type: 'string', + description: 'Native QuickBooks SyncToken returned by the mutation', + condition: { field: 'operation', value: [...MUTATION_OPERATIONS] }, + }, + recordVersion: { + type: 'string', + description: + 'QuickBooks record version returned by a mutation or by-ID read; connect this value to the Sync Token input', + condition: { + field: 'operation', + value: [ + ...MUTATION_OPERATIONS, + MASTER_DATA_OPERATION, + SALES_READ_OPERATION, + PURCHASING_READ_OPERATION, + ACCOUNTING_READ_OPERATION, + ], + }, + }, + voided: { + type: 'boolean', + description: 'True when QuickBooks successfully voided the transaction', + condition: { field: 'operation', value: [...SALES_VOID_OPERATIONS] }, + }, + linkingRequested: { + type: 'boolean', + description: 'Whether Create Bill requested any Purchase Order line links', + condition: { field: 'operation', value: 'quickbooks_create_bill' }, + }, + linkingSucceeded: { + type: 'boolean', + description: + 'Whether QuickBooks returned every requested Purchase Order line link; null when no links were requested', + condition: { field: 'operation', value: 'quickbooks_create_bill' }, + }, + linkedLines: { + type: 'array', + description: + 'Confirmed Purchase Order links as [{purchaseOrderId, purchaseOrderLineId, billLineId}]', + condition: { field: 'operation', value: 'quickbooks_create_bill' }, + }, + missingLinks: { + type: 'array', + description: + 'Requested links omitted by QuickBooks as [{purchaseOrderId, purchaseOrderLineId}]', + condition: { field: 'operation', value: 'quickbooks_create_bill' }, + }, + linkingWarning: { + type: 'string', + description: 'Warning that QuickBooks created the Bill without every requested link', + condition: { field: 'operation', value: 'quickbooks_create_bill' }, + }, + sent: { + type: 'boolean', + description: 'Whether QuickBooks accepted the transaction email request', + condition: { field: 'operation', value: EMAIL_TRANSACTION_OPERATION }, + }, + attachment: { + type: 'json', + description: 'Created native QuickBooks attachment metadata', + condition: { field: 'operation', value: ADD_ATTACHMENT_OPERATION }, + }, + attachmentId: { + type: 'string', + description: 'QuickBooks attachment ID', + condition: { + field: 'operation', + value: [ADD_ATTACHMENT_OPERATION, DOWNLOAD_ATTACHMENT_OPERATION], + }, + }, + attachmentKind: { + type: 'string', + description: 'Created QuickBooks attachment kind', + condition: { field: 'operation', value: ADD_ATTACHMENT_OPERATION }, + }, + targetType: { + type: 'string', + description: 'QuickBooks attachment target type', + condition: { field: 'operation', value: ADD_ATTACHMENT_OPERATION }, + }, + targetId: { + type: 'string', + description: 'QuickBooks attachment target ID', + condition: { field: 'operation', value: ADD_ATTACHMENT_OPERATION }, + }, + file: { + type: 'file', + description: 'Downloaded QuickBooks file stored in execution files', + condition: { + field: 'operation', + value: [DOWNLOAD_TRANSACTION_PDF_OPERATION, DOWNLOAD_ATTACHMENT_OPERATION], + }, + }, + fileName: { + type: 'string', + description: 'Downloaded file name', + condition: { + field: 'operation', + value: [DOWNLOAD_TRANSACTION_PDF_OPERATION, DOWNLOAD_ATTACHMENT_OPERATION], + }, + }, + mimeType: { + type: 'string', + description: 'Downloaded file MIME type', + condition: { + field: 'operation', + value: [DOWNLOAD_TRANSACTION_PDF_OPERATION, DOWNLOAD_ATTACHMENT_OPERATION], + }, + }, + size: { + type: 'number', + description: 'Downloaded file size in bytes', + condition: { + field: 'operation', + value: [DOWNLOAD_TRANSACTION_PDF_OPERATION, DOWNLOAD_ATTACHMENT_OPERATION], + }, + }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + condition: { + field: 'operation', + value: [DOWNLOAD_TRANSACTION_PDF_OPERATION, DOWNLOAD_ATTACHMENT_OPERATION], + not: true, + }, + }, + }, +} + +export const QuickBooksBlockMeta = { + tags: ['payments', 'automation', 'data-analytics'], + url: 'https://quickbooks.intuit.com', + templates: [ + { + icon: QuickBooksIcon, + title: 'QuickBooks customer onboarding', + prompt: + 'Build a workflow that receives an approved customer profile, creates the QuickBooks customer, and stores its ID and record version in a Sim table.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'customers', 'onboarding'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks vendor onboarding', + prompt: + 'Create a workflow that receives approved vendor identity, contact, address, and 1099 details, creates the QuickBooks vendor, and stores the returned ID and record version.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'vendors', 'procurement'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks catalogue maintenance', + prompt: + 'Build a workflow that reads filtered QuickBooks master data, creates approved non-payroll employees or Service and Non-inventory items, and safely updates exposed fields while retaining returned IDs and record versions.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'catalogue', 'operations'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks monthly and year-end reporting', + prompt: + 'Build a scheduled workflow that runs monthly and year-end Balance Sheet, Profit and Loss, Trial Balance, and Cash Flow reports, preserves their native rows and summaries, and stores review results in a Sim table.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'reporting', 'financial-close'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks estimate preparation', + prompt: + 'Build a workflow that receives an approved customer quote and line items, creates a QuickBooks estimate, and stores its ID and record version for controlled revisions.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'estimates', 'sales'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks invoice creation and delivery', + prompt: + 'Create a workflow that validates approved customer and item IDs, creates a QuickBooks invoice, stores its ID and record version, then—after explicit approval—emails it or downloads its PDF for controlled delivery and archiving.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'invoices', 'receivables'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks partial-payment application', + prompt: + 'Build a workflow that records a customer payment, applies bounded amounts to approved QuickBooks invoice IDs, and reports any unapplied remainder.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'payments', 'receivables'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks journal adjustments and deposits', + prompt: + 'Build a controlled workflow that posts an explicitly approved, balanced QuickBooks journal entry or records a bounded deposit, then runs cash- and accrual-basis reports to support the accounting review.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'accounting', 'journal-entries'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks receivables and payables aging', + prompt: + 'Create a scheduled workflow that runs A/R and A/P aging summaries and details with approved aging controls, optionally filters by customer or vendor, and flags balances requiring accountant review without changing records.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'reporting', 'aging'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks bill entry and payment', + prompt: + 'Build a controlled workflow that reads an approved Purchase Order by ID, captures its Line IDs, creates a QuickBooks Bill with explicit PO-line mappings, checks linkingSucceeded and missingLinks, attaches one approved receipt or audit note, and records a separately approved payment only after reviewing the created Bill.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'payments', 'payables'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks customer, vendor, and expense analysis', + prompt: + 'Create a workflow that runs customer and vendor balance reports, expenses by vendor, or the Transaction List with bounded date, entity, paid-status, cleared-status, document-number, grouping, and source-account filters for accountant review.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['finance', 'reporting', 'analysis'], + }, + ], + skills: [ + { + name: 'onboard-quickbooks-customers', + description: 'Create approved QuickBooks customers and retain their IDs and record versions.', + content: + '# Onboard QuickBooks Customers\n\n## Steps\n1. Validate the approved customer identity and contact details.\n2. Use Create Customer with a unique display name.\n3. Store the returned `recordId` and `recordVersion`; connect `recordVersion` to the Sync Token input for later updates.\n\n## Output\nReturn the created customer, ID, and record version. Report duplicate-name faults for human review.', + }, + { + name: 'onboard-quickbooks-vendors', + description: 'Create approved QuickBooks vendors with bounded contact and 1099 fields.', + content: + '# Onboard QuickBooks Vendors\n\n## Steps\n1. Validate the approved vendor identity, contact, address, and optional 1099 status.\n2. Use Create Vendor.\n3. Store the returned `recordId` and `recordVersion`; connect `recordVersion` to the Sync Token input for later updates.\n\n## Output\nReturn the created vendor and identifiers. Do not claim to merge vendors or administer tax identifiers.', + }, + { + name: 'maintain-products-and-services', + description: 'Create supported items or update exposed item fields without changing types.', + content: + '# Maintain QuickBooks Products and Services\n\n## Steps\n1. Read Account master data to obtain approved account IDs.\n2. Create a Service or Non-inventory Item, or update exposed basic fields without changing the existing item Type.\n3. Store the latest item ID and record version; connect the record version to the Sync Token input for updates.\n\n## Output\nReturn the native Item record and record version. Do not claim to create Inventory, Category, or Group items or manage their specialized fields.', + }, + { + name: 'record-quickbooks-accounting-adjustments', + description: 'Post approved balanced journal entries, record deposits, and review transfers.', + content: + '# Record QuickBooks Accounting Adjustments\n\n## Steps\n1. Read the approved account IDs from Master Data.\n2. For a journal entry, verify that positive debit and credit lines balance and require explicit posting confirmation; for a deposit, verify the destination and source account IDs.\n3. Store the returned `recordId` and `recordVersion`; connect `recordVersion` to the Sync Token input for later updates, and use Read Accounting Transactions to review journal entries, deposits, or read-only transfers.\n4. Run an approved Trial Balance or financial statement on cash or accrual basis when an accountant requests post-adjustment review.\n\n## Output\nReturn the native accounting transaction and identifiers plus the native report hierarchy when requested. Do not claim to create transfers, replace transaction lines, or administer currencies.', + }, + { + name: 'prepare-quickbooks-estimates', + description: 'Create and revise bounded QuickBooks estimates from approved quote details.', + content: + '# Prepare QuickBooks Estimates\n\n## Steps\n1. Validate the customer, item IDs, amounts, and dates.\n2. Use Create Estimate with bounded item or description lines.\n3. For a revision, use the estimate ID and latest `recordVersion` as the Update Estimate Sync Token input.\n\n## Output\nReturn the native Estimate, ID, and latest record version. Do not claim to email or accept the estimate.', + }, + { + name: 'create-quickbooks-invoices', + description: + 'Create approved QuickBooks invoices and explicitly deliver or archive their documents.', + content: + '# Create and Deliver QuickBooks Invoices\n\n## Steps\n1. Validate the approved customer, item IDs, positive amounts, and optional dates.\n2. Use Create Invoice with at least one bounded line.\n3. Store the returned `recordId` and `recordVersion`; connect `recordVersion` to the Sync Token input for later updates.\n4. Only after explicit approval, use Email Transaction for one recipient or Download Transaction PDF for controlled archiving.\n5. Use Add Attachment for one approved receipt or audit note when needed, and Read Attachments to verify the metadata.\n\n## Output\nReturn the native Invoice and identifiers plus any sent status, downloaded file, or attachment ID. Do not claim bulk email, automatic resend, attachment deletion, or automatic payment collection.', + }, + { + name: 'record-quickbooks-payables', + description: + 'Create standalone or PO-linked bills and record bounded payments to approved Bill IDs.', + content: + '# Record QuickBooks Payables\n\n## Steps\n1. Validate the vendor, expense lines, and optional A/P account.\n2. For PO-linked billing, use Read Purchasing Transactions by ID and copy each approved Purchase Order `Line[].Id` into the matching Create Bill line with its PO ID.\n3. Use Create Bill, store its ID and record version, and inspect `linkingSucceeded` and `missingLinks`; QuickBooks may create the Bill while omitting an invalid or unavailable link.\n4. When payment is separately approved, use Create Bill Payment with bounded Bill allocations whose amounts equal the payment total.\n5. Run A/P Aging Summary or Detail with supported vendor, department, date, and aging controls for accountant review.\n\n## Output\nAlways return the created Bill ID, record version, and linkage result. Preserve the native aging report when requested. Never imply that a missing link prevented Bill creation, and never create a payment implicitly.', + }, + { + name: 'analyze-quickbooks-financial-reports', + description: + 'Run verified financial, balance, aging, sales, and expense reports with supported filters.', + content: + '# Analyze QuickBooks Financial Reports\n\n## Steps\n1. Choose a verified report and an accountant-approved date or as-of period.\n2. Use Read Master Data to discover customer, vendor, account, item, class, or department IDs required by supported filters.\n3. Run Financial Report with only the controls shown for that report; compare cash and accrual basis or time summaries when requested.\n4. Preserve the native Header, Columns, nested Rows, and summaries for review.\n\n## Output\nReturn the report hierarchy and applied report context. Do not claim to export spreadsheets, email reports, customize columns, schedule delivery in QuickBooks, or mutate accounting records from a report.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/sqs.test.ts b/apps/sim/blocks/blocks/sqs.test.ts new file mode 100644 index 00000000000..c04ac1147d6 --- /dev/null +++ b/apps/sim/blocks/blocks/sqs.test.ts @@ -0,0 +1,62 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { SQSBlock } from '@/blocks/blocks/sqs' + +const mapParams = SQSBlock.tools.config?.params +if (!mapParams) { + throw new Error('SQS block must define tools.config.params') +} + +const CONNECTION = { + region: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'secret', +} + +const QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/test-queue' + +/** Fields whose Wand output the prompt and the contract both describe as an array. */ +const BATCH_ENTRY_FIELDS = ['sendEntries', 'deleteEntries', 'visibilityEntries'] as const + +describe('SQS block integer parsing', () => { + it('rejects a fractional value instead of truncating it', () => { + expect(() => + mapParams({ ...CONNECTION, operation: 'send', queueUrl: QUEUE_URL, delaySeconds: '1.5' }) + ).toThrow('delaySeconds must be a whole number') + }) + + it('rejects a value with a trailing suffix instead of forwarding the digits', () => { + expect(() => + mapParams({ + ...CONNECTION, + operation: 'receive_message', + queueUrl: QUEUE_URL, + maxNumberOfMessages: '10abc', + }) + ).toThrow('maxNumberOfMessages must be a whole number') + }) + + it('forwards a whole number unchanged', () => { + expect( + mapParams({ ...CONNECTION, operation: 'send', queueUrl: QUEUE_URL, delaySeconds: '30' }) + ).toMatchObject({ delaySeconds: 30 }) + }) + + it('treats a blank or whitespace-only field as unset', () => { + for (const delaySeconds of ['', ' ']) { + expect( + mapParams({ ...CONNECTION, operation: 'send', queueUrl: QUEUE_URL, delaySeconds }) + ).not.toHaveProperty('delaySeconds') + } + }) +}) + +describe('SQS block wand generation types', () => { + it.each(BATCH_ENTRY_FIELDS)('generates a JSON array for %s', (fieldId) => { + const subBlock = SQSBlock.subBlocks.find((candidate) => candidate.id === fieldId) + + expect(subBlock?.wandConfig?.generationType).toBe('json-array') + }) +}) diff --git a/apps/sim/blocks/blocks/sqs.ts b/apps/sim/blocks/blocks/sqs.ts index 79795475d7e..45f6119f5af 100644 --- a/apps/sim/blocks/blocks/sqs.ts +++ b/apps/sim/blocks/blocks/sqs.ts @@ -1,20 +1,22 @@ import { getErrorMessage } from '@sim/utils/errors' import { SQSIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' -import { IntegrationType } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' import type { SqsResponse } from '@/tools/sqs/types' export const SQSBlock: BlockConfig = { type: 'sqs', name: 'Amazon SQS', description: 'Connect to Amazon SQS', - longDescription: 'Integrate Amazon SQS into the workflow. Can send messages to SQS queues.', + longDescription: + 'Integrate Amazon SQS into the workflow. Send and receive messages one at a time or in batches of ten, delete messages, extend visibility timeouts, manage queues along with their attributes and tags, and redrive messages out of a dead-letter queue.', docsLink: 'https://docs.sim.ai/integrations/sqs', category: 'tools', integrationType: IntegrationType.DevOps, bgColor: 'linear-gradient(45deg, #2E27AD 0%, #527FFF 100%)', iconColor: '#527FFF', icon: SQSIcon, + authMode: AuthMode.ApiKey, canvasPresentation: { defaultTitle: 'Amazon SQS', sentences: { @@ -23,6 +25,74 @@ export const SQSBlock: BlockConfig = { { text: 'Send', field: 'data', core: true }, { text: 'to queue', field: 'queueUrl', core: true }, ], + send_message_batch: [ + { text: 'Send', field: 'sendEntries', core: true }, + { text: 'to queue', field: 'queueUrl', core: true }, + ], + receive_message: [ + { text: 'Receive messages from queue', field: 'queueUrl', core: true }, + { text: ', up to', field: 'maxNumberOfMessages' }, + ], + delete_message: [ + { text: 'Delete', field: 'receiptHandle', core: true }, + { text: 'from queue', field: 'queueUrl', core: true }, + ], + delete_message_batch: [ + { text: 'Delete a batch of received messages from queue', field: 'queueUrl', core: true }, + ], + change_message_visibility: [ + { text: 'Hide', field: 'receiptHandle', core: true }, + { text: 'for', field: 'visibilityTimeout', core: true, after: 'seconds' }, + { text: ', on queue', field: 'queueUrl' }, + ], + change_message_visibility_batch: [ + { + text: 'Change the visibility of a batch of received messages on queue', + field: 'queueUrl', + core: true, + }, + ], + list_queues: [ + 'List queues', + { text: ', named starting with', field: 'queueNamePrefix' }, + { text: ', up to', field: 'maxResults' }, + ], + get_queue_url: [{ text: 'Look up the URL of queue', field: 'queueName', core: true }], + get_queue_attributes: [ + { text: 'Read the attributes of queue', field: 'queueUrl', core: true }, + ], + set_queue_attributes: [ + { text: 'Update the attributes of queue', field: 'queueUrl', core: true }, + ], + create_queue: [{ text: 'Create queue', field: 'queueName', core: true }], + delete_queue: [{ text: 'Delete queue', field: 'queueUrl', core: true }], + purge_queue: [{ text: 'Delete every message in queue', field: 'queueUrl', core: true }], + list_dead_letter_source_queues: [ + { + text: 'List the queues that redrive to dead-letter queue', + field: 'queueUrl', + core: true, + }, + ], + list_queue_tags: [{ text: 'List the tags on queue', field: 'queueUrl', core: true }], + tag_queue: [ + { text: 'Tag queue', field: 'queueUrl', core: true }, + { text: 'with', field: 'queueTags', core: true }, + ], + untag_queue: [ + { text: 'Remove', field: 'tagKeys', core: true }, + { text: 'from queue', field: 'queueUrl', core: true }, + ], + start_message_move_task: [ + { text: 'Redrive the messages held in', field: 'sourceArn', core: true }, + { text: ', delivering them to', field: 'destinationArn' }, + ], + list_message_move_tasks: [ + { text: 'List the message move tasks for', field: 'sourceArn', core: true }, + ], + cancel_message_move_task: [ + { text: 'Cancel message move task', field: 'taskHandle', core: true }, + ], }, }, }, @@ -31,7 +101,29 @@ export const SQSBlock: BlockConfig = { id: 'operation', title: 'Operation', type: 'dropdown', - options: [{ label: 'Send Message', id: 'send' }], + options: [ + { label: 'Send Message', id: 'send' }, + { label: 'Send Message Batch', id: 'send_message_batch' }, + { label: 'Receive Message', id: 'receive_message' }, + { label: 'Delete Message', id: 'delete_message' }, + { label: 'Delete Message Batch', id: 'delete_message_batch' }, + { label: 'Change Message Visibility', id: 'change_message_visibility' }, + { label: 'Change Message Visibility Batch', id: 'change_message_visibility_batch' }, + { label: 'List Queues', id: 'list_queues' }, + { label: 'Get Queue URL', id: 'get_queue_url' }, + { label: 'Get Queue Attributes', id: 'get_queue_attributes' }, + { label: 'Set Queue Attributes', id: 'set_queue_attributes' }, + { label: 'Create Queue', id: 'create_queue' }, + { label: 'Delete Queue', id: 'delete_queue' }, + { label: 'Purge Queue', id: 'purge_queue' }, + { label: 'List Dead-Letter Source Queues', id: 'list_dead_letter_source_queues' }, + { label: 'List Queue Tags', id: 'list_queue_tags' }, + { label: 'Tag Queue', id: 'tag_queue' }, + { label: 'Untag Queue', id: 'untag_queue' }, + { label: 'Start Message Move Task', id: 'start_message_move_task' }, + { label: 'List Message Move Tasks', id: 'list_message_move_tasks' }, + { label: 'Cancel Message Move Task', id: 'cancel_message_move_task' }, + ], value: () => 'send', }, { @@ -62,12 +154,76 @@ export const SQSBlock: BlockConfig = { title: 'Queue URL', type: 'short-input', placeholder: 'https://sqs.us-east-1.amazonaws.com/123456789012/my-queue', - required: true, + condition: { + field: 'operation', + value: [ + 'send', + 'send_message_batch', + 'receive_message', + 'delete_message', + 'delete_message_batch', + 'change_message_visibility', + 'change_message_visibility_batch', + 'get_queue_attributes', + 'set_queue_attributes', + 'delete_queue', + 'purge_queue', + 'list_dead_letter_source_queues', + 'list_queue_tags', + 'tag_queue', + 'untag_queue', + ], + }, + required: { + field: 'operation', + value: [ + 'send', + 'send_message_batch', + 'receive_message', + 'delete_message', + 'delete_message_batch', + 'change_message_visibility', + 'change_message_visibility_batch', + 'get_queue_attributes', + 'set_queue_attributes', + 'delete_queue', + 'purge_queue', + 'list_dead_letter_source_queues', + 'list_queue_tags', + 'tag_queue', + 'untag_queue', + ], + }, + }, + { + id: 'queueName', + title: 'Queue Name', + type: 'short-input', + placeholder: 'my-queue (a FIFO queue name ends in .fifo)', + condition: { field: 'operation', value: ['create_queue', 'get_queue_url'] }, + required: { field: 'operation', value: ['create_queue', 'get_queue_url'] }, + }, + { + id: 'queueOwnerAwsAccountId', + title: 'Queue Owner AWS Account ID', + type: 'short-input', + placeholder: '123456789012', + condition: { field: 'operation', value: 'get_queue_url' }, + required: false, + mode: 'advanced', + }, + { + id: 'data', + title: 'Data (JSON)', + canvasNoun: 'a message body', + type: 'code', + placeholder: '{\n "name": "John Doe",\n "email": "john@example.com",\n "active": true\n}', + condition: { field: 'operation', value: 'send' }, + required: { field: 'operation', value: 'send' }, }, - // Data field for send message operation { id: 'messageGroupId', - title: 'Message Group ID (optional)', + title: 'Message Group ID', type: 'short-input', placeholder: '5FAB0F0B-30C6-4427-9407-5634F4A3984A', condition: { field: 'operation', value: 'send' }, @@ -75,39 +231,381 @@ export const SQSBlock: BlockConfig = { }, { id: 'messageDeduplicationId', - title: 'Message Deduplication ID (optional)', + title: 'Message Deduplication ID', type: 'short-input', placeholder: '5FAB0F0B-30C6-4427-9407-5634F4A3984A', condition: { field: 'operation', value: 'send' }, required: false, }, { - id: 'data', - title: 'Data (JSON)', - canvasNoun: 'a message body', + id: 'delaySeconds', + title: 'Delay Seconds', + type: 'short-input', + placeholder: '0-900', + condition: { field: 'operation', value: 'send' }, + required: false, + mode: 'advanced', + }, + { + id: 'messageAttributes', + title: 'Message Attributes', type: 'code', - placeholder: '{\n "name": "John Doe",\n "email": "john@example.com",\n "active": true\n}', + placeholder: '{\n "priority": { "dataType": "Number", "stringValue": "1" }\n}', condition: { field: 'operation', value: 'send' }, - required: true, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an SQS message attribute map as JSON. Each key is the attribute name and each value is an object with "dataType" (String or Number, optionally with a custom label such as Number.float) and "stringValue". Binary attributes are not supported. Return ONLY the JSON object.', + generationType: 'json-object', + }, + }, + { + id: 'sendEntries', + title: 'Message Entries', + canvasNoun: 'a batch of messages', + type: 'code', + placeholder: + '[\n { "id": "msg-1", "data": { "orderId": 1 } },\n { "id": "msg-2", "data": { "orderId": 2 } }\n]', + condition: { field: 'operation', value: 'send_message_batch' }, + required: { field: 'operation', value: 'send_message_batch' }, + wandConfig: { + enabled: true, + prompt: + 'Generate an array of at most 10 Amazon SQS batch send entries. Each entry is an object with a unique "id" (letters, digits, hyphens, underscores) and a "data" JSON object holding the message body. Optional per-entry keys are delaySeconds, messageGroupId, messageDeduplicationId, and messageAttributes. Return ONLY the JSON array.', + generationType: 'json-array', + }, + }, + { + id: 'receiptHandle', + title: 'Receipt Handle', + canvasNoun: 'a received message', + type: 'short-input', + placeholder: 'Receipt handle returned by Receive Message', + condition: { field: 'operation', value: ['delete_message', 'change_message_visibility'] }, + required: { field: 'operation', value: ['delete_message', 'change_message_visibility'] }, + }, + { + id: 'visibilityTimeout', + title: 'Visibility Timeout', + canvasNoun: 'a timeout', + type: 'short-input', + placeholder: '0-43200 seconds', + condition: { field: 'operation', value: 'change_message_visibility' }, + required: { field: 'operation', value: 'change_message_visibility' }, + }, + { + id: 'deleteEntries', + title: 'Delete Entries', + type: 'code', + placeholder: + '[\n { "id": "msg-1", "receiptHandle": "AQEB..." },\n { "id": "msg-2", "receiptHandle": "AQEB..." }\n]', + condition: { field: 'operation', value: 'delete_message_batch' }, + required: { field: 'operation', value: 'delete_message_batch' }, + wandConfig: { + enabled: true, + prompt: + 'Generate an array of at most 10 Amazon SQS delete-message batch entries. Each entry is an object with a unique "id" and the "receiptHandle" of a received message. Return ONLY the JSON array.', + generationType: 'json-array', + }, + }, + { + id: 'visibilityEntries', + title: 'Visibility Entries', + type: 'code', + placeholder: + '[\n { "id": "msg-1", "receiptHandle": "AQEB...", "visibilityTimeout": 120 }\n]', + condition: { field: 'operation', value: 'change_message_visibility_batch' }, + required: { field: 'operation', value: 'change_message_visibility_batch' }, + wandConfig: { + enabled: true, + prompt: + 'Generate an array of at most 10 Amazon SQS change-message-visibility batch entries. Each entry is an object with a unique "id", the "receiptHandle" of a received message, and an optional "visibilityTimeout" in seconds between 0 and 43200. Return ONLY the JSON array.', + generationType: 'json-array', + }, + }, + { + id: 'maxNumberOfMessages', + title: 'Max Messages', + type: 'short-input', + placeholder: '1-10 (default 1)', + condition: { field: 'operation', value: 'receive_message' }, + required: false, + mode: 'advanced', + }, + { + id: 'waitTimeSeconds', + title: 'Wait Time (Long Poll)', + type: 'short-input', + placeholder: '0-20 seconds (default 0)', + condition: { field: 'operation', value: 'receive_message' }, + required: false, + mode: 'advanced', + }, + { + id: 'receiveVisibilityTimeout', + title: 'Visibility Timeout', + type: 'short-input', + placeholder: '0-43200 seconds (defaults to the queue setting)', + condition: { field: 'operation', value: 'receive_message' }, + required: false, + mode: 'advanced', + }, + { + id: 'messageAttributeNames', + title: 'Message Attribute Names', + type: 'code', + placeholder: '["All"]', + condition: { field: 'operation', value: 'receive_message' }, + required: false, + mode: 'advanced', + }, + { + id: 'messageSystemAttributeNames', + title: 'Message System Attribute Names', + type: 'code', + placeholder: '["SentTimestamp", "ApproximateReceiveCount"]', + condition: { field: 'operation', value: 'receive_message' }, + required: false, + mode: 'advanced', + }, + { + id: 'receiveRequestAttemptId', + title: 'Receive Request Attempt ID', + type: 'short-input', + placeholder: 'FIFO deduplication token for a retried receive', + condition: { field: 'operation', value: 'receive_message' }, + required: false, + mode: 'advanced', + }, + { + id: 'queueNamePrefix', + title: 'Queue Name Prefix', + type: 'short-input', + placeholder: 'orders-', + condition: { field: 'operation', value: 'list_queues' }, + required: false, + mode: 'advanced', + }, + { + id: 'maxResults', + title: 'Max Results', + type: 'short-input', + placeholder: '1-1000 (set it to receive a next token)', + condition: { + field: 'operation', + value: ['list_queues', 'list_dead_letter_source_queues'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'nextToken', + title: 'Next Token', + type: 'short-input', + placeholder: 'Pagination token from a previous run', + condition: { + field: 'operation', + value: ['list_queues', 'list_dead_letter_source_queues'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'attributeNames', + title: 'Attribute Names', + type: 'code', + placeholder: '["All"]', + condition: { field: 'operation', value: 'get_queue_attributes' }, + required: false, + mode: 'advanced', + }, + { + id: 'queueAttributes', + title: 'Queue Attributes', + type: 'code', + placeholder: '{\n "VisibilityTimeout": "60",\n "MessageRetentionPeriod": "345600"\n}', + condition: { field: 'operation', value: 'set_queue_attributes' }, + required: { field: 'operation', value: 'set_queue_attributes' }, + wandConfig: { + enabled: true, + prompt: + 'Generate an Amazon SQS queue attribute map as JSON. Keys are documented queue attribute names such as VisibilityTimeout, DelaySeconds, MessageRetentionPeriod, MaximumMessageSize, ReceiveMessageWaitTimeSeconds, RedrivePolicy, RedriveAllowPolicy, Policy, KmsMasterKeyId, KmsDataKeyReusePeriodSeconds, SqsManagedSseEnabled, or ContentBasedDeduplication. Every value must be a string. Return ONLY the JSON object.', + generationType: 'json-object', + }, + }, + { + id: 'createQueueAttributes', + title: 'Queue Attributes', + type: 'code', + placeholder: '{\n "FifoQueue": "true",\n "VisibilityTimeout": "30"\n}', + condition: { field: 'operation', value: 'create_queue' }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an Amazon SQS queue attribute map as JSON for a new queue. Keys are documented queue attribute names such as FifoQueue, ContentBasedDeduplication, VisibilityTimeout, DelaySeconds, MessageRetentionPeriod, MaximumMessageSize, ReceiveMessageWaitTimeSeconds, RedrivePolicy, or SqsManagedSseEnabled. Every value must be a string. Return ONLY the JSON object.', + generationType: 'json-object', + }, + }, + { + id: 'createQueueTags', + title: 'Tags', + type: 'code', + placeholder: '{\n "env": "prod",\n "team": "payments"\n}', + condition: { field: 'operation', value: 'create_queue' }, + required: false, + mode: 'advanced', + }, + { + id: 'queueTags', + title: 'Tags', + canvasNoun: 'tags', + type: 'code', + placeholder: '{\n "env": "prod",\n "team": "payments"\n}', + condition: { field: 'operation', value: 'tag_queue' }, + required: { field: 'operation', value: 'tag_queue' }, + }, + { + id: 'tagKeys', + title: 'Tag Keys', + canvasNoun: 'tags', + type: 'code', + placeholder: '["env", "team"]', + condition: { field: 'operation', value: 'untag_queue' }, + required: { field: 'operation', value: 'untag_queue' }, + }, + { + id: 'sourceArn', + title: 'Source Queue ARN', + canvasNoun: 'a dead-letter queue', + type: 'short-input', + placeholder: 'arn:aws:sqs:us-east-1:123456789012:my-dlq', + condition: { + field: 'operation', + value: ['start_message_move_task', 'list_message_move_tasks'], + }, + required: { + field: 'operation', + value: ['start_message_move_task', 'list_message_move_tasks'], + }, + }, + { + id: 'destinationArn', + title: 'Destination Queue ARN', + type: 'short-input', + placeholder: 'Leave empty to redrive to each original source queue', + condition: { field: 'operation', value: 'start_message_move_task' }, + required: false, + mode: 'advanced', + }, + { + id: 'maxNumberOfMessagesPerSecond', + title: 'Max Messages Per Second', + type: 'short-input', + placeholder: '1-500 (empty moves as fast as possible)', + condition: { field: 'operation', value: 'start_message_move_task' }, + required: false, + mode: 'advanced', + }, + { + id: 'moveTaskMaxResults', + title: 'Max Results', + type: 'short-input', + placeholder: '1-10 (default 1)', + condition: { field: 'operation', value: 'list_message_move_tasks' }, + required: false, + mode: 'advanced', + }, + { + id: 'taskHandle', + title: 'Task Handle', + type: 'short-input', + placeholder: 'Task handle returned by Start Message Move Task', + condition: { field: 'operation', value: 'cancel_message_move_task' }, + required: { field: 'operation', value: 'cancel_message_move_task' }, }, ], tools: { - access: ['sqs_send'], + access: [ + 'sqs_send', + 'sqs_send_message_batch', + 'sqs_receive_message', + 'sqs_delete_message', + 'sqs_delete_message_batch', + 'sqs_change_message_visibility', + 'sqs_change_message_visibility_batch', + 'sqs_list_queues', + 'sqs_get_queue_url', + 'sqs_get_queue_attributes', + 'sqs_set_queue_attributes', + 'sqs_create_queue', + 'sqs_delete_queue', + 'sqs_purge_queue', + 'sqs_list_dead_letter_source_queues', + 'sqs_list_queue_tags', + 'sqs_tag_queue', + 'sqs_untag_queue', + 'sqs_start_message_move_task', + 'sqs_list_message_move_tasks', + 'sqs_cancel_message_move_task', + ], config: { tool: (params) => { switch (params.operation) { case 'send': return 'sqs_send' + case 'send_message_batch': + return 'sqs_send_message_batch' + case 'receive_message': + return 'sqs_receive_message' + case 'delete_message': + return 'sqs_delete_message' + case 'delete_message_batch': + return 'sqs_delete_message_batch' + case 'change_message_visibility': + return 'sqs_change_message_visibility' + case 'change_message_visibility_batch': + return 'sqs_change_message_visibility_batch' + case 'list_queues': + return 'sqs_list_queues' + case 'get_queue_url': + return 'sqs_get_queue_url' + case 'get_queue_attributes': + return 'sqs_get_queue_attributes' + case 'set_queue_attributes': + return 'sqs_set_queue_attributes' + case 'create_queue': + return 'sqs_create_queue' + case 'delete_queue': + return 'sqs_delete_queue' + case 'purge_queue': + return 'sqs_purge_queue' + case 'list_dead_letter_source_queues': + return 'sqs_list_dead_letter_source_queues' + case 'list_queue_tags': + return 'sqs_list_queue_tags' + case 'tag_queue': + return 'sqs_tag_queue' + case 'untag_queue': + return 'sqs_untag_queue' + case 'start_message_move_task': + return 'sqs_start_message_move_task' + case 'list_message_move_tasks': + return 'sqs_list_message_move_tasks' + case 'cancel_message_move_task': + return 'sqs_cancel_message_move_task' default: throw new Error(`Invalid SQS operation: ${params.operation}`) } }, params: (params) => { - const { operation, data, messageGroupId, messageDeduplicationId, ...rest } = params + const { operation, ...rest } = params - // Parse JSON fields const parseJson = (value: unknown, fieldName: string) => { - if (!value) return undefined + if (value === undefined || value === null || value === '') return undefined if (typeof value === 'object') return value if (typeof value === 'string' && value.trim()) { try { @@ -120,22 +618,186 @@ export const SQSBlock: BlockConfig = { return undefined } - const parsedData = parseJson(data, 'data') + /** + * `Number.parseInt` stops at the first non-digit, so `1.5` and `10abc` + * would forward `1` and `10` — a different setting than the one typed. + * `Number` rejects both by producing a non-integer or `NaN`. + */ + const parseInteger = (value: unknown, fieldName: string) => { + if (value === undefined || value === null || value === '') return undefined + const text = String(value).trim() + if (!text) return undefined + const parsed = Number(text) + if (!Number.isInteger(parsed)) { + throw new Error(`${fieldName} must be a whole number`) + } + return parsed + } - // Build connection config - const connectionConfig = { + const result: Record = { region: rest.region, accessKeyId: rest.accessKeyId, secretAccessKey: rest.secretAccessKey, } - // Build params object - const result: Record = { ...connectionConfig } - - if (rest.queueUrl) result.queueUrl = rest.queueUrl - if (messageGroupId) result.messageGroupId = messageGroupId - if (messageDeduplicationId) result.messageDeduplicationId = messageDeduplicationId - if (parsedData !== undefined) result.data = parsedData + switch (operation) { + case 'send': { + result.queueUrl = rest.queueUrl + const data = parseJson(rest.data, 'data') + if (data !== undefined) result.data = data + const delaySeconds = parseInteger(rest.delaySeconds, 'delaySeconds') + if (delaySeconds !== undefined) result.delaySeconds = delaySeconds + const messageAttributes = parseJson(rest.messageAttributes, 'messageAttributes') + if (messageAttributes !== undefined) result.messageAttributes = messageAttributes + if (rest.messageGroupId) result.messageGroupId = rest.messageGroupId + if (rest.messageDeduplicationId) { + result.messageDeduplicationId = rest.messageDeduplicationId + } + break + } + case 'send_message_batch': { + result.queueUrl = rest.queueUrl + const entries = parseJson(rest.sendEntries, 'entries') + if (entries !== undefined) result.entries = entries + break + } + case 'receive_message': { + result.queueUrl = rest.queueUrl + const maxNumberOfMessages = parseInteger( + rest.maxNumberOfMessages, + 'maxNumberOfMessages' + ) + if (maxNumberOfMessages !== undefined) result.maxNumberOfMessages = maxNumberOfMessages + const waitTimeSeconds = parseInteger(rest.waitTimeSeconds, 'waitTimeSeconds') + if (waitTimeSeconds !== undefined) result.waitTimeSeconds = waitTimeSeconds + const visibilityTimeout = parseInteger( + rest.receiveVisibilityTimeout, + 'visibilityTimeout' + ) + if (visibilityTimeout !== undefined) result.visibilityTimeout = visibilityTimeout + const messageAttributeNames = parseJson( + rest.messageAttributeNames, + 'messageAttributeNames' + ) + if (messageAttributeNames !== undefined) { + result.messageAttributeNames = messageAttributeNames + } + const messageSystemAttributeNames = parseJson( + rest.messageSystemAttributeNames, + 'messageSystemAttributeNames' + ) + if (messageSystemAttributeNames !== undefined) { + result.messageSystemAttributeNames = messageSystemAttributeNames + } + if (rest.receiveRequestAttemptId) { + result.receiveRequestAttemptId = rest.receiveRequestAttemptId + } + break + } + case 'delete_message': { + result.queueUrl = rest.queueUrl + result.receiptHandle = rest.receiptHandle + break + } + case 'delete_message_batch': { + result.queueUrl = rest.queueUrl + const entries = parseJson(rest.deleteEntries, 'entries') + if (entries !== undefined) result.entries = entries + break + } + case 'change_message_visibility': { + result.queueUrl = rest.queueUrl + result.receiptHandle = rest.receiptHandle + const visibilityTimeout = parseInteger(rest.visibilityTimeout, 'visibilityTimeout') + if (visibilityTimeout !== undefined) result.visibilityTimeout = visibilityTimeout + break + } + case 'change_message_visibility_batch': { + result.queueUrl = rest.queueUrl + const entries = parseJson(rest.visibilityEntries, 'entries') + if (entries !== undefined) result.entries = entries + break + } + case 'list_queues': { + if (rest.queueNamePrefix) result.queueNamePrefix = rest.queueNamePrefix + const maxResults = parseInteger(rest.maxResults, 'maxResults') + if (maxResults !== undefined) result.maxResults = maxResults + if (rest.nextToken) result.nextToken = rest.nextToken + break + } + case 'get_queue_url': { + result.queueName = rest.queueName + if (rest.queueOwnerAwsAccountId) { + result.queueOwnerAwsAccountId = rest.queueOwnerAwsAccountId + } + break + } + case 'get_queue_attributes': { + result.queueUrl = rest.queueUrl + const attributeNames = parseJson(rest.attributeNames, 'attributeNames') + if (attributeNames !== undefined) result.attributeNames = attributeNames + break + } + case 'set_queue_attributes': { + result.queueUrl = rest.queueUrl + const attributes = parseJson(rest.queueAttributes, 'attributes') + if (attributes !== undefined) result.attributes = attributes + break + } + case 'create_queue': { + result.queueName = rest.queueName + const attributes = parseJson(rest.createQueueAttributes, 'attributes') + if (attributes !== undefined) result.attributes = attributes + const tags = parseJson(rest.createQueueTags, 'tags') + if (tags !== undefined) result.tags = tags + break + } + case 'delete_queue': + case 'purge_queue': + case 'list_queue_tags': { + result.queueUrl = rest.queueUrl + break + } + case 'list_dead_letter_source_queues': { + result.queueUrl = rest.queueUrl + const maxResults = parseInteger(rest.maxResults, 'maxResults') + if (maxResults !== undefined) result.maxResults = maxResults + if (rest.nextToken) result.nextToken = rest.nextToken + break + } + case 'tag_queue': { + result.queueUrl = rest.queueUrl + const tags = parseJson(rest.queueTags, 'tags') + if (tags !== undefined) result.tags = tags + break + } + case 'untag_queue': { + result.queueUrl = rest.queueUrl + const tagKeys = parseJson(rest.tagKeys, 'tagKeys') + if (tagKeys !== undefined) result.tagKeys = tagKeys + break + } + case 'start_message_move_task': { + result.sourceArn = rest.sourceArn + if (rest.destinationArn) result.destinationArn = rest.destinationArn + const maxPerSecond = parseInteger( + rest.maxNumberOfMessagesPerSecond, + 'maxNumberOfMessagesPerSecond' + ) + if (maxPerSecond !== undefined) result.maxNumberOfMessagesPerSecond = maxPerSecond + break + } + case 'list_message_move_tasks': { + result.sourceArn = rest.sourceArn + const maxResults = parseInteger(rest.moveTaskMaxResults, 'maxResults') + if (maxResults !== undefined) result.maxResults = maxResults + break + } + case 'cancel_message_move_task': { + result.taskHandle = rest.taskHandle + break + } + } return result }, @@ -147,24 +809,133 @@ export const SQSBlock: BlockConfig = { accessKeyId: { type: 'string', description: 'AWS access key ID' }, secretAccessKey: { type: 'string', description: 'AWS secret access key' }, queueUrl: { type: 'string', description: 'SQS queue URL' }, - messageGroupId: { + queueName: { type: 'string', description: 'SQS queue name' }, + queueOwnerAwsAccountId: { type: 'string', - description: 'Message group ID (optional)', + description: '12-digit AWS account ID of the queue owner', }, + data: { type: 'json', description: 'Message body to send, as a JSON object' }, + messageGroupId: { type: 'string', description: 'Message group ID for FIFO queues' }, messageDeduplicationId: { type: 'string', - description: 'Message deduplication ID (optional)', + description: 'Message deduplication ID for FIFO queues', + }, + delaySeconds: { type: 'number', description: 'Seconds to delay delivery of the message' }, + messageAttributes: { + type: 'json', + description: 'Message attributes keyed by name, each with dataType and stringValue', + }, + sendEntries: { + type: 'json', + description: 'Batch send entries, each with id, data, and optional per-message settings', + }, + receiptHandle: { type: 'string', description: 'Receipt handle of a received message' }, + visibilityTimeout: { + type: 'number', + description: 'New visibility timeout in seconds for a received message', + }, + deleteEntries: { + type: 'json', + description: 'Batch delete entries, each with id and receiptHandle', + }, + visibilityEntries: { + type: 'json', + description: + 'Batch visibility entries, each with id, receiptHandle, and an optional visibilityTimeout', + }, + maxNumberOfMessages: { type: 'number', description: 'Maximum messages to receive (1-10)' }, + waitTimeSeconds: { type: 'number', description: 'Long-poll wait time in seconds (0-20)' }, + receiveVisibilityTimeout: { + type: 'number', + description: 'Visibility timeout applied to the received messages', + }, + messageAttributeNames: { + type: 'json', + description: 'Names of user-defined message attributes to return', + }, + messageSystemAttributeNames: { + type: 'json', + description: 'System attribute names to return with each message', + }, + receiveRequestAttemptId: { + type: 'string', + description: 'FIFO deduplication token for a retried receive', + }, + queueNamePrefix: { + type: 'string', + description: 'Return only queues whose name starts with this', + }, + maxResults: { type: 'number', description: 'Maximum results to return (1-1000)' }, + nextToken: { type: 'string', description: 'Pagination token from a previous request' }, + attributeNames: { type: 'json', description: 'Queue attribute names to read' }, + queueAttributes: { type: 'json', description: 'Queue attributes to set, as string values' }, + createQueueAttributes: { + type: 'json', + description: 'Queue attributes for the new queue, as string values', + }, + createQueueTags: { type: 'json', description: 'Tags to apply to the new queue' }, + queueTags: { type: 'json', description: 'Tags to apply to the queue' }, + tagKeys: { type: 'json', description: 'Tag keys to remove, as an array of strings' }, + sourceArn: { type: 'string', description: 'ARN of the source queue for a message move task' }, + destinationArn: { + type: 'string', + description: 'ARN of the destination queue for a message move task', }, - data: { type: 'json', description: 'Data for send message operation' }, + maxNumberOfMessagesPerSecond: { + type: 'number', + description: 'Throttle for a message move task, up to 500 messages per second', + }, + moveTaskMaxResults: { type: 'number', description: 'Maximum move tasks to return (1-10)' }, + taskHandle: { type: 'string', description: 'Handle of a message move task' }, }, outputs: { message: { type: 'string', description: 'Success or error message describing the operation outcome', }, - id: { + id: { type: 'string', description: 'Message ID of the sent message' }, + md5OfMessageBody: { type: 'string', description: 'MD5 digest of the sent message body' }, + md5OfMessageAttributes: { + type: 'string', + description: 'MD5 digest of the sent message attributes', + }, + sequenceNumber: { type: 'string', - description: 'Message ID', + description: 'Sequence number assigned by a FIFO queue', + }, + messages: { + type: 'json', + description: + 'Received messages (messageId, receiptHandle, body, md5OfBody, md5OfMessageAttributes, attributes, messageAttributes)', + }, + successful: { + type: 'json', + description: 'Batch entries that succeeded', + }, + failed: { + type: 'json', + description: 'Batch entries that failed (id, senderFault, code, message)', + }, + successCount: { type: 'number', description: 'Number of batch entries that succeeded' }, + failureCount: { type: 'number', description: 'Number of batch entries that failed' }, + queueUrls: { type: 'json', description: 'Queue URLs returned by a list operation' }, + queueUrl: { type: 'string', description: 'URL of a single queue' }, + nextToken: { type: 'string', description: 'Pagination token for the next page of results' }, + count: { type: 'number', description: 'Number of items returned' }, + attributes: { + type: 'json', + description: 'Queue attributes as string values keyed by attribute name', + }, + tags: { type: 'json', description: 'Queue tags as string values keyed by tag key' }, + results: { + type: 'json', + description: + 'Message move tasks (taskHandle, status, sourceArn, destinationArn, maxNumberOfMessagesPerSecond, approximateNumberOfMessagesMoved, approximateNumberOfMessagesToMove, failureReason, startedTimestamp)', + }, + taskHandle: { type: 'string', description: 'Handle of the started message move task' }, + approximateNumberOfMessagesMoved: { + type: 'number', + description: 'Approximate number of messages moved before a task was cancelled', }, }, } @@ -253,5 +1024,47 @@ export const SQSBlockMeta = { content: '# Send Ordered FIFO Message\n\nDispatch a message to a FIFO queue when ordering within a stream and de-duplication matter.\n\n## Steps\n1. Identify the FIFO queue URL.\n2. Build the JSON message body.\n3. Set the message group ID so messages in the same group stay ordered, and set a deduplication ID to prevent duplicate sends.\n4. Send the message.\n\n## Output\nConfirm the message was sent with its message ID, group ID, and the queue it was placed on.', }, + { + name: 'drain-queue-batch', + description: + 'Receive a batch of Amazon SQS messages with long polling, process them, and delete each one so it is not redelivered.', + content: + '# Drain Queue Batch\n\nPull a batch of work off an SQS queue, act on it, and acknowledge it. This is the standard consumer loop: a message stays invisible for its visibility timeout and reappears unless it is deleted.\n\n## Steps\n1. Receive from the queue with a max message count of up to 10 and a wait time of up to 20 seconds so the call long-polls instead of returning empty.\n2. Process each returned message body.\n3. Delete each processed message by its receipt handle, using the batch delete when more than one succeeded.\n4. Leave any message you could not process undeleted so it becomes visible again or lands in the dead-letter queue.\n\n## Output\nReport how many messages were received, how many were processed, and how many were deleted.', + }, + { + name: 'extend-processing-lease', + description: + 'Extend the visibility timeout of an in-flight Amazon SQS message so long-running work finishes before the message is redelivered.', + content: + '# Extend Processing Lease\n\nWhen handling a message takes longer than the queue visibility timeout, extend the timeout so another consumer does not pick up the same message and the eventual delete does not fail.\n\n## Steps\n1. Note the receipt handle of the message being processed.\n2. Before the current visibility timeout expires, change the message visibility to a new timeout that covers the remaining work, up to 43200 seconds.\n3. Repeat while processing continues.\n4. Delete the message once the work is done.\n\n## Output\nReport the message the lease was extended for and the new timeout in seconds.', + }, + { + name: 'redrive-dead-letter-queue', + description: + 'Move messages out of an Amazon SQS dead-letter queue back to their source queue, and track the move task to completion.', + content: + '# Redrive Dead-Letter Queue\n\nAfter fixing the defect that caused failures, replay the messages parked in a dead-letter queue.\n\n## Steps\n1. List the source queues that redrive to the dead-letter queue to confirm which workloads are affected.\n2. Start a message move task from the dead-letter queue ARN, leaving the destination empty to return each message to its original source queue. Throttle it with a per-second cap if the consumers are fragile.\n3. List the move tasks for the queue to watch status, messages moved, and messages left to move.\n4. Cancel the task if the replay needs to stop; only a running task can be cancelled.\n\n## Output\nReport the task handle, its status, and how many messages were moved.', + }, + { + name: 'check-queue-backlog', + description: + 'Read the message counts and configuration of an Amazon SQS queue to judge backlog and consumer health.', + content: + '# Check Queue Backlog\n\nInspect a queue before scaling consumers or opening an incident.\n\n## Steps\n1. Resolve the queue URL from its name if you only have the name.\n2. Read the queue attributes, requesting ApproximateNumberOfMessages, ApproximateNumberOfMessagesNotVisible, ApproximateNumberOfMessagesDelayed, VisibilityTimeout, and RedrivePolicy.\n3. Compare the visible backlog against the in-flight count to tell a slow consumer from an absent one.\n4. If a redrive policy is set, check the dead-letter queue backlog too.\n\n## Output\nReport the visible, in-flight, and delayed message counts, the visibility timeout, and whether a dead-letter queue is configured.', + }, + { + name: 'provision-worker-queue', + description: + 'Create an Amazon SQS queue with a dead-letter queue, a visibility timeout matched to the work, and cost-allocation tags.', + content: + '# Provision Worker Queue\n\nStand up a queue for a new background workload with the settings a production consumer needs.\n\n## Steps\n1. Create the dead-letter queue first so its ARN exists.\n2. Read the dead-letter queue attributes to get its QueueArn.\n3. Create the main queue, setting VisibilityTimeout to comfortably exceed the expected processing time, MessageRetentionPeriod to the replay window you want, and RedrivePolicy pointing at the dead-letter queue ARN with a maxReceiveCount. Add FifoQueue when ordering matters, naming the queue with a .fifo suffix.\n4. Tag both queues with owner and environment for cost allocation.\n\n## Output\nReport the URLs of the created queues and the redrive policy linking them.', + }, + { + name: 'reset-queue-for-test', + description: + 'Clear every message from a non-production Amazon SQS queue so a test run starts from a known empty state.', + content: + '# Reset Queue For Test\n\nEmpty a scratch or staging queue between test runs. Purging deletes every message and cannot be undone, so confirm the queue is not production.\n\n## Steps\n1. Resolve the queue URL and read its attributes to confirm the environment tag and current message count.\n2. Purge the queue.\n3. Wait before purging again; SQS rejects a second purge within 60 seconds of the first.\n4. Re-read the message count to confirm the queue is empty.\n\n## Output\nReport the queue purged and how many messages it held beforehand.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/ssm.ts b/apps/sim/blocks/blocks/ssm.ts new file mode 100644 index 00000000000..9d9aec3d3f8 --- /dev/null +++ b/apps/sim/blocks/blocks/ssm.ts @@ -0,0 +1,1354 @@ +import { SSMIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { SsmSendCommandResponse } from '@/tools/ssm/types' + +/** Operations whose SSM API caps `MaxResults` at 50. */ +const STANDARD_PAGE_OPERATIONS = [ + 'list_commands', + 'list_command_invocations', + 'describe_parameters', + 'list_compliance_items', + 'list_compliance_summaries', + 'describe_automation_executions', + 'list_documents', +] + +/** Operations that accept a `filters` array of Parameter Store string filters. */ +const PARAMETER_FILTER_OPERATIONS = ['get_parameters_by_path', 'describe_parameters'] + +const PAGINATED_OPERATIONS = [ + ...STANDARD_PAGE_OPERATIONS, + 'get_parameters_by_path', + 'describe_instance_information', + 'describe_instance_patches', + 'describe_instance_patch_states', +] + +function toOptionalNumber(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') return undefined + const parsed = Number.parseInt(String(value), 10) + return Number.isNaN(parsed) ? undefined : parsed +} + +function toOptionalBoolean(value: unknown): boolean | undefined { + if (value === 'true' || value === true) return true + if (value === 'false' || value === false) return false + return undefined +} + +function toParsedJson(value: unknown): unknown { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string') return value + return JSON.parse(value) +} + +export const SSMBlock: BlockConfig = { + type: 'ssm', + name: 'AWS Systems Manager', + description: 'Run commands, manage parameters, and audit managed nodes', + longDescription: + 'Integrate AWS Systems Manager into your workflow. Run commands on managed nodes, read and write Parameter Store values, inspect node inventory and patch compliance, and drive Automation runbooks.', + docsLink: 'https://docs.sim.ai/integrations/ssm', + category: 'tools', + integrationType: IntegrationType.DevOps, + bgColor: '#E7157B', + icon: SSMIcon, + authMode: AuthMode.ApiKey, + canvasPresentation: { + defaultTitle: 'AWS Systems Manager', + sentences: { + byOperation: { + send_command: [ + { text: 'Run document', field: 'documentName', core: true }, + { text: 'on', field: 'instanceIds' }, + ], + list_commands: [ + 'List commands', + { text: ', for command', field: 'commandId' }, + { text: ', on node', field: 'instanceId' }, + ], + list_command_invocations: [ + 'List command invocations', + { text: ', for command', field: 'commandId' }, + { text: ', on node', field: 'instanceId' }, + ], + get_command_invocation: [ + { text: 'Read output of command', field: 'commandId', core: true }, + { text: 'on node', field: 'instanceId', core: true }, + ], + cancel_command: [{ text: 'Cancel command', field: 'commandId', core: true }], + get_parameter: [{ text: 'Read parameter', field: 'parameterName', core: true }], + get_parameters: [{ text: 'Read parameters', field: 'parameterNames', core: true }], + get_parameters_by_path: [ + { text: 'Read parameters under', field: 'parameterPath', core: true }, + ], + put_parameter: [ + { text: 'Write parameter', field: 'parameterName', core: true }, + { text: ', as type', field: 'parameterType' }, + ], + delete_parameter: [{ text: 'Delete parameter', field: 'parameterName', core: true }], + describe_parameters: ['List parameter metadata', { text: ', up to', field: 'maxResults' }], + describe_instance_information: [ + 'List managed nodes', + { text: ', up to', field: 'instanceInfoMaxResults' }, + ], + describe_instance_patches: [ + { text: 'List patches on node', field: 'instanceId', core: true }, + ], + describe_instance_patch_states: [ + { text: 'Summarize patch state of', field: 'instanceIds', core: true }, + ], + list_compliance_items: ['List compliance items', { text: ', for', field: 'resourceIds' }], + list_compliance_summaries: [ + 'Summarize compliance', + { text: ', up to', field: 'maxResults' }, + ], + start_automation_execution: [ + { text: 'Start runbook', field: 'documentName', core: true }, + { text: ', over', field: 'targets' }, + ], + describe_automation_executions: [ + 'List automation executions', + { text: ', up to', field: 'maxResults' }, + ], + get_automation_execution: [ + { text: 'Read automation execution', field: 'automationExecutionId', core: true }, + ], + stop_automation_execution: [ + { text: 'Stop automation execution', field: 'automationExecutionId', core: true }, + { text: ', with', field: 'stopType' }, + ], + list_documents: ['List documents', { text: ', up to', field: 'maxResults' }], + get_document: [ + { text: 'Read document', field: 'documentName', core: true }, + { text: ', as', field: 'documentFormat' }, + ], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Send Command', id: 'send_command' }, + { label: 'List Commands', id: 'list_commands' }, + { label: 'List Command Invocations', id: 'list_command_invocations' }, + { label: 'Get Command Invocation', id: 'get_command_invocation' }, + { label: 'Cancel Command', id: 'cancel_command' }, + { label: 'Get Parameter', id: 'get_parameter' }, + { label: 'Get Parameters', id: 'get_parameters' }, + { label: 'Get Parameters By Path', id: 'get_parameters_by_path' }, + { label: 'Put Parameter', id: 'put_parameter' }, + { label: 'Delete Parameter', id: 'delete_parameter' }, + { label: 'Describe Parameters', id: 'describe_parameters' }, + { label: 'Describe Instance Information', id: 'describe_instance_information' }, + { label: 'Describe Instance Patches', id: 'describe_instance_patches' }, + { label: 'Describe Instance Patch States', id: 'describe_instance_patch_states' }, + { label: 'List Compliance Items', id: 'list_compliance_items' }, + { label: 'List Compliance Summaries', id: 'list_compliance_summaries' }, + { label: 'Start Automation Execution', id: 'start_automation_execution' }, + { label: 'Describe Automation Executions', id: 'describe_automation_executions' }, + { label: 'Get Automation Execution', id: 'get_automation_execution' }, + { label: 'Stop Automation Execution', id: 'stop_automation_execution' }, + { label: 'List Documents', id: 'list_documents' }, + { label: 'Get Document', id: 'get_document' }, + ], + value: () => 'send_command', + }, + { + id: 'region', + title: 'AWS Region', + type: 'short-input', + placeholder: 'us-east-1', + required: true, + }, + { + id: 'accessKeyId', + title: 'AWS Access Key ID', + type: 'short-input', + placeholder: 'AKIA...', + password: true, + required: true, + }, + { + id: 'secretAccessKey', + title: 'AWS Secret Access Key', + type: 'short-input', + placeholder: 'Your secret access key', + password: true, + required: true, + }, + { + id: 'documentName', + title: 'Document Name', + type: 'short-input', + placeholder: 'AWS-RunShellScript', + condition: { + field: 'operation', + value: ['send_command', 'start_automation_execution', 'get_document'], + }, + required: { + field: 'operation', + value: ['send_command', 'start_automation_execution', 'get_document'], + }, + }, + { + id: 'instanceIds', + title: 'Instance IDs', + type: 'code', + placeholder: '["i-0123456789abcdef0"]', + condition: { + field: 'operation', + value: ['send_command', 'cancel_command', 'describe_instance_patch_states'], + }, + required: { field: 'operation', value: 'describe_instance_patch_states' }, + }, + { + id: 'targets', + title: 'Targets', + type: 'code', + placeholder: '[{"Key":"tag:Environment","Values":["prod"]}]', + condition: { field: 'operation', value: ['send_command', 'start_automation_execution'] }, + required: false, + mode: 'advanced', + }, + { + id: 'parameters', + title: 'Document Parameters', + type: 'code', + placeholder: '{"commands":["df -h"]}', + condition: { field: 'operation', value: ['send_command', 'start_automation_execution'] }, + required: false, + wandConfig: { + enabled: true, + prompt: + 'Generate SSM document parameters as a JSON object mapping each parameter name to an array of string values. Return ONLY the JSON.', + generationType: 'json-object', + }, + }, + { + id: 'documentVersion', + title: 'Document Version', + type: 'short-input', + placeholder: '$LATEST, $DEFAULT, or a version number', + condition: { + field: 'operation', + value: ['send_command', 'start_automation_execution', 'get_document'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'comment', + title: 'Comment', + type: 'short-input', + placeholder: 'Restart the web tier', + condition: { field: 'operation', value: 'send_command' }, + required: false, + mode: 'advanced', + }, + { + id: 'executionTimeoutSeconds', + title: 'Acknowledgement Timeout (Seconds)', + type: 'short-input', + placeholder: '3600 (30-2592000)', + condition: { field: 'operation', value: 'send_command' }, + required: false, + mode: 'advanced', + }, + { + id: 'maxConcurrency', + title: 'Max Concurrency', + type: 'short-input', + placeholder: '50% or 10', + condition: { field: 'operation', value: ['send_command', 'start_automation_execution'] }, + required: false, + mode: 'advanced', + }, + { + id: 'maxErrors', + title: 'Max Errors', + type: 'short-input', + placeholder: '0 or 10%', + condition: { field: 'operation', value: ['send_command', 'start_automation_execution'] }, + required: false, + mode: 'advanced', + }, + { + id: 'outputS3BucketName', + title: 'Output S3 Bucket', + type: 'short-input', + placeholder: 'my-ssm-output-bucket', + condition: { field: 'operation', value: 'send_command' }, + required: false, + mode: 'advanced', + }, + { + id: 'outputS3KeyPrefix', + title: 'Output S3 Key Prefix', + type: 'short-input', + placeholder: 'run-command/', + condition: { field: 'operation', value: 'send_command' }, + required: false, + mode: 'advanced', + }, + { + id: 'serviceRoleArn', + title: 'Notification Service Role ARN', + type: 'short-input', + placeholder: 'arn:aws:iam::123456789012:role/ssm-notifications', + condition: { field: 'operation', value: 'send_command' }, + required: false, + mode: 'advanced', + }, + { + id: 'commandId', + title: 'Command ID', + type: 'short-input', + placeholder: '11111111-2222-3333-4444-555555555555', + condition: { + field: 'operation', + value: [ + 'list_commands', + 'list_command_invocations', + 'get_command_invocation', + 'cancel_command', + ], + }, + required: { field: 'operation', value: ['get_command_invocation', 'cancel_command'] }, + }, + { + id: 'instanceId', + title: 'Instance ID', + type: 'short-input', + placeholder: 'i-0123456789abcdef0', + condition: { + field: 'operation', + value: [ + 'list_commands', + 'list_command_invocations', + 'get_command_invocation', + 'describe_instance_patches', + ], + }, + required: { + field: 'operation', + value: ['get_command_invocation', 'describe_instance_patches'], + }, + }, + { + id: 'commandFilters', + title: 'Command Filters', + type: 'code', + placeholder: '[{"key":"Status","value":"Failed"}]', + condition: { field: 'operation', value: ['list_commands', 'list_command_invocations'] }, + required: false, + mode: 'advanced', + }, + { + id: 'details', + title: 'Include Plugin Detail', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'list_command_invocations' }, + required: false, + mode: 'advanced', + }, + { + id: 'pluginName', + title: 'Plugin Name', + type: 'short-input', + placeholder: 'aws:runShellScript', + condition: { field: 'operation', value: 'get_command_invocation' }, + required: false, + mode: 'advanced', + }, + { + id: 'parameterName', + title: 'Parameter Name', + type: 'short-input', + placeholder: '/prod/app/database-url', + condition: { + field: 'operation', + value: ['get_parameter', 'put_parameter', 'delete_parameter'], + }, + required: { + field: 'operation', + value: ['get_parameter', 'put_parameter', 'delete_parameter'], + }, + }, + { + id: 'parameterNames', + title: 'Parameter Names', + type: 'code', + placeholder: '["/prod/app/database-url","/prod/app/api-host"]', + condition: { field: 'operation', value: 'get_parameters' }, + required: { field: 'operation', value: 'get_parameters' }, + }, + { + id: 'parameterPath', + title: 'Parameter Path', + type: 'short-input', + placeholder: '/prod/app', + condition: { field: 'operation', value: 'get_parameters_by_path' }, + required: { field: 'operation', value: 'get_parameters_by_path' }, + }, + { + id: 'recursive', + title: 'Recursive', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'get_parameters_by_path' }, + required: false, + }, + { + id: 'withDecryption', + title: 'Decrypt SecureString Values', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { + field: 'operation', + value: ['get_parameter', 'get_parameters', 'get_parameters_by_path'], + }, + required: false, + }, + { + id: 'parameterValue', + title: 'Parameter Value', + type: 'long-input', + password: true, + placeholder: 'The value to store', + condition: { field: 'operation', value: 'put_parameter' }, + required: { field: 'operation', value: 'put_parameter' }, + }, + { + id: 'parameterType', + title: 'Parameter Type', + type: 'dropdown', + options: [ + { label: 'String', id: 'String' }, + { label: 'StringList', id: 'StringList' }, + { label: 'SecureString', id: 'SecureString' }, + ], + value: () => 'String', + condition: { field: 'operation', value: 'put_parameter' }, + required: false, + }, + { + id: 'overwrite', + title: 'Overwrite Existing', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'put_parameter' }, + required: false, + }, + { + id: 'parameterDescription', + title: 'Parameter Description', + type: 'short-input', + placeholder: 'Production database connection string', + condition: { field: 'operation', value: 'put_parameter' }, + required: false, + mode: 'advanced', + }, + { + id: 'kmsKeyId', + title: 'KMS Key ID', + type: 'short-input', + placeholder: 'alias/aws/ssm or a key ARN', + condition: { field: 'operation', value: 'put_parameter' }, + required: false, + mode: 'advanced', + }, + { + id: 'allowedPattern', + title: 'Allowed Pattern', + type: 'short-input', + placeholder: '^\\d+$', + condition: { field: 'operation', value: 'put_parameter' }, + required: false, + mode: 'advanced', + }, + { + id: 'parameterTier', + title: 'Parameter Tier', + type: 'dropdown', + /** + * `Tier` is optional, and omitting it lets the account's own default apply — + * which may be Intelligent-Tiering. A dropdown with no `value()` seeds and + * persists its first option, so without this sentinel the block would silently + * force `Standard` on every write. + */ + value: () => '', + options: [ + { label: 'Account default', id: '' }, + { label: 'Standard', id: 'Standard' }, + { label: 'Advanced', id: 'Advanced' }, + { label: 'Intelligent-Tiering', id: 'Intelligent-Tiering' }, + ], + condition: { field: 'operation', value: 'put_parameter' }, + required: false, + mode: 'advanced', + }, + { + id: 'parameterDataType', + title: 'Parameter Data Type', + type: 'short-input', + placeholder: 'text, aws:ec2:image, or aws:ssm:integration', + condition: { field: 'operation', value: 'put_parameter' }, + required: false, + mode: 'advanced', + }, + { + id: 'parameterPolicies', + title: 'Parameter Policies', + type: 'code', + placeholder: '[{"Type":"Expiration","Version":"1.0","Attributes":{"Timestamp":"..."}}]', + condition: { field: 'operation', value: 'put_parameter' }, + required: false, + mode: 'advanced', + }, + { + id: 'parameterFilters', + title: 'Parameter Filters', + type: 'code', + placeholder: '[{"Key":"Type","Option":"Equals","Values":["SecureString"]}]', + condition: { field: 'operation', value: PARAMETER_FILTER_OPERATIONS }, + required: false, + mode: 'advanced', + }, + { + id: 'shared', + title: 'Shared Parameters', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'describe_parameters' }, + required: false, + mode: 'advanced', + }, + { + id: 'instanceInfoFilters', + title: 'Node Filters', + type: 'code', + placeholder: '[{"Key":"PingStatus","Values":["Online"]}]', + condition: { field: 'operation', value: 'describe_instance_information' }, + required: false, + mode: 'advanced', + }, + { + id: 'patchFilters', + title: 'Patch Filters', + type: 'code', + placeholder: '[{"Key":"State","Values":["Missing"]}]', + condition: { field: 'operation', value: 'describe_instance_patches' }, + required: false, + mode: 'advanced', + }, + { + id: 'resourceIds', + title: 'Resource IDs', + type: 'code', + placeholder: '["i-0123456789abcdef0"]', + condition: { field: 'operation', value: 'list_compliance_items' }, + required: false, + }, + { + id: 'resourceTypes', + title: 'Resource Types', + type: 'code', + placeholder: '["ManagedInstance"]', + condition: { field: 'operation', value: 'list_compliance_items' }, + required: false, + mode: 'advanced', + }, + { + id: 'complianceFilters', + title: 'Compliance Filters', + type: 'code', + placeholder: '[{"Key":"Status","Values":["NON_COMPLIANT"],"Type":"EQUAL"}]', + condition: { + field: 'operation', + value: ['list_compliance_items', 'list_compliance_summaries'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'mode', + title: 'Execution Mode', + type: 'dropdown', + options: [ + { label: 'Auto', id: 'Auto' }, + { label: 'Interactive', id: 'Interactive' }, + ], + value: () => 'Auto', + condition: { field: 'operation', value: 'start_automation_execution' }, + required: false, + mode: 'advanced', + }, + { + id: 'targetParameterName', + title: 'Target Parameter Name', + type: 'short-input', + placeholder: 'InstanceId', + condition: { field: 'operation', value: 'start_automation_execution' }, + required: false, + mode: 'advanced', + }, + { + id: 'clientToken', + title: 'Client Token', + type: 'short-input', + placeholder: 'Idempotency token, exactly 36 characters', + condition: { field: 'operation', value: 'start_automation_execution' }, + required: false, + mode: 'advanced', + }, + { + id: 'automationFilters', + title: 'Automation Filters', + type: 'code', + placeholder: '[{"Key":"ExecutionStatus","Values":["Failed"]}]', + condition: { field: 'operation', value: 'describe_automation_executions' }, + required: false, + mode: 'advanced', + }, + { + id: 'automationExecutionId', + title: 'Automation Execution ID', + type: 'short-input', + placeholder: '11111111-2222-3333-4444-555555555555', + condition: { + field: 'operation', + value: ['get_automation_execution', 'stop_automation_execution'], + }, + required: { + field: 'operation', + value: ['get_automation_execution', 'stop_automation_execution'], + }, + }, + { + id: 'stopType', + title: 'Stop Type', + type: 'dropdown', + options: [ + { label: 'Cancel', id: 'Cancel' }, + { label: 'Complete', id: 'Complete' }, + ], + value: () => 'Cancel', + condition: { field: 'operation', value: 'stop_automation_execution' }, + required: false, + }, + { + id: 'documentFilters', + title: 'Document Filters', + type: 'code', + placeholder: '[{"Key":"DocumentType","Values":["Automation"]}]', + condition: { field: 'operation', value: 'list_documents' }, + required: false, + mode: 'advanced', + }, + { + id: 'versionName', + title: 'Version Name', + type: 'short-input', + placeholder: 'Release-2024-06', + condition: { field: 'operation', value: 'get_document' }, + required: false, + mode: 'advanced', + }, + { + id: 'documentFormat', + title: 'Document Format', + type: 'dropdown', + options: [ + { label: 'JSON', id: 'JSON' }, + { label: 'YAML', id: 'YAML' }, + { label: 'Text', id: 'TEXT' }, + ], + condition: { field: 'operation', value: 'get_document' }, + required: false, + }, + { + id: 'maxResults', + title: 'Max Results', + type: 'short-input', + placeholder: '50 (1-50)', + condition: { field: 'operation', value: STANDARD_PAGE_OPERATIONS }, + required: false, + mode: 'advanced', + }, + { + id: 'pathMaxResults', + title: 'Max Results', + type: 'short-input', + placeholder: '10 (1-10)', + condition: { field: 'operation', value: 'get_parameters_by_path' }, + required: false, + mode: 'advanced', + }, + { + id: 'instanceInfoMaxResults', + title: 'Max Results', + type: 'short-input', + placeholder: '50 (5-50)', + condition: { field: 'operation', value: 'describe_instance_information' }, + required: false, + mode: 'advanced', + }, + { + id: 'patchMaxResults', + title: 'Max Results', + type: 'short-input', + placeholder: '100 (10-100)', + condition: { + field: 'operation', + value: ['describe_instance_patches', 'describe_instance_patch_states'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'nextToken', + title: 'Next Token', + type: 'short-input', + placeholder: 'Pagination token', + condition: { field: 'operation', value: PAGINATED_OPERATIONS }, + required: false, + mode: 'advanced', + }, + ], + tools: { + access: [ + 'ssm_send_command', + 'ssm_list_commands', + 'ssm_list_command_invocations', + 'ssm_get_command_invocation', + 'ssm_cancel_command', + 'ssm_get_parameter', + 'ssm_get_parameters', + 'ssm_get_parameters_by_path', + 'ssm_put_parameter', + 'ssm_delete_parameter', + 'ssm_describe_parameters', + 'ssm_describe_instance_information', + 'ssm_describe_instance_patches', + 'ssm_describe_instance_patch_states', + 'ssm_list_compliance_items', + 'ssm_list_compliance_summaries', + 'ssm_start_automation_execution', + 'ssm_describe_automation_executions', + 'ssm_get_automation_execution', + 'ssm_stop_automation_execution', + 'ssm_list_documents', + 'ssm_get_document', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'send_command': + case 'list_commands': + case 'list_command_invocations': + case 'get_command_invocation': + case 'cancel_command': + case 'get_parameter': + case 'get_parameters': + case 'get_parameters_by_path': + case 'put_parameter': + case 'delete_parameter': + case 'describe_parameters': + case 'describe_instance_information': + case 'describe_instance_patches': + case 'describe_instance_patch_states': + case 'list_compliance_items': + case 'list_compliance_summaries': + case 'start_automation_execution': + case 'describe_automation_executions': + case 'get_automation_execution': + case 'stop_automation_execution': + case 'list_documents': + case 'get_document': + return `ssm_${params.operation}` + default: + throw new Error(`Invalid Systems Manager operation: ${params.operation}`) + } + }, + params: (params) => { + const result: Record = { + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + } + + const setJson = (key: string, value: unknown) => { + const parsed = toParsedJson(value) + if (parsed !== undefined) result[key] = parsed + } + const setNumber = (key: string, value: unknown) => { + const parsed = toOptionalNumber(value) + if (parsed !== undefined) result[key] = parsed + } + const setBoolean = (key: string, value: unknown) => { + const parsed = toOptionalBoolean(value) + if (parsed !== undefined) result[key] = parsed + } + const setString = (key: string, value: unknown) => { + if (value === undefined || value === null || value === '') return + result[key] = String(value) + } + + switch (params.operation) { + case 'send_command': + result.documentName = params.documentName + setJson('instanceIds', params.instanceIds) + setJson('targets', params.targets) + setJson('parameters', params.parameters) + setString('documentVersion', params.documentVersion) + setString('comment', params.comment) + setNumber('executionTimeoutSeconds', params.executionTimeoutSeconds) + setString('maxConcurrency', params.maxConcurrency) + setString('maxErrors', params.maxErrors) + setString('outputS3BucketName', params.outputS3BucketName) + setString('outputS3KeyPrefix', params.outputS3KeyPrefix) + setString('serviceRoleArn', params.serviceRoleArn) + break + case 'list_commands': + setString('commandId', params.commandId) + setString('instanceId', params.instanceId) + setJson('filters', params.commandFilters) + setNumber('maxResults', params.maxResults) + setString('nextToken', params.nextToken) + break + case 'list_command_invocations': + setString('commandId', params.commandId) + setString('instanceId', params.instanceId) + setJson('filters', params.commandFilters) + setBoolean('details', params.details) + setNumber('maxResults', params.maxResults) + setString('nextToken', params.nextToken) + break + case 'get_command_invocation': + result.commandId = params.commandId + result.instanceId = params.instanceId + setString('pluginName', params.pluginName) + break + case 'cancel_command': + result.commandId = params.commandId + setJson('instanceIds', params.instanceIds) + break + case 'get_parameter': + result.name = params.parameterName + setBoolean('withDecryption', params.withDecryption) + break + case 'get_parameters': + setJson('names', params.parameterNames) + setBoolean('withDecryption', params.withDecryption) + break + case 'get_parameters_by_path': + result.path = params.parameterPath + setBoolean('recursive', params.recursive) + setBoolean('withDecryption', params.withDecryption) + setJson('parameterFilters', params.parameterFilters) + setNumber('maxResults', params.pathMaxResults) + setString('nextToken', params.nextToken) + break + case 'put_parameter': + result.name = params.parameterName + result.value = params.parameterValue + setString('type', params.parameterType) + setString('description', params.parameterDescription) + setString('keyId', params.kmsKeyId) + setBoolean('overwrite', params.overwrite) + setString('allowedPattern', params.allowedPattern) + setString('tier', params.parameterTier) + setString('dataType', params.parameterDataType) + setString('policies', params.parameterPolicies) + break + case 'delete_parameter': + result.name = params.parameterName + break + case 'describe_parameters': + setJson('parameterFilters', params.parameterFilters) + setBoolean('shared', params.shared) + setNumber('maxResults', params.maxResults) + setString('nextToken', params.nextToken) + break + case 'describe_instance_information': + setJson('filters', params.instanceInfoFilters) + setNumber('maxResults', params.instanceInfoMaxResults) + setString('nextToken', params.nextToken) + break + case 'describe_instance_patches': + result.instanceId = params.instanceId + setJson('filters', params.patchFilters) + setNumber('maxResults', params.patchMaxResults) + setString('nextToken', params.nextToken) + break + case 'describe_instance_patch_states': + setJson('instanceIds', params.instanceIds) + setNumber('maxResults', params.patchMaxResults) + setString('nextToken', params.nextToken) + break + case 'list_compliance_items': + setJson('resourceIds', params.resourceIds) + setJson('resourceTypes', params.resourceTypes) + setJson('filters', params.complianceFilters) + setNumber('maxResults', params.maxResults) + setString('nextToken', params.nextToken) + break + case 'list_compliance_summaries': + setJson('filters', params.complianceFilters) + setNumber('maxResults', params.maxResults) + setString('nextToken', params.nextToken) + break + case 'start_automation_execution': + result.documentName = params.documentName + setString('documentVersion', params.documentVersion) + setJson('parameters', params.parameters) + setString('mode', params.mode) + setString('targetParameterName', params.targetParameterName) + setJson('targets', params.targets) + setString('maxConcurrency', params.maxConcurrency) + setString('maxErrors', params.maxErrors) + setString('clientToken', params.clientToken) + break + case 'describe_automation_executions': + setJson('filters', params.automationFilters) + setNumber('maxResults', params.maxResults) + setString('nextToken', params.nextToken) + break + case 'get_automation_execution': + result.automationExecutionId = params.automationExecutionId + break + case 'stop_automation_execution': + result.automationExecutionId = params.automationExecutionId + setString('stopType', params.stopType) + break + case 'list_documents': + setJson('filters', params.documentFilters) + setNumber('maxResults', params.maxResults) + setString('nextToken', params.nextToken) + break + case 'get_document': + result.name = params.documentName + setString('documentVersion', params.documentVersion) + setString('versionName', params.versionName) + setString('documentFormat', params.documentFormat) + break + } + + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Systems Manager operation to perform' }, + region: { type: 'string', description: 'AWS region' }, + accessKeyId: { type: 'string', description: 'AWS access key ID' }, + secretAccessKey: { type: 'string', description: 'AWS secret access key' }, + documentName: { type: 'string', description: 'SSM document or runbook name' }, + documentVersion: { type: 'string', description: 'Document version to use' }, + instanceIds: { type: 'json', description: 'Managed node IDs, as an array of strings' }, + targets: { type: 'json', description: 'Targets, as an array of {Key, Values} objects' }, + parameters: { + type: 'json', + description: 'Document parameters, as an object of name to string array', + }, + comment: { type: 'string', description: 'Comment describing the command' }, + executionTimeoutSeconds: { + type: 'number', + description: 'Seconds a node has to acknowledge the command', + }, + maxConcurrency: { type: 'string', description: 'Concurrency, as a number or percentage' }, + maxErrors: { type: 'string', description: 'Error threshold, as a number or percentage' }, + outputS3BucketName: { type: 'string', description: 'S3 bucket for command output' }, + outputS3KeyPrefix: { type: 'string', description: 'S3 key prefix for command output' }, + serviceRoleArn: { type: 'string', description: 'IAM service role ARN for notifications' }, + commandId: { type: 'string', description: 'Run Command execution ID' }, + instanceId: { type: 'string', description: 'Managed node ID' }, + commandFilters: { + type: 'json', + description: 'Run Command filters, as an array of {key, value} objects', + }, + details: { type: 'string', description: 'Whether to include per-plugin invocation detail' }, + pluginName: { type: 'string', description: 'Document plugin to read output for' }, + parameterName: { type: 'string', description: 'Parameter Store parameter name' }, + parameterNames: { type: 'json', description: 'Parameter names, as an array of strings' }, + parameterPath: { type: 'string', description: 'Parameter Store hierarchy path' }, + recursive: { type: 'string', description: 'Whether to include nested paths' }, + withDecryption: { type: 'string', description: 'Whether to decrypt SecureString values' }, + parameterValue: { type: 'string', description: 'Value to store in the parameter' }, + parameterType: { type: 'string', description: 'String, StringList, or SecureString' }, + overwrite: { type: 'string', description: 'Whether to overwrite an existing parameter' }, + parameterDescription: { type: 'string', description: 'Description of the parameter' }, + kmsKeyId: { type: 'string', description: 'KMS key used to encrypt a SecureString parameter' }, + allowedPattern: { type: 'string', description: 'Regular expression the value must match' }, + parameterTier: { type: 'string', description: 'Standard, Advanced, or Intelligent-Tiering' }, + parameterDataType: { type: 'string', description: 'Data type of the parameter' }, + parameterPolicies: { type: 'string', description: 'Parameter policies as a JSON array string' }, + parameterFilters: { + type: 'json', + description: 'Parameter filters, as an array of {Key, Option, Values} objects', + }, + shared: { type: 'string', description: 'Whether to list parameters shared with this account' }, + instanceInfoFilters: { + type: 'json', + description: 'Managed node filters, as an array of {Key, Values} objects', + }, + patchFilters: { + type: 'json', + description: 'Patch filters, as an array of {Key, Values} objects', + }, + resourceIds: { type: 'json', description: 'Compliance resource IDs, as an array of strings' }, + resourceTypes: { + type: 'json', + description: 'Compliance resource types, as an array of strings', + }, + complianceFilters: { + type: 'json', + description: 'Compliance filters, as an array of {Key, Values, Type} objects', + }, + mode: { type: 'string', description: 'Automation execution mode' }, + targetParameterName: { + type: 'string', + description: 'Runbook parameter that receives each resolved target', + }, + clientToken: { type: 'string', description: 'Idempotency token for the automation execution' }, + automationFilters: { + type: 'json', + description: 'Automation filters, as an array of {Key, Values} objects', + }, + automationExecutionId: { type: 'string', description: 'Automation execution ID' }, + stopType: { type: 'string', description: 'How to stop the automation execution' }, + documentFilters: { + type: 'json', + description: 'Document filters, as an array of {Key, Values} objects', + }, + versionName: { type: 'string', description: 'User-defined document version name' }, + documentFormat: { type: 'string', description: 'Format to return document content in' }, + maxResults: { type: 'number', description: 'Maximum number of results to return' }, + pathMaxResults: { + type: 'number', + description: 'Maximum number of parameters to return for a path read', + }, + instanceInfoMaxResults: { + type: 'number', + description: 'Maximum number of managed nodes to return', + }, + patchMaxResults: { + type: 'number', + description: 'Maximum number of patch records to return', + }, + nextToken: { type: 'string', description: 'Pagination token' }, + }, + outputs: { + message: { type: 'string', description: 'Operation status message' }, + commandId: { type: 'string', description: 'Run Command execution ID' }, + commands: { + type: 'json', + description: + 'Commands, each with commandId, documentName, status, statusDetails, requestedDateTime, instanceIds, targets, targetCount, completedCount, and errorCount', + }, + commandInvocations: { + type: 'json', + description: + 'Per-node invocations, each with commandId, instanceId, instanceName, status, statusDetails, requestedDateTime, standardOutputUrl, standardErrorUrl, and commandPlugins', + }, + documentName: { type: 'string', description: 'Name of the document that was used' }, + documentVersion: { type: 'string', description: 'Document version that was used' }, + comment: { type: 'string', description: 'Comment supplied with the command' }, + status: { type: 'string', description: 'Status of the command, invocation, or document' }, + statusDetails: { type: 'string', description: 'Detailed status text' }, + statusInformation: { type: 'string', description: 'Detail about a document status' }, + requestedDateTime: { type: 'string', description: 'When the command was requested' }, + expiresAfter: { type: 'string', description: 'When the command stops being dispatched' }, + instanceIds: { type: 'array', description: 'Managed node IDs the command targets' }, + instanceId: { type: 'string', description: 'Managed node the invocation ran on' }, + targets: { + type: 'json', + description: 'Targets the command was sent to, as an array of {key, values}', + }, + maxConcurrency: { type: 'string', description: 'Concurrency the execution ran with' }, + maxErrors: { type: 'string', description: 'Error threshold the execution ran with' }, + targetCount: { type: 'number', description: 'Number of targets the command was sent to' }, + completedCount: { type: 'number', description: 'Number of targets that have completed' }, + errorCount: { type: 'number', description: 'Number of targets whose execution failed' }, + deliveryTimedOutCount: { + type: 'number', + description: 'Number of targets the command could not reach in time', + }, + executionTimeoutSeconds: { + type: 'number', + description: 'Acknowledgement timeout the command ran with', + }, + outputS3BucketName: { type: 'string', description: 'S3 bucket command output is written to' }, + outputS3KeyPrefix: { type: 'string', description: 'S3 key prefix for command output' }, + outputS3Region: { type: 'string', description: 'S3 region reported for command output' }, + serviceRole: { type: 'string', description: 'IAM service role used for notifications' }, + pluginName: { type: 'string', description: 'Document plugin the output belongs to' }, + responseCode: { type: 'number', description: 'Exit code of the command on the node' }, + executionStartDateTime: { type: 'string', description: 'When the command started on the node' }, + executionElapsedTime: { type: 'string', description: 'How long the command ran' }, + executionEndDateTime: { type: 'string', description: 'When the command finished on the node' }, + standardOutputContent: { type: 'string', description: 'First 24000 characters of stdout' }, + standardOutputUrl: { type: 'string', description: 'S3 URL of the full stdout' }, + standardErrorContent: { type: 'string', description: 'First 8000 characters of stderr' }, + standardErrorUrl: { type: 'string', description: 'S3 URL of the full stderr' }, + name: { type: 'string', description: 'Name of the parameter or document' }, + type: { type: 'string', description: 'Parameter type' }, + value: { type: 'string', description: 'Parameter value' }, + version: { type: 'number', description: 'Parameter version' }, + selector: { type: 'string', description: 'Version or label selector used to read a parameter' }, + sourceResult: { type: 'string', description: 'Raw result from the parameter source' }, + lastModifiedDate: { type: 'string', description: 'When the parameter was last changed' }, + arn: { type: 'string', description: 'ARN of the parameter' }, + dataType: { type: 'string', description: 'Data type of the parameter' }, + tier: { type: 'string', description: 'Tier the parameter is stored in' }, + parameters: { + type: 'json', + description: + 'Parameters read, or the parameter values an automation execution was started with', + }, + invalidParameters: { + type: 'array', + description: 'Parameter names that could not be read', + }, + instances: { + type: 'json', + description: + 'Managed nodes, each with instanceId, pingStatus, lastPingDateTime, agentVersion, platformType, platformName, platformVersion, computerName, ipAddress, iamRole, and associationStatus', + }, + patches: { + type: 'json', + description: + 'Patches, each with title, kbId, classification, severity, state, installedTime, and cveIds', + }, + instancePatchStates: { + type: 'json', + description: + 'Patch states, each with instanceId, patchGroup, baselineId, operation, installedCount, missingCount, failedCount, criticalNonCompliantCount, and securityNonCompliantCount', + }, + complianceItems: { + type: 'json', + description: + 'Compliance items, each with complianceType, resourceType, resourceId, id, title, status, severity, executionTime, and details', + }, + complianceSummaryItems: { + type: 'json', + description: + 'Compliance summaries, each with complianceType, compliantCount, compliantSeveritySummary, nonCompliantCount, and nonCompliantSeveritySummary', + }, + automationExecutionId: { type: 'string', description: 'Automation execution ID' }, + automationExecutions: { + type: 'json', + description: + 'Automation executions, each with automationExecutionId, documentName, automationExecutionStatus, executionStartTime, executionEndTime, executedBy, currentStepName, and failureMessage', + }, + automationExecutionStatus: { + type: 'string', + description: 'Status of the automation execution', + }, + executionStartTime: { type: 'string', description: 'When the automation execution started' }, + executionEndTime: { type: 'string', description: 'When the automation execution finished' }, + executedBy: { type: 'string', description: 'IAM identity that started the execution' }, + mode: { type: 'string', description: 'Automation execution mode' }, + parentAutomationExecutionId: { type: 'string', description: 'Parent execution ID' }, + currentStepName: { type: 'string', description: 'Step the execution is currently running' }, + currentAction: { type: 'string', description: 'Action the execution is currently running' }, + failureMessage: { type: 'string', description: 'Reason the execution failed' }, + targetParameterName: { + type: 'string', + description: 'Runbook parameter that received each resolved target', + }, + target: { type: 'string', description: 'Resource the execution targeted' }, + outputs: { type: 'json', description: 'Outputs the automation execution produced' }, + stepExecutions: { + type: 'json', + description: + 'Automation steps, each with stepName, action, stepStatus, executionStartTime, executionEndTime, failureMessage, and nextStep', + }, + stepExecutionsTruncated: { + type: 'boolean', + description: 'Whether the returned step list was truncated', + }, + documents: { + type: 'json', + description: + 'Documents, each with name, displayName, owner, documentType, documentFormat, documentVersion, platformTypes, targetType, createdDate, and tags', + }, + displayName: { type: 'string', description: 'Friendly name of the document' }, + createdDate: { type: 'string', description: 'When the document was created' }, + versionName: { type: 'string', description: 'User-defined document version name' }, + content: { type: 'string', description: 'Content of the document' }, + documentType: { type: 'string', description: 'Type of the document' }, + documentFormat: { type: 'string', description: 'Format the document content is returned in' }, + reviewStatus: { type: 'string', description: 'Review status of the document' }, + nextToken: { type: 'string', description: 'Pagination token for the next page of results' }, + count: { type: 'number', description: 'Number of records returned' }, + }, +} + +export const SSMBlockMeta = { + tags: ['cloud', 'automation'], + url: 'https://aws.amazon.com/systems-manager', + templates: [ + { + icon: SSMIcon, + title: 'Systems Manager patch reporter', + prompt: + 'Build a scheduled workflow that reads AWS Systems Manager patch compliance for every managed node, flags nodes with missing critical or security patches, and posts a ranked remediation list to Slack.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: SSMIcon, + title: 'Systems Manager agent health watch', + prompt: + 'Create a scheduled workflow that lists AWS Systems Manager managed nodes, identifies nodes whose agent has lost connection or is running an outdated version, and opens a Jira ticket for each one.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'monitoring'], + alsoIntegrations: ['jira'], + }, + { + icon: SSMIcon, + title: 'Systems Manager runbook responder', + prompt: + 'Build a workflow that receives an incident alert, starts the matching AWS Systems Manager Automation runbook, polls the execution until it finishes, and reports the step results back to the incident channel.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['devops', 'incident-management'], + alsoIntegrations: ['slack'], + featured: true, + }, + { + icon: SSMIcon, + title: 'Systems Manager config promoter', + prompt: + 'Create a workflow that reads application configuration from one AWS Systems Manager Parameter Store path, requests approval in Slack, writes the approved values to the production path, and records the change in a table.', + modules: ['tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'automation'], + alsoIntegrations: ['slack'], + }, + { + icon: SSMIcon, + title: 'Systems Manager fleet command runner', + prompt: + 'Build a workflow that runs a diagnostic shell script on every AWS Systems Manager managed node carrying a chosen tag, collects the per-node output, and summarizes the failures for the on-call engineer.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['devops', 'automation'], + }, + { + icon: SSMIcon, + title: 'Systems Manager compliance digest', + prompt: + 'Create a scheduled workflow that pulls AWS Systems Manager compliance summaries, compares them with last week’s counts stored in a table, and emails leadership a short trend report.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['enterprise', 'reporting'], + }, + { + icon: SSMIcon, + title: 'Systems Manager parameter auditor', + prompt: + 'Build a scheduled workflow that lists AWS Systems Manager Parameter Store metadata, flags SecureString parameters that have not changed within the rotation window and plaintext parameters that look like credentials, and files the findings for security review.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'enterprise'], + }, + { + icon: SSMIcon, + title: 'Systems Manager runbook catalog', + prompt: + 'Create a workflow that lists AWS Systems Manager Automation runbooks, reads the content of each one, and writes a plain-English catalog of what every runbook does and which parameters it needs.', + modules: ['tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'reporting'], + }, + ], + skills: [ + { + name: 'run-fleet-command', + description: + 'Run an SSM document such as AWS-RunShellScript across managed nodes and collect the per-node result. Use for fleet-wide diagnostics, log collection, or a scripted remediation.', + content: + '# Run Fleet Command\n\nExecute a command on managed nodes and report what happened on each one.\n\n## Steps\n1. Choose the SSM document to run (for example AWS-RunShellScript or AWS-RunPowerShellScript) and the parameters it needs.\n2. Pick the targets: explicit instance IDs, or tag targets such as tag:Environment = prod. Set concurrency and an error threshold so a bad script cannot roll through the whole fleet.\n3. Send the command and keep the returned command ID.\n4. List the invocations for that command ID to see per-node status, then read the invocation on each node of interest for its stdout and stderr.\n5. Summarize successes, failures, and any node that never acknowledged the command.\n\n## Output\nThe command ID, a per-node status table, and the captured output for every failed node.', + }, + { + name: 'triage-command-failure', + description: + 'Investigate why a Run Command execution failed on specific managed nodes. Use when a deployment or maintenance command reports errors.', + content: + '# Triage Command Failure\n\nFind out which nodes failed a command and why.\n\n## Steps\n1. List recent commands, filtering by status to find the failed execution.\n2. List that command’s invocations to identify the nodes that failed or timed out.\n3. Read the invocation on each failing node, including per-plugin detail, to get the exit code and captured stderr.\n4. Separate genuine script errors from delivery problems, where the node never acknowledged the command.\n5. If the command is still running and clearly wrong, cancel it.\n\n## Output\nThe failing nodes, their exit codes, the error text from each, and whether the cause was the script or node connectivity.', + }, + { + name: 'read-app-configuration', + description: + 'Read application configuration from a Parameter Store hierarchy path so a workflow can act on live settings. Use to load environment configuration without hardcoding it.', + content: + '# Read App Configuration\n\nLoad configuration for an environment from Parameter Store.\n\n## Steps\n1. Identify the hierarchy path that holds the environment’s settings, such as /prod/app.\n2. Read the parameters under that path recursively when the settings are nested.\n3. Only request decryption of SecureString values when the workflow genuinely needs the plaintext; leave it off for a settings inventory.\n4. Map each parameter name to the setting it represents.\n\n## Output\nThe configuration keys and their values. Never echo a decrypted SecureString value into a summary, a log, or a chat message.', + }, + { + name: 'promote-parameter-value', + description: + 'Write or update a Parameter Store value as part of a controlled configuration change. Use to promote a setting between environments or apply an approved change.', + content: + '# Promote Parameter Value\n\nApply a configuration change through Parameter Store.\n\n## Steps\n1. Read the current value and metadata of the target parameter so the change can be reversed.\n2. Confirm the parameter type: use SecureString for anything secret, and name the KMS key when the account default is not wanted.\n3. Write the new value with overwrite enabled for an existing parameter, or set the type explicitly when creating a new one.\n4. Read back the parameter metadata to confirm the new version number.\n\n## Output\nThe parameter name, the new version number, and the tier it was stored in. Never print the value itself.', + }, + { + name: 'audit-node-inventory', + description: + 'Report which managed nodes are registered with Systems Manager, their agent version, and whether they are reachable. Use for fleet hygiene and onboarding checks.', + content: + '# Audit Node Inventory\n\nReport the state of the managed node fleet.\n\n## Steps\n1. List managed nodes, filtering by ping status or platform type when the audit is scoped.\n2. Group nodes by ping status to find ones that have lost connection.\n3. Flag nodes that are not on the latest agent version, and nodes with no recent successful association run.\n4. Note nodes registered as managed instances rather than EC2 instances, since they onboard differently.\n\n## Output\nA fleet summary: total nodes, unreachable nodes, outdated agents, and the platform mix.', + }, + { + name: 'report-patch-compliance', + description: + 'Summarize patch compliance across managed nodes and drill into missing patches on a specific node. Use for monthly patching reviews and vulnerability follow-up.', + content: + '# Report Patch Compliance\n\nShow where the fleet stands on patching.\n\n## Steps\n1. Read patch states for the nodes in scope to get installed, missing, failed, and non-compliant counts per node.\n2. Rank the nodes by critical and security non-compliant counts.\n3. For the worst nodes, list the individual patches and filter to the ones in a Missing or Failed state to get titles, KB IDs, severities, and CVE IDs.\n4. Note nodes whose last patch operation never completed.\n\n## Output\nA ranked compliance table plus, for the top offenders, the specific missing patches and their severities.', + }, + { + name: 'run-automation-runbook', + description: + 'Start an SSM Automation runbook, follow it to completion, and report the step results. Use for scripted remediation such as restarting an instance or rotating an AMI.', + content: + '# Run Automation Runbook\n\nDrive an Automation runbook and report what it did.\n\n## Steps\n1. Find the runbook by listing documents filtered to the Automation type, and read its content to confirm the parameters it expects.\n2. Start the execution with those parameters. For a fleet-wide run, set the target parameter name and a rate-control target, plus concurrency and error limits.\n3. Poll the execution until its status leaves the in-progress states.\n4. Read the step executions to see which step failed and why, if it did not succeed.\n5. Stop the execution if it needs to be aborted; cancelling stops it immediately, completing lets the current step finish.\n\n## Output\nThe execution ID, the final status, the runbook outputs, and the failing step with its failure message when applicable.', + }, + { + name: 'review-compliance-findings', + description: + 'Pull Systems Manager compliance findings for a managed node and explain what is non-compliant. Use when a compliance dashboard flags a resource and someone needs the detail.', + content: + '# Review Compliance Findings\n\nExplain a node’s compliance status in detail.\n\n## Steps\n1. Read the compliance summaries to see which compliance types are non-compliant and at what severity.\n2. For the affected type, list the compliance items for the specific managed node, filtering to non-compliant status.\n3. Read each item’s title, severity, and details to explain what the finding actually is.\n4. Separate association findings from patch findings, since they are remediated differently.\n\n## Output\nA per-node list of non-compliant findings with severity and a short explanation of each, grouped by compliance type.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/tinyfish.ts b/apps/sim/blocks/blocks/tinyfish.ts index 53e96533e47..7dbb70c81bd 100644 --- a/apps/sim/blocks/blocks/tinyfish.ts +++ b/apps/sim/blocks/blocks/tinyfish.ts @@ -65,6 +65,7 @@ export const TinyFishBlock: BlockConfig = { { text: 'as', field: 'format' }, ], tinyfish_list_vault_items: ['List credentials from the connected vault'], + tinyfish_list_profiles: ['List saved browser profiles'], }, }, }, @@ -82,6 +83,7 @@ export const TinyFishBlock: BlockConfig = { { label: 'Search', id: 'tinyfish_search' }, { label: 'Fetch URLs', id: 'tinyfish_fetch' }, { label: 'List Vault Items', id: 'tinyfish_list_vault_items' }, + { label: 'List Browser Profiles', id: 'tinyfish_list_profiles' }, ], value: () => 'tinyfish_run', }, @@ -147,8 +149,9 @@ Return ONLY the JSON Schema - no explanations, no extra text.`, }, { id: 'browserProfile', - title: 'Browser Profile', + title: 'Browser Engine', type: 'dropdown', + description: 'Which browser the agent drives. Unrelated to a saved Browser Context Profile', options: [ { label: 'Lite (standard browser)', id: 'lite' }, { label: 'Stealth (anti-detection)', id: 'stealth' }, @@ -178,6 +181,15 @@ Return ONLY the JSON Schema - no explanations, no extra text.`, condition: { field: 'operation', value: AUTOMATION_OPERATIONS }, mode: 'advanced', }, + { + id: 'maxDurationSeconds', + title: 'Max Duration (seconds)', + type: 'short-input', + placeholder: '300', + description: 'Wall-clock limit before the agent stops. Unlimited when empty', + condition: { field: 'operation', value: AUTOMATION_OPERATIONS }, + mode: 'advanced', + }, { id: 'proxyEnabled', title: 'Use Proxy', @@ -220,6 +232,28 @@ Return ONLY the JSON Schema - no explanations, no extra text.`, }, mode: 'advanced', }, + { + id: 'useProfile', + title: 'Use Browser Profile', + type: 'switch', + description: 'Start the run from a saved browser session, so the agent is already logged in', + condition: { field: 'operation', value: AUTOMATION_OPERATIONS }, + mode: 'advanced', + }, + { + id: 'profileId', + title: 'Browser Profile ID', + type: 'short-input', + placeholder: 'prof_abc123def4567890', + description: + 'Run List Browser Profiles to find it. Leave empty to use your default profile, which the run requires you to have set', + condition: { + field: 'operation', + value: AUTOMATION_OPERATIONS, + and: { field: 'useProfile', value: true }, + }, + mode: 'advanced', + }, { id: 'webhookUrl', title: 'Webhook URL', @@ -439,6 +473,7 @@ Return ONLY the comma-separated URL list - no explanations, no extra text.`, 'tinyfish_search', 'tinyfish_fetch', 'tinyfish_list_vault_items', + 'tinyfish_list_profiles', ], config: { tool: (params) => { @@ -457,6 +492,8 @@ Return ONLY the comma-separated URL list - no explanations, no extra text.`, return 'tinyfish_fetch' case 'tinyfish_list_vault_items': return 'tinyfish_list_vault_items' + case 'tinyfish_list_profiles': + return 'tinyfish_list_profiles' default: return 'tinyfish_run' } @@ -464,11 +501,30 @@ Return ONLY the comma-separated URL list - no explanations, no extra text.`, params: (params) => { const result: Record = {} + /** + * A numeric text input that does not parse is rejected rather than + * dropped. Omitting it silently hands the run TinyFish's default — no + * wall-clock limit at all for Max Duration — while the block still + * shows the value the user typed. + */ + const toFiniteNumber = (value: string, field: string): number => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid numeric value for ${field}: ${value}`) + } + return parsed + } + const maxSteps = String(params.maxSteps ?? '').trim() - if (maxSteps) result.maxSteps = Number(maxSteps) + if (maxSteps) result.maxSteps = toFiniteNumber(maxSteps, 'Max Steps') + + const maxDurationSeconds = String(params.maxDurationSeconds ?? '').trim() + if (maxDurationSeconds) { + result.maxDurationSeconds = toFiniteNumber(maxDurationSeconds, 'Max Duration (seconds)') + } const limit = String(params.limit ?? '').trim() - if (limit) result.limit = Number(limit) + if (limit) result.limit = toFiniteNumber(limit, 'Limit') /** * The list filter has its own sub-block id so it does not collide with @@ -492,10 +548,16 @@ Return ONLY the comma-separated URL list - no explanations, no extra text.`, browserProfile: { type: 'string', description: 'Browser engine: lite or stealth' }, agentMode: { type: 'string', description: 'Agent behavior: default or strict' }, maxSteps: { type: 'number', description: 'Maximum agent steps' }, + maxDurationSeconds: { + type: 'number', + description: 'Maximum wall-clock seconds before the agent stops', + }, proxyEnabled: { type: 'boolean', description: 'Route the run through the Tetra proxy' }, proxyCountryCode: { type: 'string', description: 'Proxy country code' }, useVault: { type: 'boolean', description: 'Allow vault credentials during the run' }, credentialItemIds: { type: 'string', description: 'Comma-separated vault credential URIs' }, + useProfile: { type: 'boolean', description: 'Start the run from a saved browser session' }, + profileId: { type: 'string', description: 'Browser Context Profile to start the run from' }, webhookUrl: { type: 'string', description: 'HTTPS endpoint notified on run lifecycle events' }, runId: { type: 'string', description: 'Run identifier' }, status: { type: 'string', description: 'Run status filter' }, @@ -548,7 +610,7 @@ Return ONLY the comma-separated URL list - no explanations, no extra text.`, runs: { type: 'json', description: - 'Runs matching the list filters [{runId, status, goal, createdAt, startedAt, finishedAt, numOfSteps, result, schemaValidation, error, streamingUrl, browserConfig}]', + 'Runs matching the list filters [{runId, status, goal, createdAt, startedAt, finishedAt, numOfSteps, result, schemaValidation, error, streamingUrl, browserConfig, profileAttached, profileId, profileHint}]', }, total: { type: 'number', description: 'Total runs matching the list filters' }, nextCursor: { type: 'string', description: 'Cursor for the next page of runs' }, @@ -566,6 +628,24 @@ Return ONLY the comma-separated URL list - no explanations, no extra text.`, description: 'Vault credentials available to a run [{itemId, connectionId, label, vaultName, domains, fieldMetadata, hasTotp}]', }, + profileId: { + type: 'string', + description: 'Browser Context Profile the run attached, null when none did', + }, + profileAttached: { + type: 'boolean', + description: 'Whether the run actually started from a Browser Context Profile', + }, + profileHint: { + type: 'json', + description: + 'Set when TinyFish believes a Browser Context Profile would fix a failed run {message, setupUrl, reason} — reason is auth_wall or bot_challenge', + }, + profiles: { + type: 'json', + description: + 'Browser Context Profiles a run can start from [{profileId, name, proxyCountryCode, fingerprintSeed, domainCount, createdAt, updatedAt, isDefault}]. Every field but profileId and name can be null when the API omits it', + }, }, } @@ -655,14 +735,14 @@ export const TinyFishBlockMeta = { description: 'Drive a TinyFish web agent to navigate a site and return data matching a JSON schema. Use to pull records — prices, listings, table rows — from pages that have no API.', content: - '# Extract Structured Data From Site\n\nUse the TinyFish Run Agent operation to read a website and return structured data.\n\n## Steps\n1. Set Website URL to the page the agent should start on. Starting closer to the data costs fewer steps.\n2. Write a Goal that names exactly what to collect and where, e.g. "open the pricing page and collect every plan name and monthly price".\n3. Provide an Output Schema (JSON Schema draft-07) describing the fields you want back. TinyFish re-prompts the agent when the result does not match and reports the mismatches in `schemaValidation`.\n4. Raise Max Steps for deeper flows; switch Browser Profile to Stealth when the site blocks automation.\n\n## Output\nReturn the extracted `result` object. Check `schemaValidation.valid` before trusting it, and report any field the agent could not find rather than filling it in.', + '# Extract Structured Data From Site\n\nUse the TinyFish Run Agent operation to read a website and return structured data.\n\n## Steps\n1. Set Website URL to the page the agent should start on. Starting closer to the data costs fewer steps.\n2. Write a Goal that names exactly what to collect and where, e.g. "open the pricing page and collect every plan name and monthly price".\n3. Provide an Output Schema (JSON Schema draft-07) describing the fields you want back. TinyFish re-prompts the agent when the result does not match and reports the mismatches in `schemaValidation`.\n4. Raise Max Steps for deeper flows; switch Browser Engine to Stealth when the site blocks automation.\n\n## Output\nReturn the extracted `result` object. Check `schemaValidation.valid` before trusting it, and report any field the agent could not find rather than filling it in.', }, { name: 'automate-web-task', description: 'Have a TinyFish agent complete a multi-step task on a website — logging in, navigating, filling and submitting forms. Use when a site has no API and a person would normally do the clicks.', content: - '# Automate Web Task\n\nUse the TinyFish Run Agent operation to complete a goal-oriented task on the web.\n\n## Steps\n1. Set Website URL to the entry point and write a Goal that states the steps and the success condition, e.g. "log in, open Billing, download the latest invoice".\n2. Turn on Use Vault Credentials when the task needs a login, and scope it with Vault Credential URIs so only the intended credential is available.\n3. Use Agent Mode "strict" when the run is a test that should fail fast instead of improvising.\n4. Enable Use Proxy and pick a Proxy Country when the site is geo-restricted.\n\n## Output\nReport whether the run completed, what the agent extracted, and the step count. On failure, quote the `error` message and category — AGENT_FAILURE means the goal needs rewording, SYSTEM_FAILURE is worth retrying.', + '# Automate Web Task\n\nUse the TinyFish Run Agent operation to complete a goal-oriented task on the web.\n\n## Steps\n1. Set Website URL to the entry point and write a Goal that states the steps and the success condition, e.g. "log in, open Billing, download the latest invoice".\n2. Turn on Use Browser Profile to start from a session that is already logged in, and give a Browser Profile ID to pick one. Turn on Use Vault Credentials when the task needs to log in itself, and scope it with Vault Credential URIs so only the intended credential is available. Pairing both lets TinyFish repair a profile whose session has expired.\n3. Use Agent Mode "strict" when the run is a test that should fail fast instead of improvising.\n4. Enable Use Proxy and pick a Proxy Country when the site is geo-restricted.\n\n## Output\nReport whether the run completed, what the agent extracted, and the step count. On failure, quote the `error` message and category — AGENT_FAILURE means the goal needs rewording, SYSTEM_FAILURE is worth retrying.', }, { name: 'search-and-read-the-web', @@ -683,7 +763,7 @@ export const TinyFishBlockMeta = { description: 'Read a failed TinyFish run and decide whether to retry, reword the goal, or escalate. Use when an automation returns FAILED or a workflow keeps burning steps without a result.', content: - '# Diagnose A Failed Run\n\nA failed automation comes back as a normal 200 response with the failure inside the run, so read `status` before trusting `result`.\n\n## Steps\n1. Read `error.category`. `AGENT_FAILURE` means the goal or the page is the problem — reword the goal or start the run closer to the target. `SYSTEM_FAILURE` is TinyFish-side; wait `error.retryAfter` seconds and retry the same input. `BILLING_FAILURE` means the TinyFish wallet is empty and no retry will help. `UNKNOWN` should be treated as retryable once.\n2. Compare `numOfSteps` against the Max Steps you set. Hitting the cap means the agent was still working, so raise the cap or narrow the goal.\n3. If an Output Schema was set, read `schemaValidation.errors` — a run can reach the right page and still fail on one mistyped field, and `rePromptAttempts` shows how hard TinyFish already tried to repair it.\n4. For an async run, call Get Run and read the `steps` list to find the last action before the failure. `videoUrl` gives a recording, but the link expires 15 minutes after it is issued.\n\n## Output\nState the category, the concrete cause, and the single next action. Quote `error.message` rather than paraphrasing it, and do not retry a `BILLING_FAILURE` or an `AGENT_FAILURE` without changing the input first.', + '# Diagnose A Failed Run\n\nA failed automation comes back as a normal 200 response with the failure inside the run, so read `status` before trusting `result`.\n\n## Steps\n1. Read `error.category`. `AGENT_FAILURE` means the goal or the page is the problem — reword the goal or start the run closer to the target. `SYSTEM_FAILURE` is TinyFish-side; wait `error.retryAfter` seconds and retry the same input. `BILLING_FAILURE` means the TinyFish wallet is empty and no retry will help. `UNKNOWN` should be treated as retryable once.\n2. Read `profileHint` whenever it is set — TinyFish attaches it only when a Browser Context Profile would likely fix the run, so it outranks the category above. `reason` is `auth_wall` (the agent hit a login) or `bot_challenge` (the site blocked automation); the fix is to set up a profile for that domain and rerun with Use Browser Profile on, not to reword the goal.\n3. Compare `numOfSteps` against the Max Steps you set. Hitting the cap means the agent was still working, so raise the cap or narrow the goal. If the run instead stopped early on a slow page, set Max Duration.\n4. If an Output Schema was set, read `schemaValidation.errors` — a run can reach the right page and still fail on one mistyped field, and `rePromptAttempts` shows how hard TinyFish already tried to repair it.\n5. For an async run, call Get Run and read the `steps` list to find the last action before the failure. `videoUrl` gives a recording, but the link expires 15 minutes after it is issued.\n\n## Output\nState the category, the concrete cause, and the single next action. Quote `error.message` rather than paraphrasing it, and do not retry a `BILLING_FAILURE` or an `AGENT_FAILURE` without changing the input first.', }, { name: 'queue-and-track-long-runs', diff --git a/apps/sim/blocks/password-masking.test.ts b/apps/sim/blocks/password-masking.test.ts index aa001531f23..eda870e458d 100644 --- a/apps/sim/blocks/password-masking.test.ts +++ b/apps/sim/blocks/password-masking.test.ts @@ -20,6 +20,7 @@ const FIELDS_REQUIRING_MASKING: ReadonlyArray<{ block: string; subBlock: string { block: 'sftp', subBlock: 'privateKey' }, { block: 'ssh', subBlock: 'privateKey' }, { block: 'secrets_manager', subBlock: 'secretValue' }, + { block: 'ssm', subBlock: 'parameterValue' }, { block: 'kalshi', subBlock: 'privateKey' }, { block: 'sts', subBlock: 'webIdentityToken' }, { block: 'sts', subBlock: 'samlAssertion' }, diff --git a/apps/sim/blocks/pi-model-options.test.ts b/apps/sim/blocks/pi-model-options.test.ts index f0e2201e554..f6df2ea6935 100644 --- a/apps/sim/blocks/pi-model-options.test.ts +++ b/apps/sim/blocks/pi-model-options.test.ts @@ -44,17 +44,17 @@ describe('Pi model options', () => { } }) - it('keeps current models and excludes stale catalog entries', () => { + it('keeps current models and excludes retired catalog entries', () => { const modelIds = getPiModelOptions().map(({ id }) => id) expect(modelIds).toContain('claude-sonnet-4-6') + expect(modelIds).not.toContain('claude-opus-4-1') expect(modelIds).not.toContain('claude-sonnet-4-0') }) it('keeps persisted and selectable models available', () => { const modelIds = getPiModelOptions().map(({ id }) => id) - expect(modelIds).toContain('claude-opus-4-1') expect(modelIds).toContain('cerebras/zai-glm-4.7') expect(modelIds).toContain('glm-5.1') expect(modelIds).toContain('glm-4.5-air') diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 3b161aa97e4..672ebb5e0e1 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -42,6 +42,7 @@ import { ClickHouseBlock, ClickHouseBlockMeta } from '@/blocks/blocks/clickhouse import { ClickUpBlock, ClickUpBlockMeta } from '@/blocks/blocks/clickup' import { CloudflareBlock, CloudflareBlockMeta } from '@/blocks/blocks/cloudflare' import { CloudFormationBlock, CloudFormationBlockMeta } from '@/blocks/blocks/cloudformation' +import { CloudTrailBlock, CloudTrailBlockMeta } from '@/blocks/blocks/cloudtrail' import { CloudWatchBlock, CloudWatchBlockMeta } from '@/blocks/blocks/cloudwatch' import { CodePipelineBlock, CodePipelineBlockMeta } from '@/blocks/blocks/codepipeline' import { ConditionBlock } from '@/blocks/blocks/condition' @@ -270,6 +271,7 @@ import { ProspeoBlock, ProspeoBlockMeta } from '@/blocks/blocks/prospeo' import { PulseBlock, PulseBlockMeta, PulseV2Block } from '@/blocks/blocks/pulse' import { QdrantBlock, QdrantBlockMeta } from '@/blocks/blocks/qdrant' import { QuartrBlock, QuartrBlockMeta } from '@/blocks/blocks/quartr' +import { QuickBooksBlock, QuickBooksBlockMeta } from '@/blocks/blocks/quickbooks' import { QuiverBlock, QuiverBlockMeta } from '@/blocks/blocks/quiver' import { RabbitmqBlock, RabbitmqBlockMeta } from '@/blocks/blocks/rabbitmq' import { RailwayBlock, RailwayBlockMeta } from '@/blocks/blocks/railway' @@ -317,6 +319,7 @@ import { SpotifyBlock, SpotifyBlockMeta } from '@/blocks/blocks/spotify' import { SQSBlock, SQSBlockMeta } from '@/blocks/blocks/sqs' import { SquareBlock, SquareBlockMeta } from '@/blocks/blocks/square' import { SSHBlock, SSHBlockMeta } from '@/blocks/blocks/ssh' +import { SSMBlock, SSMBlockMeta } from '@/blocks/blocks/ssm' import { StagehandBlock, StagehandBlockMeta } from '@/blocks/blocks/stagehand' import { StartTriggerBlock } from '@/blocks/blocks/start_trigger' import { StarterBlock } from '@/blocks/blocks/starter' @@ -419,6 +422,7 @@ export const BLOCK_REGISTRY: Record = { clickup: ClickUpBlock, cloudflare: CloudflareBlock, cloudformation: CloudFormationBlock, + cloudtrail: CloudTrailBlock, cloudwatch: CloudWatchBlock, codepipeline: CodePipelineBlock, condition: ConditionBlock, @@ -614,6 +618,7 @@ export const BLOCK_REGISTRY: Record = { pulse_v2: PulseV2Block, qdrant: QdrantBlock, quartr: QuartrBlock, + quickbooks: QuickBooksBlock, quiver: QuiverBlock, rabbitmq: RabbitmqBlock, railway: RailwayBlock, @@ -665,6 +670,7 @@ export const BLOCK_REGISTRY: Record = { sqs: SQSBlock, square: SquareBlock, ssh: SSHBlock, + ssm: SSMBlock, stagehand: StagehandBlock, start_trigger: StartTriggerBlock, starter: StarterBlock, @@ -774,6 +780,7 @@ export const BLOCK_META_REGISTRY: Record = { clickup: ClickUpBlockMeta, cloudflare: CloudflareBlockMeta, cloudformation: CloudFormationBlockMeta, + cloudtrail: CloudTrailBlockMeta, cloudwatch: CloudWatchBlockMeta, codepipeline: CodePipelineBlockMeta, confluence: ConfluenceBlockMeta, @@ -934,6 +941,7 @@ export const BLOCK_META_REGISTRY: Record = { pulse: PulseBlockMeta, qdrant: QdrantBlockMeta, quartr: QuartrBlockMeta, + quickbooks: QuickBooksBlockMeta, quiver: QuiverBlockMeta, rabbitmq: RabbitmqBlockMeta, railway: RailwayBlockMeta, @@ -977,6 +985,7 @@ export const BLOCK_META_REGISTRY: Record = { sqs: SQSBlockMeta, square: SquareBlockMeta, ssh: SSHBlockMeta, + ssm: SSMBlockMeta, stagehand: StagehandBlockMeta, stripe: StripeBlockMeta, sts: STSBlockMeta, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 6756bb2b28b..9c87acb35cc 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -535,6 +535,14 @@ export interface SubBlockConfig { dependsOn?: string[] | { all?: string[]; any?: string[] } // Copyable-text specific: Use webhook URL from webhook management hook useWebhookUrl?: boolean + /** + * Displays an app-level provider callback URL whose final segment comes from + * a server-derived trigger config field rather than a per-workflow path. + */ + providerWebhookUrl?: { + providerPath: string + routingKeySubBlockId: string + } /** * tool-input only: tool categories the consuming block cannot execute. They * stay visible in the picker but are greyed out with a tooltip rather than diff --git a/apps/sim/components/browser-credential-icon.tsx b/apps/sim/components/browser-credential-icon.tsx new file mode 100644 index 00000000000..edccb006658 --- /dev/null +++ b/apps/sim/components/browser-credential-icon.tsx @@ -0,0 +1,26 @@ +'use client' + +import { useState } from 'react' +import { Key } from '@sim/emcn/icons' + +interface BrowserCredentialIconProps { + icon?: string +} + +/** Uses only imported PNG data; fetching a logo would disclose a saved-login site. */ +export function BrowserCredentialIcon({ icon }: BrowserCredentialIconProps) { + const [failedIcon, setFailedIcon] = useState() + + if (!icon?.startsWith('data:image/png;base64,') || icon === failedIcon) { + return + } + + return ( + setFailedIcon(icon)} + /> + ) +} diff --git a/apps/sim/components/browser-import/browser-import-dialog.test.tsx b/apps/sim/components/browser-import/browser-import-dialog.test.tsx new file mode 100644 index 00000000000..570aeead538 --- /dev/null +++ b/apps/sim/components/browser-import/browser-import-dialog.test.tsx @@ -0,0 +1,215 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import type { BrowserChromeImportResult } from '@sim/desktop-bridge' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { importer, mockBridge } = vi.hoisted(() => ({ + importer: { listChromeProfiles: vi.fn(), importFromChrome: vi.fn() }, + mockBridge: vi.fn(), +})) + +vi.mock('@/lib/desktop', () => ({ getDesktopBridge: mockBridge })) + +import { toast } from '@sim/emcn' +import { BrowserImportDialog } from '@/components/browser-import/browser-import-dialog' + +const PROFILE = { + id: 'chrome:Default', + label: 'Chrome', + browserId: 'chrome', + browserLabel: 'Chrome', + profileLabel: 'Default', +} +const SUCCESS: BrowserChromeImportResult = { + cookies: { cookiesImported: 4, cookiesSkipped: 0 }, + passwords: { passwordsAdded: 2, passwordsUpdated: 1, passwordsSkipped: 0 }, +} + +let container: HTMLDivElement +let root: Root +const onImported = vi.fn(async () => {}) +const onOpenChange = vi.fn() + +async function render(open = true) { + await act(async () => { + root.render( + + ) + }) +} + +function button(label: string): HTMLButtonElement { + const match = [...document.querySelectorAll('button')].find((node) => node.textContent === label) + if (!match) throw new Error(`Missing button: ${label}`) + return match +} + +async function importProfile() { + await act(async () => button('Import').click()) +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + vi.clearAllMocks() + mockBridge.mockReturnValue({ browserImport: importer }) + importer.listChromeProfiles.mockResolvedValue([PROFILE]) + importer.importFromChrome.mockResolvedValue(SUCCESS) + for (const variant of ['success', 'warning', 'error', 'info'] as const) { + vi.spyOn(toast, variant).mockReturnValue('toast-id') + } +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.restoreAllMocks() +}) + +describe('BrowserImportDialog', () => { + it('discovers profiles only on opening and refreshes them on reopening', async () => { + await render(false) + expect(importer.listChromeProfiles).not.toHaveBeenCalled() + await render() + expect(importer.listChromeProfiles).toHaveBeenCalledOnce() + expect(importer.importFromChrome).not.toHaveBeenCalled() + await render(false) + await render() + expect(importer.listChromeProfiles).toHaveBeenCalledTimes(2) + }) + + it('imports directly from the click and refreshes its caller', async () => { + await render() + act(() => { + button('Import').click() + expect(importer.importFromChrome).toHaveBeenCalledWith('chrome:Default', 'replace') + }) + await act(async () => {}) + expect(toast.success).toHaveBeenCalledWith('Imported 4 cookies and 3 passwords from Chrome') + expect(onImported).toHaveBeenCalledOnce() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it.each(['cookies', 'passwords'] as const)( + 'keeps %s failures visible after partial success', + async (kind) => { + importer.importFromChrome.mockResolvedValue({ + ...SUCCESS, + [kind]: + kind === 'cookies' + ? { cookiesImported: 0, cookiesSkipped: 0, error: 'profile-unreadable' } + : { + passwordsAdded: 0, + passwordsUpdated: 0, + passwordsSkipped: 0, + error: 'vault-unavailable', + }, + }) + await render() + await importProfile() + expect(toast.warning).toHaveBeenCalledWith( + expect.stringContaining(kind === 'cookies' ? 'Cookies:' : 'Passwords:') + ) + expect(toast.success).not.toHaveBeenCalled() + expect(onOpenChange).not.toHaveBeenCalled() + expect(onImported).toHaveBeenCalledOnce() + } + ) + + it('does not call an unchanged import a failure', async () => { + importer.importFromChrome.mockResolvedValue({ + cookies: { cookiesImported: 0, cookiesSkipped: 0 }, + passwords: { passwordsAdded: 0, passwordsUpdated: 0, passwordsSkipped: 3 }, + }) + await render() + await importProfile() + expect(toast.info).toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('reports saved passwords alongside a failure to read another password store', async () => { + importer.importFromChrome.mockResolvedValue({ + ...SUCCESS, + passwords: { ...SUCCESS.passwords, error: 'unsupported-schema' }, + }) + await render() + await importProfile() + expect(toast.warning).toHaveBeenCalledWith( + expect.stringContaining('Imported 4 cookies and 3 passwords from Chrome. Passwords:') + ) + expect(toast.success).not.toHaveBeenCalled() + expect(onOpenChange).not.toHaveBeenCalled() + expect(onImported).toHaveBeenCalledOnce() + }) + + it('blocks duplicate clicks, Escape and Close until import settles', async () => { + let finish!: (result: BrowserChromeImportResult) => void + importer.importFromChrome.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + await render() + act(() => { + const trigger = button('Import') + trigger.click() + trigger.click() + }) + expect(importer.importFromChrome).toHaveBeenCalledOnce() + expect(button('Importing...').disabled).toBe(true) + expect(button('Cancel').disabled).toBe(true) + expect(button('Close').disabled).toBe(true) + act(() => + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ) + ) + expect(onOpenChange).not.toHaveBeenCalled() + await act(async () => finish(SUCCESS)) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('explains discovery failures and disables import', async () => { + importer.listChromeProfiles.mockRejectedValue(new Error('Unavailable')) + await render() + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Could not load browser profiles' + ) + expect(button('Import').disabled).toBe(true) + }) + + it('explains when no supported profiles exist', async () => { + importer.listChromeProfiles.mockResolvedValue([]) + await render() + expect(document.querySelector('[role="status"]')?.textContent).toContain( + 'No supported browser profiles' + ) + expect(button('Import').disabled).toBe(true) + }) + + it('reports import failures and allows retry', async () => { + importer.importFromChrome.mockRejectedValueOnce(new Error('Unavailable')) + await render() + await importProfile() + expect(toast.error).toHaveBeenCalledWith('Could not import from that browser') + expect(onOpenChange).not.toHaveBeenCalled() + await importProfile() + expect(onImported).toHaveBeenCalledOnce() + }) + + it('does not describe a refresh failure as an import failure', async () => { + onImported.mockRejectedValueOnce(new Error('Unavailable')) + await render() + await importProfile() + expect(toast.success).toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith( + 'Import finished, but the browser could not refresh. Try reopening it.' + ) + }) +}) diff --git a/apps/sim/components/browser-import/browser-import-dialog.tsx b/apps/sim/components/browser-import/browser-import-dialog.tsx new file mode 100644 index 00000000000..93032ca0e1c --- /dev/null +++ b/apps/sim/components/browser-import/browser-import-dialog.tsx @@ -0,0 +1,128 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import type { + BrowserChromeImportResult, + BrowserImportError, + BrowserImportProfile, +} from '@sim/desktop-bridge' +import { toast } from '@sim/emcn' +import { ImportModal } from '@/components/browser-import/import-modal' +import { getDesktopBridge } from '@/lib/desktop' + +const IMPORT_ERROR_MESSAGES: Record = { + 'unsupported-platform': 'Importing from another browser is only supported on macOS.', + 'chrome-not-found': 'Could not find that browser profile.', + 'keychain-unavailable': + 'Sim needs your permission to read that browser’s saved data. Allow the Keychain prompt and try again.', + 'profile-unreadable': + 'Could not read that browser’s data. Try quitting the other browser, then import again.', + 'unsupported-schema': 'That browser stores its data in a format Sim cannot read yet.', + 'nothing-imported': 'Nothing from that profile could be imported.', + 'vault-unavailable': + 'This device cannot store passwords securely, so saved passwords were not imported.', + unknown: 'Could not import from that browser.', +} + +/** Counts only what landed; importing a cookie does not guarantee a signed-in session. */ +function importSummary({ cookies, passwords }: BrowserChromeImportResult): string | null { + const parts: string[] = [] + const saved = passwords.passwordsAdded + passwords.passwordsUpdated + if (cookies.cookiesImported > 0) { + parts.push(`${cookies.cookiesImported} ${cookies.cookiesImported === 1 ? 'cookie' : 'cookies'}`) + } + if (saved > 0) parts.push(`${saved} ${saved === 1 ? 'password' : 'passwords'}`) + return parts.length > 0 ? `Imported ${parts.join(' and ')}` : null +} + +interface BrowserImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onImported: () => Promise +} + +/** Shares the same user-initiated import flow between Settings and the browser panel. */ +export function BrowserImportDialog({ open, onOpenChange, onImported }: BrowserImportDialogProps) { + const [profiles, setProfiles] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState() + const [pending, setPending] = useState(false) + const importInFlight = useRef(false) + + useEffect(() => { + if (!open) return + let active = true + setLoading(true) + setError(undefined) + setProfiles([]) + const listProfiles = getDesktopBridge()?.browserImport?.listChromeProfiles + if (!listProfiles) { + setError('Browser import is not available on this device.') + setLoading(false) + return + } + void listProfiles() + .then((next) => { + if (active) setProfiles(next) + }) + .catch(() => { + if (active) setError('Could not load browser profiles. Close this dialog and try again.') + }) + .finally(() => { + if (active) setLoading(false) + }) + return () => { + active = false + } + }, [open]) + + async function importFromBrowser(profile: BrowserImportProfile) { + const runImport = getDesktopBridge()?.browserImport?.importFromChrome + if (!runImport || importInFlight.current) return + importInFlight.current = true + setPending(true) + try { + /** The IPC call must precede any await to preserve the click's user activation. */ + const result = await runImport(profile.id, 'replace') + const summary = importSummary(result) + const failures: string[] = [] + if (result.cookies.error) { + failures.push(`Cookies: ${IMPORT_ERROR_MESSAGES[result.cookies.error]}`) + } + if (result.passwords.error) { + failures.push(`Passwords: ${IMPORT_ERROR_MESSAGES[result.passwords.error]}`) + } + if (failures.length > 0) { + const message = [summary && `${summary} from ${profile.label}.`, ...failures] + .filter(Boolean) + .join(' ') + if (summary) toast.warning(message) + else toast.error(message) + } else { + if (summary) toast.success(`${summary} from ${profile.label}`) + else toast.info('No new data was imported. Existing saved passwords were kept.') + onOpenChange(false) + } + await onImported().catch(() => { + toast.error('Import finished, but the browser could not refresh. Try reopening it.') + }) + } catch { + toast.error('Could not import from that browser') + } finally { + importInFlight.current = false + setPending(false) + } + } + + return ( + void importFromBrowser(profile)} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.test.tsx b/apps/sim/components/browser-import/import-modal.test.tsx similarity index 96% rename from apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.test.tsx rename to apps/sim/components/browser-import/import-modal.test.tsx index 05fea55f6a8..b7c402e937f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.test.tsx +++ b/apps/sim/components/browser-import/import-modal.test.tsx @@ -8,12 +8,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true -/** ChipSelect stands in as a native select so options are inspectable. */ vi.mock('@sim/emcn', () => ({ ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) => open ?
    {children}
    : null, ChipModalHeader: ({ children }: { children: ReactNode }) =>

    {children}

    , ChipModalBody: ({ children }: { children: ReactNode }) =>
    {children}
    , + ChipModalError: ({ children }: { children?: ReactNode }) => + children ?

    {children}

    : null, ChipModalFooter: ({ onCancel, primaryAction, @@ -58,7 +59,7 @@ vi.mock('@sim/emcn', () => ({ ), })) -import { ImportModal } from '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal' +import { ImportModal } from '@/components/browser-import/import-modal' function profile( id: string, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.tsx b/apps/sim/components/browser-import/import-modal.tsx similarity index 67% rename from apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.tsx rename to apps/sim/components/browser-import/import-modal.tsx index b140c7c9747..bc1c9acd02b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal.tsx +++ b/apps/sim/components/browser-import/import-modal.tsx @@ -1,10 +1,11 @@ 'use client' -import { useMemo, useState } from 'react' +import { useState } from 'react' import type { BrowserImportProfile } from '@sim/desktop-bridge' import { ChipModal, ChipModalBody, + ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, @@ -15,6 +16,8 @@ interface ImportModalProps { onOpenChange: (open: boolean) => void /** Every importable profile across every detected browser. */ profiles: BrowserImportProfile[] + loading?: boolean + error?: string pending: boolean onImport: (profile: BrowserImportProfile) => void } @@ -37,8 +40,16 @@ function browserOptions(profiles: BrowserImportProfile[]) { * single identity it takes on, and there is no coherent "all of them" (two * profiles' cookies for the same site would just overwrite each other). */ -export function ImportModal({ open, onOpenChange, profiles, pending, onImport }: ImportModalProps) { - const browsers = useMemo(() => browserOptions(profiles), [profiles]) +export function ImportModal({ + open, + onOpenChange, + profiles, + loading = false, + error, + pending, + onImport, +}: ImportModalProps) { + const browsers = browserOptions(profiles) const [pickedBrowserId, setPickedBrowserId] = useState(browsers[0]?.value ?? '') /** @@ -51,10 +62,7 @@ export function ImportModal({ open, onOpenChange, profiles, pending, onImport }: ? pickedBrowserId : (browsers[0]?.value ?? '') - const profilesForBrowser = useMemo( - () => profiles.filter((profile) => profile.browserId === browserId), - [browserId, profiles] - ) + const profilesForBrowser = profiles.filter((profile) => profile.browserId === browserId) const [pickedProfileId, setPickedProfileId] = useState(profilesForBrowser[0]?.id ?? '') const profileId = profilesForBrowser.some((profile) => profile.id === pickedProfileId) @@ -64,16 +72,31 @@ export function ImportModal({ open, onOpenChange, profiles, pending, onImport }: const selected = profilesForBrowser.find((profile) => profile.id === profileId) ?? null return ( - + onOpenChange(false)}> Import from your browser -

    - Copies cookies and saved passwords into Sim’s browser, and reads which sites you use there - so the address bar can suggest them. The other browser is only read, never changed, and - nothing is uploaded. +

    + Copy cookies, saved passwords, and address-bar suggestions into Sim. Your other browser + stays unchanged and nothing is uploaded. Future changes are not synced. +

    +

    + Passwords stay encrypted on this device until you delete them or sign out of Sim. Website + sessions may still expire.

    + {(loading || (!error && profiles.length === 0)) && ( +

    + {loading + ? 'Looking for browser profiles…' + : 'No supported browser profiles were found on this device.'} +

    + )} + {!loading && error}
    onOpenChange(false)} cancelDisabled={pending} primaryAction={{ label: pending ? 'Importing...' : 'Import', - disabled: pending || selected === null, + disabled: loading || pending || selected === null, onClick: () => { if (selected) onImport(selected) }, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 23ddfd3d0ce..867a81af5c2 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2710,6 +2710,25 @@ export function BrexIcon(props: SVGProps) { ) } +/** + * Official QuickBooks circular mark, cropped from the user-supplied + * Intuit_QuickBooks_logo.svg wordmark. + */ +export function QuickBooksIcon(props: SVGProps) { + return ( + + + + + ) +} + export function BrightDataIcon(props: SVGProps) { return ( ) { ) } +export function SSMIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function SQSIcon(props: SVGProps) { return ( ) { ) } +export function CloudTrailIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function CloudWatchIcon(props: SVGProps) { return ( { }) }) +describe('useUpdateWorkspaceFileContent version precondition', () => { + it('forwards the supplied content version and disables mutation retries', async () => { + const client = new QueryClient({ defaultOptions: { mutations: { retry: 3 } } }) + const container = document.createElement('div') + const root = createRoot(container) + let mutation: ReturnType | undefined + function Probe() { + mutation = useUpdateWorkspaceFileContent() + return null + } + await act(async () => + root.render( + + + + ) + ) + const expectedUpdatedAt = '2026-09-03T20:00:00.000Z' + mockRequestJson.mockRejectedValueOnce(new Error('conflict')) + await act(async () => { + await expect( + mutation?.mutateAsync({ + workspaceId: 'ws-1', + fileId: 'file-1', + content: 'draft', + expectedUpdatedAt, + }) + ).rejects.toThrow('conflict') + }) + expect(mockRequestJson).toHaveBeenCalledExactlyOnceWith(updateWorkspaceFileContentContract, { + params: { id: 'ws-1', fileId: 'file-1' }, + body: { content: 'draft', expectedUpdatedAt }, + }) + act(() => root.unmount()) + client.clear() + }) +}) + +describe('useReloadWorkspaceFileContent', () => { + const file: WorkspaceFileRecord = { + id: 'file-1', + workspaceId: 'ws-1', + name: 'notes.md', + key: 'workspace/ws-1/immutable-new-notes.md', + path: '/notes.md', + size: 12, + type: 'text/markdown', + uploadedBy: 'user-1', + uploadedAt: new Date('2026-09-03T20:00:00.000Z'), + updatedAt: new Date('2026-09-03T20:00:01.000Z'), + contentUpdatedAt: new Date('2026-09-03T20:00:01.000Z'), + } + let client: QueryClient + let root: Root + let mutation: ReturnType + + beforeEach(() => { + client = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, + mutations: { retry: 3, retryDelay: 0 }, + }, + }) + root = createRoot(document.createElement('div')) + mockRequestJson.mockResolvedValue({ success: true, files: [file] }) + function Probe() { + mutation = useReloadWorkspaceFileContent() + return null + } + act(() => { + root.render( + + + + ) + }) + }) + + afterEach(() => { + act(() => root.unmount()) + client.clear() + }) + + it.each([false, true])('forces matching fresh metadata and bytes for raw=%s', async (raw) => { + const oldFile = { + ...file, + key: 'workspace/ws-1/immutable-old-notes.md', + contentUpdatedAt: file.uploadedAt, + } + const contentKey = workspaceFilesKeys.content('ws-1', file.id, raw ? 'raw' : 'text', file.key) + client.setQueryData(workspaceFilesKeys.list('ws-1'), [oldFile]) + client.setQueryData(contentKey, 'previously cached bytes') + const fetchMock = vi.fn(async () => new Response('fresh bytes', { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + await act(async () => { + await expect( + mutation.mutateAsync({ workspaceId: 'ws-1', fileId: file.id, raw }) + ).resolves.toEqual({ + file, + content: 'fresh bytes', + }) + }) + + expect(mockRequestJson).toHaveBeenCalledExactlyOnceWith(listWorkspaceFilesContract, { + params: { id: 'ws-1' }, + query: { scope: 'active' }, + signal: expect.any(AbortSignal), + }) + expect(fetchMock).toHaveBeenCalledExactlyOnceWith(expect.any(String), { + signal: expect.any(AbortSignal), + cache: 'no-store', + }) + const url = new URL(vi.mocked(fetch).mock.calls[0]?.[0] as string, 'http://localhost') + expect(url.pathname).toBe(`/api/files/serve/${encodeURIComponent(file.key)}`) + expect(url.searchParams.get('context')).toBe('workspace') + expect(url.searchParams.has('t')).toBe(true) + expect(url.searchParams.get('raw')).toBe(raw ? '1' : null) + expect(client.getQueryData(contentKey)).toBe('fresh bytes') + expect(client.getQueryData(workspaceFilesKeys.list('ws-1'))).toEqual([file]) + }) + + it.each([false, true])( + 'recovers matching bytes and version after key rotation (raw=%s)', + async (raw) => { + const nextFile = { + ...file, + key: 'workspace/ws-1/replacement.md', + contentUpdatedAt: new Date('2026-09-03T20:00:02.000Z'), + } + client.setDefaultOptions({ + queries: { retry: 2, retryDelay: 0 }, + mutations: { retryDelay: 0 }, + }) + mockRequestJson + .mockResolvedValueOnce({ success: true, files: [file] }) + .mockResolvedValueOnce({ success: true, files: [nextFile] }) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('gone', { status: 404 })) + .mockResolvedValueOnce(new Response('replacement bytes')) + vi.stubGlobal('fetch', fetchMock) + await act(async () => { + await expect( + mutation.mutateAsync({ workspaceId: 'ws-1', fileId: file.id, raw }) + ).resolves.toEqual({ file: nextFile, content: 'replacement bytes' }) + }) + expect(mockRequestJson).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect( + fetchMock.mock.calls.map(([url]) => new URL(url, 'http://localhost').pathname) + ).toEqual( + [file.key, nextFile.key].map((key) => `/api/files/serve/${encodeURIComponent(key)}`) + ) + expect( + client.getQueryData( + workspaceFilesKeys.content('ws-1', file.id, raw ? 'raw' : 'text', nextFile.key) + ) + ).toBe('replacement bytes') + } + ) + + it.each([ + { files: [], error: 'File no longer exists' }, + { + files: [{ ...file, contentUpdatedAt: null }], + error: 'The latest file version is unavailable', + }, + ])('stops key recovery when metadata becomes unusable: $error', async ({ files, error }) => { + mockRequestJson + .mockResolvedValueOnce({ success: true, files: [file] }) + .mockResolvedValueOnce({ success: true, files }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('gone', { status: 404 })) + ) + await act(async () => { + await expect( + mutation.mutateAsync({ workspaceId: 'ws-1', fileId: file.id, raw: false }) + ).rejects.toThrow(error) + }) + expect(mockRequestJson).toHaveBeenCalledTimes(2) + expect(fetch).toHaveBeenCalledOnce() + }) + + it('does not retry a transport failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) + await act(async () => { + await expect( + mutation.mutateAsync({ workspaceId: 'ws-1', fileId: file.id, raw: false }) + ).rejects.toThrow('offline') + }) + expect(mockRequestJson).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledOnce() + }) + + it('cancels a pending metadata read before resolving the latest version', async () => { + const pending = Promise.withResolvers<{ success: boolean; files: WorkspaceFileRecord[] }>() + let oldSignal: AbortSignal | undefined + mockRequestJson.mockImplementationOnce( + (_contract: unknown, input: { signal?: AbortSignal }) => { + oldSignal = input.signal + return pending.promise + } + ) + const previousRead = client + .fetchQuery({ + queryKey: workspaceFilesKeys.list('ws-1'), + queryFn: ({ signal }) => mockRequestJson(listWorkspaceFilesContract, { signal }), + }) + .then( + () => 'resolved', + () => 'cancelled' + ) + + await act(async () => { + await expect( + mutation.mutateAsync({ workspaceId: 'ws-1', fileId: file.id, raw: false }) + ).resolves.toEqual({ + file, + content: '# content', + }) + }) + + expect(oldSignal?.aborted).toBe(true) + expect(await previousRead).toBe('cancelled') + expect(mockRequestJson).toHaveBeenCalledTimes(2) + pending.resolve({ success: true, files: [] }) + await pending.promise + expect(client.getQueryData(workspaceFilesKeys.list('ws-1'))).toEqual([file]) + }) + + it.each([false, true])( + 'forwards cancellation without accepting content (retry=%s)', + async (retry) => { + const started = Promise.withResolvers() + const pending = Promise.withResolvers() + let contentSignal: AbortSignal | null | undefined + let attempt = 0 + vi.stubGlobal( + 'fetch', + vi.fn((_url: RequestInfo | URL, init?: RequestInit) => { + if (retry && attempt++ === 0) + return Promise.resolve(new Response('gone', { status: 404 })) + contentSignal = init?.signal + started.resolve() + return pending.promise + }) + ) + let outcome: Promise | undefined + await act(async () => { + outcome = mutation.mutateAsync({ workspaceId: 'ws-1', fileId: file.id, raw: true }).then( + () => 'resolved', + () => 'cancelled' + ) + await started.promise + await client.cancelQueries({ queryKey: workspaceFilesKeys.contentFile('ws-1', file.id) }) + }) + expect(contentSignal?.aborted).toBe(true) + expect(await outcome).toBe('cancelled') + expect(mockRequestJson).toHaveBeenCalledTimes(retry ? 2 : 1) + pending.resolve(new Response('late bytes', { status: 200 })) + await pending.promise + expect( + client.getQueryData(workspaceFilesKeys.content('ws-1', file.id, 'raw', file.key)) + ).toBeUndefined() + } + ) + + it.each([ + { files: [], error: 'File no longer exists' }, + { + files: [{ ...file, contentUpdatedAt: null }], + error: 'The latest file version is unavailable', + }, + ])('rejects unusable metadata: $error', async ({ files, error }) => { + mockRequestJson.mockResolvedValue({ success: true, files }) + await act(async () => { + await expect( + mutation.mutateAsync({ workspaceId: 'ws-1', fileId: file.id, raw: false }) + ).rejects.toThrow(error) + }) + expect(fetch).not.toHaveBeenCalled() + expect(mockRequestJson).toHaveBeenCalledTimes(1) + }) + + it('propagates metadata failure without reading bytes or retrying the operation', async () => { + mockRequestJson.mockRejectedValue(new Error('metadata offline')) + await act(async () => { + await expect( + mutation.mutateAsync({ workspaceId: 'ws-1', fileId: file.id, raw: false }) + ).rejects.toThrow('metadata offline') + }) + expect(fetch).not.toHaveBeenCalled() + expect(mockRequestJson).toHaveBeenCalledTimes(1) + }) + + it.each([404, 500])( + 'propagates a %s byte failure without replacing cached bytes', + async (status) => { + const contentKey = workspaceFilesKeys.content('ws-1', file.id, 'text', file.key) + client.setQueryData(contentKey, 'cached bytes') + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('unavailable', { status })) + ) + await act(async () => { + await expect( + mutation.mutateAsync({ workspaceId: 'ws-1', fileId: file.id, raw: false }) + ).rejects.toThrow( + status === 404 + ? 'File content is no longer at the requested storage key' + : 'Failed to fetch file content' + ) + }) + expect(fetch).toHaveBeenCalledTimes(status === 404 ? 2 : 1) + expect(mockRequestJson).toHaveBeenCalledTimes(status === 404 ? 2 : 1) + expect(client.getQueryData(contentKey)).toBe('cached bytes') + } + ) +}) + /** * A content update rewrites the file under a NEW storage key and deletes the old object, so the key * held by an open tab goes dead — every few seconds while a collaborative document is being edited, diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index da45c0f0ac4..304f9761e72 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -19,6 +19,7 @@ import { listWorkspaceFilesContract, renameWorkspaceFileContract, restoreWorkspaceFileContract, + type UpdateWorkspaceFileContentBody, updateWorkspaceFileContentContract, updateWorkspaceFileDimensionsContract, } from '@/lib/api/contracts/workspace-files' @@ -584,23 +585,23 @@ export function useCreateWorkspaceFile() { /** * Update workspace file content mutation */ -interface UpdateFileContentParams { +interface UpdateFileContentParams extends UpdateWorkspaceFileContentBody { workspaceId: string fileId: string - content: string - encoding?: 'base64' | 'utf-8' } export function useUpdateWorkspaceFileContent() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ workspaceId, fileId, content, encoding }: UpdateFileContentParams) => { + retry: false, + mutationFn: async ({ workspaceId, fileId, ...body }: UpdateFileContentParams) => { return requestJson(updateWorkspaceFileContentContract, { params: { id: workspaceId, fileId }, - body: encoding ? { content, encoding } : { content }, + body, }) }, + /** A lost response may follow a committed write; reconcile bytes and versions even after transport errors. */ onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(variables.workspaceId, variables.fileId), @@ -616,6 +617,41 @@ export function useUpdateWorkspaceFileContent() { }) } +/** Reloads matching immutable bytes and their version before a user discards a conflicting draft. */ +export function useReloadWorkspaceFileContent() { + const queryClient = useQueryClient() + const source = useFileContentSource() + return useMutation({ + mutationFn: async ({ + workspaceId, + fileId, + raw, + }: { + workspaceId: string + fileId: string + raw: boolean + }) => { + await queryClient.cancelQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) }) + const files = await queryClient.fetchQuery({ + ...getWorkspaceFilesQueryOptions(workspaceId), + staleTime: 0, + }) + const file = files.find((record) => record.id === fileId) + if (!file) throw new Error('File no longer exists') + if (!file.contentUpdatedAt) throw new Error('The latest file version is unavailable') + const content = await queryClient.fetchQuery({ + queryKey: workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text', file.key), + queryFn: ({ signal }) => + fetchWorkspaceFileContent(source.buildUrl(file.key, { raw, bust: true }), signal), + staleTime: 0, + retry: false, + }) + return { file, content } + }, + retry: (failureCount, error) => failureCount < 1 && error instanceof StaleStorageKeyError, + }) +} + /** * Rename a workspace file */ diff --git a/apps/sim/hooks/use-autosave.test.tsx b/apps/sim/hooks/use-autosave.test.tsx index 0f035fee248..25f1acb92f5 100644 --- a/apps/sim/hooks/use-autosave.test.tsx +++ b/apps/sim/hooks/use-autosave.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act } from 'react' +import { act, Suspense, startTransition } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -28,6 +28,7 @@ interface ProbeProps { onSave: (overrideContent?: string) => Promise delay?: number enabled?: boolean + pauseSaving?: boolean draftKey?: string onRestoreDraft?: (content: string) => void onDiscardCorrectionFailed?: () => void @@ -92,8 +93,47 @@ async function flush() { }) } +/** Holds the committed UI while a transition renders a suspended draft. */ +function renderSuspendingAutosave(initial: ProbeProps) { + const container = document.createElement('div') + const root = createRoot(container) + const pending = new Promise(() => {}) + let latest: ReturnType | null = null + + function Probe({ suspend, ...options }: ProbeProps & { suspend: boolean }) { + const autosave = useAutosave(options) + if (suspend) throw pending + latest = autosave + return
    {options.content}
    + } + + const render = async (options: ProbeProps, suspend = false) => { + const view = ( + Loading
    }> + + + ) + await act(async () => { + if (suspend) startTransition(() => root.render(view)) + else root.render(view) + }) + } + + return { + container, + render, + mount: () => render(initial), + current: () => { + if (!latest) throw new Error('Autosave probe has not committed') + return latest + }, + unmount: () => act(async () => root.unmount()), + } +} + describe('useAutosave', () => { beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) vi.useFakeTimers() fakeDraftStore.clear() }) @@ -103,6 +143,85 @@ describe('useAutosave', () => { vi.restoreAllMocks() }) + it('keeps saving and local recovery tied to the committed draft after a suspended render', async () => { + const committedSave = vi.fn(async () => {}) + const abandonedSave = vi.fn(async () => {}) + const options: ProbeProps = { + content: 'committed draft', + savedContent: 'baseline', + onSave: committedSave, + delay: 60_000, + draftKey: 'committed-file', + } + const view = renderSuspendingAutosave(options) + try { + await view.mount() + const committed = view.current() + await view.render({ ...options, content: 'abandoned draft', onSave: abandonedSave }, true) + expect(view.container.textContent).toBe('committed draft') + + await act(async () => vi.advanceTimersByTime(400)) + expect(fakeDraftStore.get('autosave-draft:committed-file')).toEqual({ + content: 'committed draft', + savedContent: 'baseline', + }) + + await act(async () => committed.saveImmediately()) + expect(committedSave).toHaveBeenCalledOnce() + expect(abandonedSave).not.toHaveBeenCalled() + } finally { + await view.unmount() + } + }) + + it('retains a failed discard correction when an abandoned render changes draft and file identity', async () => { + let resolveSave: (() => void) | undefined + const onSave = vi + .fn<(overrideContent?: string) => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSave = resolve + }) + ) + .mockRejectedValueOnce(new Error('correction failed')) + .mockResolvedValue(undefined) + const options: ProbeProps = { + content: 'baseline', + savedContent: 'baseline', + onSave, + delay: 60_000, + draftKey: 'committed-file', + } + const view = renderSuspendingAutosave(options) + try { + await view.mount() + await view.render({ ...options, content: 'local draft' }) + let pendingSave: Promise | undefined + await act(async () => { + pendingSave = view.current().saveImmediately() + }) + act(() => view.current().discard()) + await view.render(options) + await act(async () => { + resolveSave?.() + await pendingSave + }) + await flush() + expect(onSave).toHaveBeenCalledTimes(2) + expect(view.current().saveStatus).toBe('error') + + const committed = view.current() + await view.render({ ...options, content: 'abandoned edit', draftKey: 'abandoned-file' }, true) + expect(view.container.textContent).toBe('baseline') + await act(async () => committed.saveImmediately()) + expect(onSave).toHaveBeenCalledTimes(3) + expect(onSave).toHaveBeenLastCalledWith('baseline') + } finally { + await view.unmount() + } + }) + it('debounces edits into a single save after the delay', async () => { const onSave = vi.fn(async () => {}) const { handle, props } = renderAutosave({ @@ -1027,6 +1146,45 @@ describe('useAutosave', () => { expect(onSave).toHaveBeenLastCalledWith() }) + it.each([false, true])( + 'retains and reports a paused discard correction (unmounted=%s)', + async (unmounted) => { + const pending = Promise.withResolvers() + const onSave = vi + .fn<(content?: string) => Promise>() + .mockReturnValueOnce(pending.promise) + .mockResolvedValue(undefined) + const onDiscardCorrectionFailed = vi.fn() + const { handle } = renderAutosave({ + content: 'baseline', + savedContent: 'baseline', + onSave, + draftKey: 'paused-correction', + onDiscardCorrectionFailed, + }) + handle.rerender({ content: 'discarded draft' }) + await act(async () => vi.advanceTimersByTime(1500)) + expect(onSave).toHaveBeenCalledOnce() + act(() => handle.discard()) + handle.rerender({ content: 'baseline', pauseSaving: true }) + if (unmounted) handle.unmount() + await act(async () => pending.resolve()) + await flush() + expect(onSave).toHaveBeenCalledOnce() + expect(onDiscardCorrectionFailed).toHaveBeenCalledOnce() + if (!unmounted) { + expect(handle.status()).toBe('error') + handle.rerender({ pauseSaving: false }) + expect(onSave).toHaveBeenCalledOnce() + await act(async () => handle.saveImmediately()) + expect(onSave).toHaveBeenLastCalledWith('baseline') + expect(onSave).toHaveBeenCalledTimes(2) + expect(handle.status()).toBe('idle') + handle.unmount() + } + } + ) + it('does not lift discard suppression when only `enabled` toggles for the same document', async () => { const resolvers: Array<() => void> = [] const rejecters: Array<(error: Error) => void> = [] diff --git a/apps/sim/hooks/use-autosave.ts b/apps/sim/hooks/use-autosave.ts index aaf2d3d6b88..ba38b89960d 100644 --- a/apps/sim/hooks/use-autosave.ts +++ b/apps/sim/hooks/use-autosave.ts @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { del, get, set } from 'idb-keyval' @@ -41,6 +41,8 @@ interface UseAutosaveOptions { onSave: (overrideContent?: string) => Promise delay?: number enabled?: boolean + /** Suspends network writes without disabling local draft persistence or recovery. */ + pauseSaving?: boolean /** * Uniquely identifies the document being edited (e.g. a file id). When set, the draft is * mirrored into IndexedDB on a short debounce, independent of the network save, and recovered @@ -48,6 +50,8 @@ interface UseAutosaveOptions { */ draftKey?: string onRestoreDraft?: (content: string) => void + /** Retains a recovered draft when its original server baseline is no longer current. */ + onRestoreConflictingDraft?: (content: string) => void /** Called if `discard()`'s corrective save fails — the only way that failure can surface, since it happens after the component may already have unmounted. */ onDiscardCorrectionFailed?: () => void } @@ -57,7 +61,7 @@ interface UseAutosaveReturn { saveImmediately: () => Promise isDirty: boolean /** Abandons the current draft: blocks any save/local-draft write not yet started, clears the local draft immediately, and corrects the server if a save already in flight lands afterward. */ - discard: () => void + discard: (options?: { correctInFlightSave?: boolean }) => void } /** @@ -71,8 +75,10 @@ export function useAutosave({ onSave, delay = 1500, enabled = true, + pauseSaving = false, draftKey, onRestoreDraft, + onRestoreConflictingDraft, onDiscardCorrectionFailed, }: UseAutosaveOptions): UseAutosaveReturn { const [saveStatus, setSaveStatus] = useState('idle') @@ -82,25 +88,20 @@ export function useAutosave({ const displayTimerRef = useRef>(undefined) const savingRef = useRef(false) const savingStartRef = useRef(0) + const saveGenerationRef = useRef(0) const inFlightRef = useRef | null>(null) const unmountedRef = useRef(false) const onSaveRef = useRef(onSave) - onSaveRef.current = onSave const enabledRef = useRef(enabled) - enabledRef.current = enabled - + const pauseSavingRef = useRef(pauseSaving) const savedContentRef = useRef(savedContent) - savedContentRef.current = savedContent const contentRef = useRef(content) - contentRef.current = content const effectiveDraftKey = enabled ? draftKey : undefined const draftKeyRef = useRef(effectiveDraftKey) - draftKeyRef.current = effectiveDraftKey const onRestoreDraftRef = useRef(onRestoreDraft) - onRestoreDraftRef.current = onRestoreDraft + const onRestoreConflictingDraftRef = useRef(onRestoreConflictingDraft) const onDiscardCorrectionFailedRef = useRef(onDiscardCorrectionFailed) - onDiscardCorrectionFailedRef.current = onDiscardCorrectionFailed const localDraftTimerRef = useRef>(undefined) const lastPersistedContentRef = useRef(null) @@ -108,22 +109,31 @@ export function useAutosave({ const discardedRef = useRef(false) const discardTargetRef = useRef(null) const failedCorrectionTargetRef = useRef(null) - // Keyed off the raw `draftKey`, not `effectiveDraftKey` — the latter also flips with `enabled` - // (e.g. a streaming lock toggling for the SAME document), which must not be mistaken for a - // hook instance being reused across documents (today's callers all remount per file instead). + /** Disabling autosave during a stream must not count as switching documents. */ const documentKeyRef = useRef(draftKey) - const documentChanged = documentKeyRef.current !== draftKey - documentKeyRef.current = draftKey - if (documentChanged) { - discardedRef.current = false - failedCorrectionTargetRef.current = null - } + + /** Timers, recovery, and explicit saves must never observe a suspended or abandoned render. */ + useLayoutEffect(() => { + if ( + documentKeyRef.current !== draftKey || + (discardedRef.current && content !== discardTargetRef.current) + ) { + discardedRef.current = false + failedCorrectionTargetRef.current = null + } + documentKeyRef.current = draftKey + onSaveRef.current = onSave + enabledRef.current = enabled + pauseSavingRef.current = pauseSaving + savedContentRef.current = savedContent + contentRef.current = content + draftKeyRef.current = effectiveDraftKey + onRestoreDraftRef.current = onRestoreDraft + onRestoreConflictingDraftRef.current = onRestoreConflictingDraft + onDiscardCorrectionFailedRef.current = onDiscardCorrectionFailed + }) const isDirty = content !== savedContent - if (discardedRef.current && content !== discardTargetRef.current) { - discardedRef.current = false - failedCorrectionTargetRef.current = null - } const persistLocalDraft = useCallback(() => { const key = draftKeyRef.current @@ -158,12 +168,14 @@ export function useAutosave({ if ( discardedRef.current || !enabledRef.current || + pauseSavingRef.current || savingRef.current || contentRef.current === savedContentRef.current ) { return } savingRef.current = true + const generation = saveGenerationRef.current savingStartRef.current = Date.now() if (!unmountedRef.current) setSaveStatus('saving') const run = (async () => { @@ -174,7 +186,10 @@ export function useAutosave({ nextStatus = 'error' } finally { inFlightRef.current = null - if (unmountedRef.current) { + if (generation !== saveGenerationRef.current) { + savingRef.current = false + if (!unmountedRef.current && contentRef.current !== savedContentRef.current) save() + } else if (unmountedRef.current) { savingRef.current = false } else { const elapsed = Date.now() - savingStartRef.current @@ -202,11 +217,11 @@ export function useAutosave({ }, []) useEffect(() => { - if (!enabled || !isDirty || savingRef.current) return + if (!enabled || pauseSaving || !isDirty || savingRef.current) return clearTimeout(timerRef.current) timerRef.current = setTimeout(save, delay) return () => clearTimeout(timerRef.current) - }, [content, enabled, isDirty, delay, save]) + }, [content, enabled, pauseSaving, isDirty, delay, save]) useEffect(() => { // Reset on every (re)mount, not only set on unmount: React strict mode runs effects @@ -223,6 +238,7 @@ export function useAutosave({ if ( discardedRef.current || !enabledRef.current || + pauseSavingRef.current || contentRef.current === savedContentRef.current ) { return @@ -232,7 +248,7 @@ export function useAutosave({ // latest sequentially (last) prevents an out-of-order completion from clobbering it. void (async () => { await inFlightRef.current - if (!discardedRef.current) { + if (!discardedRef.current && !pauseSavingRef.current) { await onSaveRef.current().then(clearLocalDraft, () => {}) } })() @@ -270,14 +286,20 @@ export function useAutosave({ useEffect(() => { if (!effectiveDraftKey || recoveredForKeyRef.current === effectiveDraftKey) return - recoveredForKeyRef.current = effectiveDraftKey let cancelled = false void enqueueDraftOp(effectiveDraftKey, () => get(localDraftDbKey(effectiveDraftKey)) ) .then((draft) => { - if (cancelled || !draft) return + if (cancelled) return + recoveredForKeyRef.current = effectiveDraftKey + if (!draft) return if (draft.savedContent !== savedContentRef.current) { + if (onRestoreConflictingDraftRef.current) { + if (contentRef.current === savedContentRef.current) + onRestoreConflictingDraftRef.current(draft.content) + return + } clearLocalDraft() return } @@ -297,8 +319,10 @@ export function useAutosave({ const runCorrection = useCallback( (target: string) => { savingRef.current = true - const correctionRun = onSaveRef - .current(target) + const correction = pauseSavingRef.current + ? Promise.reject(new Error('Saving is paused; the discarded edit could not be reverted')) + : onSaveRef.current(target) + const correctionRun = correction .then( () => { failedCorrectionTargetRef.current = null @@ -340,23 +364,35 @@ export function useAutosave({ await save() }, [save, runCorrection]) - const discard = useCallback(() => { - discardedRef.current = true - discardTargetRef.current = savedContentRef.current - failedCorrectionTargetRef.current = null - clearTimeout(timerRef.current) - clearTimeout(localDraftTimerRef.current) - clearLocalDraft() - const pendingSave = inFlightRef.current - if (!pendingSave) return - const target = discardTargetRef.current - const contentAtDiscard = contentRef.current - void pendingSave.then(() => { - const current = contentRef.current - if (inFlightRef.current || (current !== target && current !== contentAtDiscard)) return - runCorrection(target) - }) - }, [clearLocalDraft, runCorrection]) + const discard = useCallback( + (options?: { correctInFlightSave?: boolean }) => { + discardedRef.current = true + discardTargetRef.current = savedContentRef.current + failedCorrectionTargetRef.current = null + clearTimeout(timerRef.current) + clearTimeout(localDraftTimerRef.current) + clearLocalDraft() + const pendingSave = inFlightRef.current + if (options?.correctInFlightSave === false) { + saveGenerationRef.current += 1 + clearTimeout(displayTimerRef.current) + clearTimeout(idleTimerRef.current) + if (!pendingSave) savingRef.current = false + } + if (!pendingSave || options?.correctInFlightSave === false) { + if (!unmountedRef.current) setSaveStatus('idle') + return + } + const target = discardTargetRef.current + const contentAtDiscard = contentRef.current + void pendingSave.then(() => { + const current = contentRef.current + if (inFlightRef.current || (current !== target && current !== contentAtDiscard)) return + runCorrection(target) + }) + }, + [clearLocalDraft, runCorrection] + ) return { saveStatus, saveImmediately, isDirty, discard } } diff --git a/apps/sim/hooks/use-member-access.ts b/apps/sim/hooks/use-member-access.ts new file mode 100644 index 00000000000..b14ca64cb3b --- /dev/null +++ b/apps/sim/hooks/use-member-access.ts @@ -0,0 +1,16 @@ +'use client' + +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' + +/** + * Whether per-member knowledge access is on for the routed workspace, as + * `isKnowledgeMemberAccessAvailable` judged it, resolved once into the + * workspace host context. + * + * The single client-side reading of that judgement, so no surface can drift + * into offering a feature the server refuses. Outside a workspace route there + * is no workspace to judge, so it reads false. + */ +export function useMemberAccessAvailable(): boolean { + return useOptionalWorkspaceHostContext()?.features?.knowledgeMemberAccess === true +} diff --git a/apps/sim/hooks/use-oauth-return.test.ts b/apps/sim/hooks/use-oauth-return.test.ts index 08debcc1a28..7f641748634 100644 --- a/apps/sim/hooks/use-oauth-return.test.ts +++ b/apps/sim/hooks/use-oauth-return.test.ts @@ -21,7 +21,11 @@ vi.mock('@/hooks/queries/utils/fetch-workspace-credentials', () => ({ })) import type { OAuthReturnContext } from '@/lib/credentials/client-state' -import { resolveOAuthMessage } from '@/hooks/use-oauth-return' +import { + buildKnowledgeBaseOAuthReturnUrl, + resolveOAuthCallbackError, + resolveOAuthMessage, +} from '@/hooks/use-oauth-return' const context: OAuthReturnContext = { origin: 'integrations', @@ -97,3 +101,34 @@ describe('resolveOAuthMessage', () => { }) }) }) + +describe('resolveOAuthCallbackError', () => { + it('prevents a provider rejection from being reported as reconnect success', () => { + expect( + resolveOAuthCallbackError( + 'https://sim.ai/workspace/workspace-1/integrations?error=quickbooks_access_denied', + context + ) + ).toEqual({ + kind: 'error', + text: 'The "New Gmail" connection didn’t finish. Try again.', + }) + }) + + it('returns no error for a successful callback URL', () => { + expect( + resolveOAuthCallbackError( + 'https://sim.ai/workspace/workspace-1/integrations?connected=true', + context + ) + ).toBeNull() + }) +}) + +describe('buildKnowledgeBaseOAuthReturnUrl', () => { + it('preserves the connector picker on both successful and failed OAuth returns', () => { + expect(buildKnowledgeBaseOAuthReturnUrl('workspace-1', 'kb-1', 'google_drive')).toBe( + '/workspace/workspace-1/knowledge/kb-1?addConnector=google_drive' + ) + }) +}) diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index 43db5d09624..acf6081b168 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -39,7 +39,10 @@ export interface OAuthResultMessage { export async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise { if (ctx.reconnect) { - return { kind: 'success', text: `"${ctx.displayName}" reconnected successfully.` } + return { + kind: 'success', + text: `"${ctx.displayName}" reconnected successfully.`, + } } try { @@ -86,6 +89,27 @@ export async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise +): OAuthResultMessage | null { + if (!new URL(callbackUrl).searchParams.has('error')) return null + return { + kind: 'error', + text: `The "${ctx.displayName}" connection didn’t finish. Try again.`, + } +} + +function consumeOAuthCallbackError(ctx: OAuthReturnContext): OAuthResultMessage | null { + const result = resolveOAuthCallbackError(window.location.href, ctx) + if (!result) return null + const url = new URL(window.location.href) + url.searchParams.delete('error') + url.searchParams.delete('error_description') + window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`) + return result +} + function showOAuthResultMessage(result: OAuthResultMessage): void { if (result.kind === 'success') { toast.success(result.text) @@ -161,7 +185,11 @@ async function verifyOAuthChatAttempt(queryClient: QueryClient, attemptId: strin // A short retry window covers callback hooks committing just after redirect. } if (attemptNumber < VERIFY_ATTEMPT_TRIES - 1) { - await sleep(backoffWithJitter(attemptNumber + 1, null, { baseMs: VERIFY_BACKOFF_BASE_MS })) + await sleep( + backoffWithJitter(attemptNumber + 1, null, { + baseMs: VERIFY_BACKOFF_BASE_MS, + }) + ) } } @@ -216,6 +244,19 @@ export function useOAuthReturnRouter() { } handledRef.current = true + const callbackError = consumeOAuthCallbackError(ctx) + if (callbackError) { + consumeOAuthReturnContext() + showOAuthResultMessage(callbackError) + if (ctx.origin === 'workflow') { + router.replace(`/workspace/${workspaceId}/w/${ctx.workflowId}`) + } else if (ctx.origin === 'kb-connectors') { + router.replace( + buildKnowledgeBaseOAuthReturnUrl(workspaceId, ctx.knowledgeBaseId, ctx.connectorType) + ) + } + return + } if (ctx.origin === 'integrations') { consumeOAuthReturnContext() @@ -239,16 +280,25 @@ export function useOAuthReturnRouter() { try { sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY) } catch {} - const kbUrl = `/workspace/${workspaceId}/knowledge/${ctx.knowledgeBaseId}` - const connectorParam = ctx.connectorType - ? `?${ADD_CONNECTOR_SEARCH_PARAM}=${encodeURIComponent(ctx.connectorType)}` - : '' - router.replace(`${kbUrl}${connectorParam}`) + router.replace( + buildKnowledgeBaseOAuthReturnUrl(workspaceId, ctx.knowledgeBaseId, ctx.connectorType) + ) return } }, [queryClient, router, workspaceId]) } +export function buildKnowledgeBaseOAuthReturnUrl( + workspaceId: string, + knowledgeBaseId: string, + connectorType?: string +): string { + const kbUrl = `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}` + return connectorType + ? `${kbUrl}?${ADD_CONNECTOR_SEARCH_PARAM}=${encodeURIComponent(connectorType)}` + : kbUrl +} + /** * Post-OAuth handler for workflow pages. * Consumes the return context and shows a workflow-scoped notification. @@ -262,6 +312,12 @@ export function useOAuthReturnForWorkflow(workflowId: string) { consumeOAuthReturnContext() if (Date.now() - ctx.requestedAt > CONTEXT_MAX_AGE_MS) return + const callbackError = consumeOAuthCallbackError(ctx) + if (callbackError) { + showOAuthResultMessage(callbackError) + return + } + void (async () => { const message = await resolveOAuthMessage(ctx) showOAuthResultMessage(message) @@ -283,6 +339,12 @@ export function useOAuthReturnForKBConnectors(knowledgeBaseId: string) { consumeOAuthReturnContext() if (Date.now() - ctx.requestedAt > CONTEXT_MAX_AGE_MS) return + const callbackError = consumeOAuthCallbackError(ctx) + if (callbackError) { + showOAuthResultMessage(callbackError) + return + } + void (async () => { const message = await resolveOAuthMessage(ctx) showOAuthResultMessage(message) @@ -308,8 +370,12 @@ export function useDesktopOAuthConnectListener() { if (!bridge?.onOAuthConnectComplete) return return bridge.onOAuthConnectComplete((result) => { - void queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() }) - void queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all }) + void queryClient.invalidateQueries({ + queryKey: oauthConnectionsKeys.connections(), + }) + void queryClient.invalidateQueries({ + queryKey: workspaceCredentialKeys.all, + }) // The app stays open across interleaved connect flows, so an abandoned // modal-connect can leave a stale context that would attach to a later diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts index 65586d58cca..2bd0911b290 100644 --- a/apps/sim/hooks/use-webhook-management.ts +++ b/apps/sim/hooks/use-webhook-management.ts @@ -16,6 +16,10 @@ interface UseWebhookManagementProps { triggerId?: string isPreview?: boolean useWebhookUrl?: boolean + providerWebhookUrl?: { + providerPath: string + routingKeySubBlockId: string + } } interface WebhookManagementState { @@ -88,6 +92,7 @@ export function useWebhookManagement({ triggerId, isPreview = false, useWebhookUrl = false, + providerWebhookUrl, }: UseWebhookManagementProps): WebhookManagementState { const params = useParams() const workflowId = params.workflowId as string @@ -99,6 +104,16 @@ export function useWebhookManagement({ const webhookPath = useSubBlockStore( useCallback((state) => state.getValue(blockId, 'triggerPath') as string | null, [blockId]) ) + const providerRoutingKey = useSubBlockStore( + useCallback( + (state) => + providerWebhookUrl + ? (state.getValue(blockId, providerWebhookUrl.routingKeySubBlockId) as string | null) + : null, + [blockId, providerWebhookUrl] + ) + ) + const shouldLoadWebhook = useWebhookUrl || Boolean(providerWebhookUrl) /** * Derived only when the caller actually renders the URL. `getBaseUrl()` throws @@ -109,15 +124,19 @@ export function useWebhookManagement({ * Consumers already gate their reads on `useWebhookUrl`. */ const webhookUrl = useMemo(() => { - if (!useWebhookUrl) { + if (!shouldLoadWebhook) { return '' } + if (providerWebhookUrl) { + if (!providerRoutingKey) return '' + return `${getBaseUrl()}/api/webhooks/${encodeURIComponent(providerWebhookUrl.providerPath)}/${encodeURIComponent(providerRoutingKey)}` + } const baseUrl = getBaseUrl() if (!webhookPath) { return `${baseUrl}/api/webhooks/trigger/${blockId}` } return `${baseUrl}/api/webhooks/trigger/${webhookPath}` - }, [useWebhookUrl, webhookPath, blockId]) + }, [shouldLoadWebhook, providerWebhookUrl, providerRoutingKey, webhookPath, blockId]) useEffect(() => { if (triggerId && !isPreview) { @@ -128,7 +147,7 @@ export function useWebhookManagement({ } }, [triggerId, blockId, isPreview]) - const queryEnabled = useWebhookUrl && !isPreview && Boolean(workflowId && blockId) + const queryEnabled = shouldLoadWebhook && !isPreview && Boolean(workflowId && blockId) // Reset sync flag when blockId changes or query becomes disabled (render-phase guard) const prevBlockIdRef = useRef(blockId) diff --git a/apps/sim/lib/analytics/google.test.ts b/apps/sim/lib/analytics/google.test.ts new file mode 100644 index 00000000000..ecc91ca5291 --- /dev/null +++ b/apps/sim/lib/analytics/google.test.ts @@ -0,0 +1,27 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { trackGoogleAdsConversion } from '@/lib/analytics/google' +import { GOOGLE_ADS_ID } from '@/lib/consent/scripts' + +afterEach(() => { + window.gtag = undefined +}) + +describe('trackGoogleAdsConversion', () => { + it('addresses the conversion to the Ads tag and its registered label', () => { + const gtag = vi.fn() + window.gtag = gtag + + trackGoogleAdsConversion('demo_booked') + + expect(gtag).toHaveBeenCalledWith('event', 'conversion', { + send_to: `${GOOGLE_ADS_ID}/Xt8wCK7b1e4cEL_Zk99C`, + }) + }) + + it('is a no-op when the Google tag has not loaded', () => { + expect(() => trackGoogleAdsConversion('demo_booked')).not.toThrow() + }) +}) diff --git a/apps/sim/lib/analytics/google.ts b/apps/sim/lib/analytics/google.ts index a696f0bd6f7..93c936ab409 100644 --- a/apps/sim/lib/analytics/google.ts +++ b/apps/sim/lib/analytics/google.ts @@ -1,4 +1,11 @@ -import { GOOGLE_ANALYTICS_ID } from '@/lib/consent/scripts' +import { GOOGLE_ADS_ID, GOOGLE_ANALYTICS_ID } from '@/lib/consent/scripts' + +/** Conversion labels registered in Google Ads, keyed by the action they measure. */ +const GOOGLE_ADS_CONVERSION_LABELS = { + demo_booked: 'Xt8wCK7b1e4cEL_Zk99C', +} as const + +export type GoogleAdsConversion = keyof typeof GOOGLE_ADS_CONVERSION_LABELS interface GoogleAnalyticsEventMap { sign_up: { method: string } @@ -17,6 +24,17 @@ export function trackGoogleEvent( window.gtag?.('event', name, parameters) } +/** + * Records a Google Ads conversion, addressed as `/`. + * Call only after the caller has verified marketing consent: without it Consent + * Mode keeps `ad_storage` denied and the hit could not be attributed to a click. + */ +export function trackGoogleAdsConversion(conversion: GoogleAdsConversion): void { + window.gtag?.('event', 'conversion', { + send_to: `${GOOGLE_ADS_ID}/${GOOGLE_ADS_CONVERSION_LABELS[conversion]}`, + }) +} + export function trackGooglePageView(path: string): void { window.gtag?.('event', 'page_view', { page_path: path, diff --git a/apps/sim/lib/api/contracts/credentials.test.ts b/apps/sim/lib/api/contracts/credentials.test.ts index ae6f6406ee7..8c1c2a371d2 100644 --- a/apps/sim/lib/api/contracts/credentials.test.ts +++ b/apps/sim/lib/api/contracts/credentials.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { + createCredentialDraftBodySchema, updateCredentialByIdBodySchema, workspaceCredentialSchema, } from '@/lib/api/contracts/credentials' @@ -24,7 +25,9 @@ const credential = { describe('updateCredentialByIdBodySchema unredacted', () => { it('accepts unredacted alone as the one updated field', () => { - const parsed = updateCredentialByIdBodySchema.safeParse({ unredacted: true }) + const parsed = updateCredentialByIdBodySchema.safeParse({ + unredacted: true, + }) expect(parsed.success).toBe(true) if (parsed.success) expect(parsed.data).toEqual({ unredacted: true }) @@ -37,7 +40,10 @@ describe('updateCredentialByIdBodySchema unredacted', () => { describe('workspaceCredentialSchema unredacted', () => { it('accepts a credential carrying unredacted: false', () => { - const parsed = workspaceCredentialSchema.parse({ ...credential, unredacted: false }) + const parsed = workspaceCredentialSchema.parse({ + ...credential, + unredacted: false, + }) expect(parsed.unredacted).toBe(false) }) @@ -46,3 +52,43 @@ describe('workspaceCredentialSchema unredacted', () => { expect(workspaceCredentialSchema.safeParse(credential).success).toBe(false) }) }) + +describe('createCredentialDraftBodySchema OAuth client configuration', () => { + const base = { + workspaceId: 'workspace-1', + displayName: 'Accounting', + } + const oauthClientConfig = { + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox' as const, + webhookVerifierToken: 'verifier-token', + } + + it('requires caller-managed app credentials for QuickBooks', () => { + const result = createCredentialDraftBodySchema.safeParse({ + ...base, + providerId: 'quickbooks', + }) + + expect(result.success).toBe(false) + if (!result.success) expect(result.error.issues[0]?.path).toEqual(['oauthClientConfig']) + }) + + it('accepts QuickBooks app credentials and rejects them for other providers', () => { + expect( + createCredentialDraftBodySchema.safeParse({ + ...base, + providerId: 'quickbooks', + oauthClientConfig, + }).success + ).toBe(true) + expect( + createCredentialDraftBodySchema.safeParse({ + ...base, + providerId: 'google-email', + oauthClientConfig, + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index d8a096f49ac..8be92a6a717 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -302,13 +302,71 @@ export const workspaceCredentialMemberSchema = z.object({ export type WorkspaceCredentialMember = z.output -export const createCredentialDraftBodySchema = z.object({ - workspaceId: z.string().min(1), - providerId: z.string().min(1), - displayName: z.string().min(1), - description: z.string().trim().max(500).optional(), - credentialId: z.string().min(1).optional(), -}) +export const quickBooksOAuthClientConfigSchema = z + .object({ + clientId: z + .string({ error: 'QuickBooks client ID is required' }) + .trim() + .min(1, 'QuickBooks client ID is required') + .max(255, 'QuickBooks client ID must be at most 255 characters') + .describe('Client ID for the caller-managed Intuit OAuth application.'), + clientSecret: z + .string({ error: 'QuickBooks client secret is required' }) + .trim() + .min(1, 'QuickBooks client secret is required') + .max(512, 'QuickBooks client secret must be at most 512 characters') + .describe('Write-only client secret for the caller-managed Intuit OAuth application.') + .meta({ writeOnly: true }), + environment: z + .enum(['sandbox', 'production'], { + error: 'QuickBooks environment must be sandbox or production', + }) + .describe('Intuit company environment used for authorization and API requests.'), + webhookVerifierToken: z + .string({ error: 'QuickBooks webhook verifier token is required' }) + .trim() + .min(1, 'QuickBooks webhook verifier token is required') + .max(512, 'QuickBooks webhook verifier token must be at most 512 characters') + .describe('Write-only verifier token for webhook signatures from the caller-managed app.') + .meta({ writeOnly: true }), + }) + .strict() + +export type QuickBooksOAuthClientConfigInput = z.input + +export function refineOAuthClientConfigForProvider( + value: { + providerId: string + oauthClientConfig?: QuickBooksOAuthClientConfigInput + }, + ctx: z.RefinementCtx +): void { + if (value.providerId === 'quickbooks' && !value.oauthClientConfig) { + ctx.addIssue({ + code: 'custom', + path: ['oauthClientConfig'], + message: 'oauthClientConfig is required for QuickBooks', + }) + } + if (value.providerId !== 'quickbooks' && value.oauthClientConfig) { + ctx.addIssue({ + code: 'custom', + path: ['oauthClientConfig'], + message: 'oauthClientConfig is only supported for QuickBooks', + }) + } +} + +export const createCredentialDraftBodySchema = z + .object({ + workspaceId: z.string().min(1), + providerId: z.string().min(1), + displayName: z.string().min(1), + description: z.string().trim().max(500).optional(), + credentialId: z.string().min(1).optional(), + oauthClientConfig: quickBooksOAuthClientConfigSchema.optional(), + }) + .superRefine(refineOAuthClientConfigForProvider) export const upsertWorkspaceCredentialMemberBodySchema = z.object({ userId: z.string().min(1), diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index 5e154df85ec..dab587ae1e5 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -154,8 +154,6 @@ export const createKnowledgeBaseBodySchema = z.object({ * `null` (or omitted) creates it at the workspace root. */ folderId: z.string().min(1, 'Folder ID cannot be empty').nullable().optional(), - embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'), - embeddingDimension: z.literal(1536).default(1536), chunkingConfig: chunkingConfigSchema.default(DEFAULT_CHUNKING_CONFIG), }) @@ -173,8 +171,6 @@ export const updateKnowledgeBaseBodySchema = createKnowledgeBaseBodySchema */ folderId: z.string().min(1, 'Folder ID cannot be empty').nullable().optional(), workspaceId: z.string().nullable().optional(), - embeddingModel: z.literal('text-embedding-3-small').optional(), - embeddingDimension: z.literal(1536).optional(), }) const knowledgeChunkingConfigSchema = z diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 81ba4376414..0203e9082c4 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -120,6 +120,8 @@ const oauthTokenResponseSchema = z.object({ apiDomain: z.string().optional(), cloudId: z.string().optional(), domain: z.string().optional(), + realmId: z.string().optional(), + quickBooksEnvironment: z.enum(['sandbox', 'production']).optional(), authStyle: z.enum(['x-api-token']).optional(), }) @@ -278,6 +280,31 @@ export const instagramCallbackContract = defineRouteContract({ response: { mode: 'redirect' }, }) +export const quickBooksCallbackQuerySchema = z + .object({ + code: z + .string() + .min(1, 'Authorization code cannot be empty') + .max(MAX_OAUTH_CODE_LENGTH, 'Authorization code is too long') + .optional(), + state: z.string().min(1, 'OAuth state cannot be empty').max(4096, 'OAuth state is too long'), + realmId: z + .string() + .min(1, 'QuickBooks company identity cannot be empty') + .max(64, 'QuickBooks company identity is too long') + .optional(), + error: z.string().min(1).max(MAX_OAUTH_ERROR_LENGTH).optional(), + error_description: z.string().min(1).max(MAX_OAUTH_ERROR_LENGTH).optional(), + }) + .strip() + +export const quickBooksCallbackContract = defineRouteContract({ + method: 'GET', + path: '/api/auth/oauth2/callback/quickbooks', + query: quickBooksCallbackQuerySchema, + response: { mode: 'redirect' }, +}) + export const authorizeOAuth2QuerySchema = z .object({ draftId: oauthCredentialDraftIdSchema.optional(), @@ -288,7 +315,7 @@ export const authorizeOAuth2QuerySchema = z }) .superRefine((data, ctx) => { if (data.draftId) { - for (const field of ['providerId', 'workspaceId', 'callbackURL', 'credentialId'] as const) { + for (const field of ['providerId', 'workspaceId', 'credentialId'] as const) { if (data[field] !== undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/apps/sim/lib/api/contracts/providers.ts b/apps/sim/lib/api/contracts/providers.ts index 3b7f1d5eb28..7dcab18ab31 100644 --- a/apps/sim/lib/api/contracts/providers.ts +++ b/apps/sim/lib/api/contracts/providers.ts @@ -143,6 +143,23 @@ export const ollamaUpstreamResponseSchema = z.object({ .default([]), }) +/** + * Ollama `/api/show`. `capabilities` is what separates an embedding model from a + * chat one — `/api/tags` lists both and says nothing about either. `model_info` + * is keyed by architecture (`nomic-bert.embedding_length`, `bert.embedding_length`, + * …), so the width is found by suffix rather than by a fixed key. + * + * Both fields are optional: an Ollama older than 0.5 reports no `capabilities`, + * and a model whose architecture publishes no embedding length simply has none + * to show. + */ +export const ollamaShowUpstreamResponseSchema = z + .object({ + capabilities: z.array(z.string()).optional(), + model_info: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough() + export const getBaseProviderModelsContract = defineRouteContract({ method: 'GET', path: '/api/providers/base/models', diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-cancel-query.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-cancel-query.ts new file mode 100644 index 00000000000..d20f64b6925 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-cancel-query.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const CancelQuerySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + queryId: z + .string() + .trim() + .regex(/^[a-f0-9-]{36}$/, 'Query ID must be a 36-character query identifier'), + eventDataStoreOwnerAccountId: z + .string() + .trim() + .min(12) + .max(16) + .regex(/^\d+$/, 'Account ID must be numeric') + .optional(), +}) + +const CancelQueryResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + queryId: z.string(), + queryStatus: z.string().nullable(), + eventDataStoreOwnerAccountId: z.string().nullable(), + }), +}) + +export const awsCloudtrailCancelQueryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/cancel-query', + body: CancelQuerySchema, + response: { mode: 'json', schema: CancelQueryResponseSchema }, +}) +export type AwsCloudtrailCancelQueryRequest = ContractBodyInput< + typeof awsCloudtrailCancelQueryContract +> +export type AwsCloudtrailCancelQueryBody = ContractBody +export type AwsCloudtrailCancelQueryResponse = ContractJsonResponse< + typeof awsCloudtrailCancelQueryContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-describe-query.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-describe-query.ts new file mode 100644 index 00000000000..1959927de84 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-describe-query.ts @@ -0,0 +1,101 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +/** + * `DescribeQuery` requires either `QueryId` or `QueryAlias`. AWS documents `RefreshId` as + * something you "provide along with `QueryAlias`" to read a dashboard refresh, so it is + * rejected when the query is addressed by ID. + * @see https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_DescribeQuery.html + */ +const DescribeQuerySchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + queryId: z + .string() + .trim() + .regex(/^[a-f0-9-]{36}$/, 'Query ID must be a 36-character query identifier') + .optional(), + queryAlias: z + .string() + .trim() + .min(1) + .max(256) + .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid query alias format') + .optional(), + refreshId: z + .string() + .trim() + .min(10) + .max(20) + .regex(/^\d+$/, 'Refresh ID must be numeric') + .optional(), + eventDataStoreOwnerAccountId: z + .string() + .trim() + .min(12) + .max(16) + .regex(/^\d+$/, 'Account ID must be numeric') + .optional(), + }) + .superRefine((v, ctx) => { + if (Boolean(v.queryId) === Boolean(v.queryAlias)) { + ctx.addIssue({ + code: 'custom', + message: 'Specify exactly one of queryId or queryAlias', + path: ['queryId'], + }) + } + if (v.refreshId && !v.queryAlias) { + ctx.addIssue({ + code: 'custom', + message: 'refreshId identifies a dashboard refresh and is only valid with queryAlias', + path: ['refreshId'], + }) + } + }) + +const DescribeQueryResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + queryId: z.string().nullable(), + queryString: z.string().nullable(), + queryStatus: z.string().nullable(), + errorMessage: z.string().nullable(), + deliveryS3Uri: z.string().nullable(), + deliveryStatus: z.string().nullable(), + prompt: z.string().nullable(), + eventDataStoreOwnerAccountId: z.string().nullable(), + eventsMatched: z.number().nullable(), + eventsScanned: z.number().nullable(), + bytesScanned: z.number().nullable(), + executionTimeInMillis: z.number().nullable(), + creationTime: z.string().nullable(), + }), +}) + +export const awsCloudtrailDescribeQueryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/describe-query', + body: DescribeQuerySchema, + response: { mode: 'json', schema: DescribeQueryResponseSchema }, +}) +export type AwsCloudtrailDescribeQueryRequest = ContractBodyInput< + typeof awsCloudtrailDescribeQueryContract +> +export type AwsCloudtrailDescribeQueryBody = ContractBody +export type AwsCloudtrailDescribeQueryResponse = ContractJsonResponse< + typeof awsCloudtrailDescribeQueryContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-describe-trails.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-describe-trails.ts new file mode 100644 index 00000000000..6347056c0f7 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-describe-trails.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import { cloudtrailTrailNameOrArnSchema } from '@/lib/api/contracts/tools/aws/cloudtrail-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const trailSchema = z.object({ + name: z.string(), + s3BucketName: z.string().nullable(), + s3KeyPrefix: z.string().nullable(), + snsTopicName: z.string().nullable(), + snsTopicArn: z.string().nullable(), + includeGlobalServiceEvents: z.boolean().nullable(), + isMultiRegionTrail: z.boolean().nullable(), + homeRegion: z.string().nullable(), + trailArn: z.string().nullable(), + logFileValidationEnabled: z.boolean().nullable(), + cloudWatchLogsLogGroupArn: z.string().nullable(), + cloudWatchLogsRoleArn: z.string().nullable(), + kmsKeyId: z.string().nullable(), + hasCustomEventSelectors: z.boolean().nullable(), + hasInsightSelectors: z.boolean().nullable(), + isOrganizationTrail: z.boolean().nullable(), +}) + +const DescribeTrailsSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + /** + * `DescribeTrails` documents no array-member limit on `trailNameList`, so this ceiling is a + * request-payload guard rather than an AWS constraint. It is deliberately set above anything + * reachable: the trails-per-Region quota is 5, so naming every trail and shadow trail in an + * account across all commercial Regions still stays well under 200. + * @see https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_DescribeTrails.html + * @see https://docs.aws.amazon.com/awscloudtrail/latest/userguide/WhatIsCloudTrail-Limits.html + */ + trailNameList: z + .array(cloudtrailTrailNameOrArnSchema) + .max(200, 'At most 200 trail names or ARNs can be described in one request') + .optional(), + includeShadowTrails: z.boolean().optional(), +}) + +const DescribeTrailsResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + trails: z.array(trailSchema), + }), +}) + +export const awsCloudtrailDescribeTrailsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/describe-trails', + body: DescribeTrailsSchema, + response: { mode: 'json', schema: DescribeTrailsResponseSchema }, +}) +export type AwsCloudtrailDescribeTrailsRequest = ContractBodyInput< + typeof awsCloudtrailDescribeTrailsContract +> +export type AwsCloudtrailDescribeTrailsBody = ContractBody< + typeof awsCloudtrailDescribeTrailsContract +> +export type AwsCloudtrailDescribeTrailsResponse = ContractJsonResponse< + typeof awsCloudtrailDescribeTrailsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-event-data-store.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-event-data-store.ts new file mode 100644 index 00000000000..a7d4bbf2501 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-event-data-store.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const advancedEventSelectorSchema = z.object({ + name: z.string().nullable(), + fieldSelectors: z.array( + z.object({ + field: z.string(), + equals: z.array(z.string()), + startsWith: z.array(z.string()), + endsWith: z.array(z.string()), + notEquals: z.array(z.string()), + notStartsWith: z.array(z.string()), + notEndsWith: z.array(z.string()), + }) + ), +}) + +const GetEventDataStoreSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + eventDataStore: z + .string() + .trim() + .min(3, 'Event data store ARN or ID is required') + .max(256) + .regex(/^[a-zA-Z0-9._/\-:]+$/, 'Invalid event data store ARN or ID'), +}) + +const GetEventDataStoreResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + eventDataStoreArn: z.string().nullable(), + name: z.string().nullable(), + status: z.string().nullable(), + advancedEventSelectors: z.array(advancedEventSelectorSchema), + multiRegionEnabled: z.boolean().nullable(), + organizationEnabled: z.boolean().nullable(), + retentionPeriod: z.number().nullable(), + terminationProtectionEnabled: z.boolean().nullable(), + createdTimestamp: z.string().nullable(), + updatedTimestamp: z.string().nullable(), + kmsKeyId: z.string().nullable(), + billingMode: z.string().nullable(), + federationStatus: z.string().nullable(), + federationRoleArn: z.string().nullable(), + partitionKeys: z.array(z.object({ name: z.string(), type: z.string() })), + }), +}) + +export const awsCloudtrailGetEventDataStoreContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/get-event-data-store', + body: GetEventDataStoreSchema, + response: { mode: 'json', schema: GetEventDataStoreResponseSchema }, +}) +export type AwsCloudtrailGetEventDataStoreRequest = ContractBodyInput< + typeof awsCloudtrailGetEventDataStoreContract +> +export type AwsCloudtrailGetEventDataStoreBody = ContractBody< + typeof awsCloudtrailGetEventDataStoreContract +> +export type AwsCloudtrailGetEventDataStoreResponse = ContractJsonResponse< + typeof awsCloudtrailGetEventDataStoreContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-event-selectors.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-event-selectors.ts new file mode 100644 index 00000000000..0e1218bc50e --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-event-selectors.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { cloudtrailTrailNameOrArnSchema } from '@/lib/api/contracts/tools/aws/cloudtrail-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const advancedEventSelectorSchema = z.object({ + name: z.string().nullable(), + fieldSelectors: z.array( + z.object({ + field: z.string(), + equals: z.array(z.string()), + startsWith: z.array(z.string()), + endsWith: z.array(z.string()), + notEquals: z.array(z.string()), + notStartsWith: z.array(z.string()), + notEndsWith: z.array(z.string()), + }) + ), +}) + +const GetEventSelectorsSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + trailName: cloudtrailTrailNameOrArnSchema, +}) + +const GetEventSelectorsResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + trailArn: z.string().nullable(), + eventSelectors: z.array( + z.object({ + readWriteType: z.string().nullable(), + includeManagementEvents: z.boolean().nullable(), + dataResources: z.array( + z.object({ + type: z.string().nullable(), + values: z.array(z.string()), + }) + ), + excludeManagementEventSources: z.array(z.string()), + }) + ), + advancedEventSelectors: z.array(advancedEventSelectorSchema), + }), +}) + +export const awsCloudtrailGetEventSelectorsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/get-event-selectors', + body: GetEventSelectorsSchema, + response: { mode: 'json', schema: GetEventSelectorsResponseSchema }, +}) +export type AwsCloudtrailGetEventSelectorsRequest = ContractBodyInput< + typeof awsCloudtrailGetEventSelectorsContract +> +export type AwsCloudtrailGetEventSelectorsBody = ContractBody< + typeof awsCloudtrailGetEventSelectorsContract +> +export type AwsCloudtrailGetEventSelectorsResponse = ContractJsonResponse< + typeof awsCloudtrailGetEventSelectorsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-insight-selectors.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-insight-selectors.ts new file mode 100644 index 00000000000..a0561b72409 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-insight-selectors.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import { cloudtrailTrailNameOrArnSchema } from '@/lib/api/contracts/tools/aws/cloudtrail-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +/** + * The ARN, or the ID suffix of the ARN, of a CloudTrail Lake event data store. + * @see https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_GetEventDataStore.html + */ +const eventDataStoreSchema = z + .string() + .trim() + .min(3, 'Event data store ARN or ID is required') + .max(256, 'Event data store ARN or ID is too long') + .regex(/^[a-zA-Z0-9._/\-:]+$/, 'Invalid event data store ARN or ID') + +const GetInsightSelectorsSchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + trailName: cloudtrailTrailNameOrArnSchema.optional(), + eventDataStore: eventDataStoreSchema.optional(), + }) + .refine((v) => Boolean(v.trailName) !== Boolean(v.eventDataStore), { + message: 'Specify exactly one of trailName or eventDataStore', + path: ['trailName'], + }) + +const GetInsightSelectorsResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + trailArn: z.string().nullable(), + eventDataStoreArn: z.string().nullable(), + insightsDestination: z.string().nullable(), + insightSelectors: z.array( + z.object({ + insightType: z.string().nullable(), + eventCategories: z.array(z.string()), + }) + ), + }), +}) + +export const awsCloudtrailGetInsightSelectorsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/get-insight-selectors', + body: GetInsightSelectorsSchema, + response: { mode: 'json', schema: GetInsightSelectorsResponseSchema }, +}) +export type AwsCloudtrailGetInsightSelectorsRequest = ContractBodyInput< + typeof awsCloudtrailGetInsightSelectorsContract +> +export type AwsCloudtrailGetInsightSelectorsBody = ContractBody< + typeof awsCloudtrailGetInsightSelectorsContract +> +export type AwsCloudtrailGetInsightSelectorsResponse = ContractJsonResponse< + typeof awsCloudtrailGetInsightSelectorsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-query-results.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-query-results.ts new file mode 100644 index 00000000000..f921b3e7e39 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-query-results.ts @@ -0,0 +1,64 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const GetQueryResultsSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + queryId: z + .string() + .trim() + .regex(/^[a-f0-9-]{36}$/, 'Query ID must be a 36-character query identifier'), + maxQueryResults: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().min(1).max(1000).optional() + ), + nextToken: z.string().min(4).max(1000).optional(), + eventDataStoreOwnerAccountId: z + .string() + .trim() + .min(12) + .max(16) + .regex(/^\d+$/, 'Account ID must be numeric') + .optional(), +}) + +const GetQueryResultsResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + queryStatus: z.string().nullable(), + rows: z.array(z.record(z.string(), z.string())), + resultsCount: z.number().nullable(), + totalResultsCount: z.number().nullable(), + bytesScanned: z.number().nullable(), + errorMessage: z.string().nullable(), + nextToken: z.string().nullable(), + }), +}) + +export const awsCloudtrailGetQueryResultsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/get-query-results', + body: GetQueryResultsSchema, + response: { mode: 'json', schema: GetQueryResultsResponseSchema }, +}) +export type AwsCloudtrailGetQueryResultsRequest = ContractBodyInput< + typeof awsCloudtrailGetQueryResultsContract +> +export type AwsCloudtrailGetQueryResultsBody = ContractBody< + typeof awsCloudtrailGetQueryResultsContract +> +export type AwsCloudtrailGetQueryResultsResponse = ContractJsonResponse< + typeof awsCloudtrailGetQueryResultsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-trail-status.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-trail-status.ts new file mode 100644 index 00000000000..9ae5ad30f18 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-trail-status.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' +import { cloudtrailTrailNameOrArnSchema } from '@/lib/api/contracts/tools/aws/cloudtrail-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const GetTrailStatusSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + name: cloudtrailTrailNameOrArnSchema, +}) + +const GetTrailStatusResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + isLogging: z.boolean().nullable(), + latestDeliveryError: z.string().nullable(), + latestDeliveryTime: z.string().nullable(), + latestNotificationError: z.string().nullable(), + latestNotificationTime: z.string().nullable(), + latestCloudWatchLogsDeliveryError: z.string().nullable(), + latestCloudWatchLogsDeliveryTime: z.string().nullable(), + latestDigestDeliveryError: z.string().nullable(), + latestDigestDeliveryTime: z.string().nullable(), + startLoggingTime: z.string().nullable(), + stopLoggingTime: z.string().nullable(), + }), +}) + +export const awsCloudtrailGetTrailStatusContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/get-trail-status', + body: GetTrailStatusSchema, + response: { mode: 'json', schema: GetTrailStatusResponseSchema }, +}) +export type AwsCloudtrailGetTrailStatusRequest = ContractBodyInput< + typeof awsCloudtrailGetTrailStatusContract +> +export type AwsCloudtrailGetTrailStatusBody = ContractBody< + typeof awsCloudtrailGetTrailStatusContract +> +export type AwsCloudtrailGetTrailStatusResponse = ContractJsonResponse< + typeof awsCloudtrailGetTrailStatusContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-trail.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-trail.ts new file mode 100644 index 00000000000..ef5f3b9074a --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-get-trail.ts @@ -0,0 +1,55 @@ +import { z } from 'zod' +import { cloudtrailTrailNameOrArnSchema } from '@/lib/api/contracts/tools/aws/cloudtrail-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const GetTrailSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + name: cloudtrailTrailNameOrArnSchema, +}) + +const GetTrailResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + name: z.string(), + s3BucketName: z.string().nullable(), + s3KeyPrefix: z.string().nullable(), + snsTopicName: z.string().nullable(), + snsTopicArn: z.string().nullable(), + includeGlobalServiceEvents: z.boolean().nullable(), + isMultiRegionTrail: z.boolean().nullable(), + homeRegion: z.string().nullable(), + trailArn: z.string().nullable(), + logFileValidationEnabled: z.boolean().nullable(), + cloudWatchLogsLogGroupArn: z.string().nullable(), + cloudWatchLogsRoleArn: z.string().nullable(), + kmsKeyId: z.string().nullable(), + hasCustomEventSelectors: z.boolean().nullable(), + hasInsightSelectors: z.boolean().nullable(), + isOrganizationTrail: z.boolean().nullable(), + }), +}) + +export const awsCloudtrailGetTrailContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/get-trail', + body: GetTrailSchema, + response: { mode: 'json', schema: GetTrailResponseSchema }, +}) +export type AwsCloudtrailGetTrailRequest = ContractBodyInput +export type AwsCloudtrailGetTrailBody = ContractBody +export type AwsCloudtrailGetTrailResponse = ContractJsonResponse< + typeof awsCloudtrailGetTrailContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-list-event-data-stores.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-list-event-data-stores.ts new file mode 100644 index 00000000000..c30a351e45b --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-list-event-data-stores.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const advancedEventSelectorSchema = z.object({ + name: z.string().nullable(), + fieldSelectors: z.array( + z.object({ + field: z.string(), + equals: z.array(z.string()), + startsWith: z.array(z.string()), + endsWith: z.array(z.string()), + notEquals: z.array(z.string()), + notStartsWith: z.array(z.string()), + notEndsWith: z.array(z.string()), + }) + ), +}) + +const ListEventDataStoresSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + maxResults: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().min(1).max(1000).optional() + ), + nextToken: z.string().min(4).max(1000).optional(), +}) + +const ListEventDataStoresResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + eventDataStores: z.array( + z.object({ + eventDataStoreArn: z.string().nullable(), + name: z.string().nullable(), + status: z.string().nullable(), + advancedEventSelectors: z.array(advancedEventSelectorSchema), + multiRegionEnabled: z.boolean().nullable(), + organizationEnabled: z.boolean().nullable(), + retentionPeriod: z.number().nullable(), + terminationProtectionEnabled: z.boolean().nullable(), + createdTimestamp: z.string().nullable(), + updatedTimestamp: z.string().nullable(), + }) + ), + nextToken: z.string().nullable(), + }), +}) + +export const awsCloudtrailListEventDataStoresContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/list-event-data-stores', + body: ListEventDataStoresSchema, + response: { mode: 'json', schema: ListEventDataStoresResponseSchema }, +}) +export type AwsCloudtrailListEventDataStoresRequest = ContractBodyInput< + typeof awsCloudtrailListEventDataStoresContract +> +export type AwsCloudtrailListEventDataStoresBody = ContractBody< + typeof awsCloudtrailListEventDataStoresContract +> +export type AwsCloudtrailListEventDataStoresResponse = ContractJsonResponse< + typeof awsCloudtrailListEventDataStoresContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-list-tags.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-list-tags.ts new file mode 100644 index 00000000000..c79f07e9f31 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-list-tags.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ListTagsSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + resourceIdList: z + .array( + z + .string() + .trim() + .regex( + /^arn:aws[a-zA-Z0-9-]*:cloudtrail:[a-z0-9-]+:\d{12}:(?:trail|eventdatastore|dashboard|channel)\/[\w.\-/]+$/, + 'Must be a CloudTrail trail, event data store, dashboard, or channel ARN' + ) + ) + .min(1, 'At least one resource ARN is required') + .max(20, 'A maximum of 20 resource ARNs can be requested at once'), + nextToken: z.string().optional(), +}) + +const ListTagsResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + resourceTags: z.array( + z.object({ + resourceId: z.string().nullable(), + tags: z.array(z.object({ key: z.string(), value: z.string().nullable() })), + }) + ), + nextToken: z.string().nullable(), + }), +}) + +export const awsCloudtrailListTagsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/list-tags', + body: ListTagsSchema, + response: { mode: 'json', schema: ListTagsResponseSchema }, +}) +export type AwsCloudtrailListTagsRequest = ContractBodyInput +export type AwsCloudtrailListTagsBody = ContractBody +export type AwsCloudtrailListTagsResponse = ContractJsonResponse< + typeof awsCloudtrailListTagsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-list-trails.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-list-trails.ts new file mode 100644 index 00000000000..1f6b679c868 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-list-trails.ts @@ -0,0 +1,48 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ListTrailsSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + nextToken: z.string().optional(), +}) + +const ListTrailsResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + trails: z.array( + z.object({ + trailArn: z.string().nullable(), + name: z.string().nullable(), + homeRegion: z.string().nullable(), + }) + ), + nextToken: z.string().nullable(), + }), +}) + +export const awsCloudtrailListTrailsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/list-trails', + body: ListTrailsSchema, + response: { mode: 'json', schema: ListTrailsResponseSchema }, +}) +export type AwsCloudtrailListTrailsRequest = ContractBodyInput< + typeof awsCloudtrailListTrailsContract +> +export type AwsCloudtrailListTrailsBody = ContractBody +export type AwsCloudtrailListTrailsResponse = ContractJsonResponse< + typeof awsCloudtrailListTrailsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-lookup-events.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-lookup-events.ts new file mode 100644 index 00000000000..dc940512aef --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-lookup-events.ts @@ -0,0 +1,90 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const LookupEventsSchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + attributeKey: z + .enum([ + 'AccessKeyId', + 'EventId', + 'EventName', + 'EventSource', + 'ReadOnly', + 'ResourceName', + 'ResourceType', + 'Username', + ]) + .optional(), + attributeValue: z + .string() + .trim() + .min(1, 'Lookup attribute value cannot be empty') + .max(2000, 'Lookup attribute value cannot exceed 2000 characters') + .optional(), + startTime: z.string().datetime({ offset: true }).optional(), + endTime: z.string().datetime({ offset: true }).optional(), + eventCategory: z.literal('insight').optional(), + maxResults: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().min(1).max(50).optional() + ), + nextToken: z.string().optional(), + }) + .refine((v) => (v.attributeKey === undefined) === (v.attributeValue === undefined), { + message: 'attributeKey and attributeValue must be provided together', + path: ['attributeValue'], + }) + +const LookupEventsResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + events: z.array( + z.object({ + eventId: z.string().nullable(), + eventName: z.string().nullable(), + readOnly: z.string().nullable(), + accessKeyId: z.string().nullable(), + eventTime: z.string().nullable(), + eventSource: z.string().nullable(), + username: z.string().nullable(), + resources: z.array( + z.object({ + resourceType: z.string().nullable(), + resourceName: z.string().nullable(), + }) + ), + cloudTrailEvent: z.record(z.string(), z.unknown()).nullable(), + cloudTrailEventRaw: z.string().nullable(), + }) + ), + nextToken: z.string().nullable(), + }), +}) + +export const awsCloudtrailLookupEventsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/lookup-events', + body: LookupEventsSchema, + response: { mode: 'json', schema: LookupEventsResponseSchema }, +}) +export type AwsCloudtrailLookupEventsRequest = ContractBodyInput< + typeof awsCloudtrailLookupEventsContract +> +export type AwsCloudtrailLookupEventsBody = ContractBody +export type AwsCloudtrailLookupEventsResponse = ContractJsonResponse< + typeof awsCloudtrailLookupEventsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-shared.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-shared.ts new file mode 100644 index 00000000000..f85fc59bcd4 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-shared.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' + +/** + * Boundary primitives shared by the AWS CloudTrail tool contracts. + * + * Every bound and pattern here is transcribed from the CloudTrail API Reference. + */ + +/** Longest trail name CloudTrail accepts, per `InvalidTrailNameException`. */ +const TRAIL_NAME_MAX_LENGTH = 128 + +/** Longest trail ARN CloudTrail accepts wherever a name or ARN is allowed. */ +const TRAIL_ARN_MAX_LENGTH = 256 + +const TRAIL_NAME_OR_ARN_PATTERN = + /^(?:arn:aws[a-zA-Z0-9-]*:cloudtrail:[a-z0-9-]+:\d{12}:trail\/[\w.\-/]+|[a-zA-Z0-9](?:[a-zA-Z0-9]|[._-][a-zA-Z0-9])+)$/ + +/** + * A trail name or a full trail ARN. The two branches carry different ceilings: a bare name + * is 3-128 characters of ASCII alphanumerics plus non-adjacent `.`, `_`, `-`, starting and + * ending alphanumeric, while an ARN may run to 256. Applying only the ARN ceiling would let + * a 129-256 character bare name through to AWS, which rejects it with + * `InvalidTrailNameException`. Shadow trails and organization trails in another Region can + * only be addressed by ARN. + * @see https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_GetTrail.html + * @see https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_DescribeTrails.html + */ +export const cloudtrailTrailNameOrArnSchema = z + .string() + .trim() + .min(3, 'Trail name must be at least 3 characters') + .max(TRAIL_ARN_MAX_LENGTH, 'Trail name or ARN is too long') + .regex(TRAIL_NAME_OR_ARN_PATTERN, 'Must be a valid trail name or trail ARN') + .superRefine((value, ctx) => { + if (!value.startsWith('arn:') && value.length > TRAIL_NAME_MAX_LENGTH) { + ctx.addIssue({ + code: 'custom', + message: `Trail name must be at most ${TRAIL_NAME_MAX_LENGTH} characters; use the trail ARN to address a trail in another Region`, + }) + } + }) diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudtrail-start-query.ts b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-start-query.ts new file mode 100644 index 00000000000..39e5cf2c21c --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudtrail-start-query.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const StartQuerySchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + queryStatement: z.string().trim().min(1).max(10000).optional(), + queryAlias: z + .string() + .trim() + .min(1) + .max(256) + .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid query alias format') + .optional(), + queryParameters: z.array(z.string().min(1).max(1024)).min(1).max(10).optional(), + deliveryS3Uri: z + .string() + .trim() + .max(1024) + .regex(/^s3:\/\/[a-z0-9][.\-a-z0-9]{1,61}[a-z0-9](\/.*)?$/, 'Invalid S3 URI') + .optional(), + eventDataStoreOwnerAccountId: z + .string() + .trim() + .min(12) + .max(16) + .regex(/^\d+$/, 'Account ID must be numeric') + .optional(), + }) + .refine((v) => Boolean(v.queryStatement) !== Boolean(v.queryAlias), { + message: 'Specify exactly one of queryStatement or queryAlias', + path: ['queryStatement'], + }) + +const StartQueryResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + queryId: z.string(), + eventDataStoreOwnerAccountId: z.string().nullable(), + }), +}) + +export const awsCloudtrailStartQueryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudtrail/start-query', + body: StartQuerySchema, + response: { mode: 'json', schema: StartQueryResponseSchema }, +}) +export type AwsCloudtrailStartQueryRequest = ContractBodyInput< + typeof awsCloudtrailStartQueryContract +> +export type AwsCloudtrailStartQueryBody = ContractBody +export type AwsCloudtrailStartQueryResponse = ContractJsonResponse< + typeof awsCloudtrailStartQueryContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-add-user-to-group.ts b/apps/sim/lib/api/contracts/tools/aws/iam-add-user-to-group.ts index 68ba95d2e74..68eae0c0714 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-add-user-to-group.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-add-user-to-group.ts @@ -1,23 +1,20 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamGroupNameSchema, + iamUserName128Schema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - groupName: z.string().min(1, 'Group name is required'), + ...iamConnectionShape, + userName: iamUserName128Schema, + groupName: iamGroupNameSchema, }) export const awsIamAddUserToGroupContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-attach-role-policy.ts b/apps/sim/lib/api/contracts/tools/aws/iam-attach-role-policy.ts index afcbead191f..3592dee3411 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-attach-role-policy.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-attach-role-policy.ts @@ -1,23 +1,20 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamPolicyArnSchema, + iamRoleNameSchema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), - policyArn: z.string().min(1, 'Policy ARN is required'), + ...iamConnectionShape, + roleName: iamRoleNameSchema, + policyArn: iamPolicyArnSchema, }) export const awsIamAttachRolePolicyContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-attach-user-policy.ts b/apps/sim/lib/api/contracts/tools/aws/iam-attach-user-policy.ts index 0d841996dd4..0043777a47d 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-attach-user-policy.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-attach-user-policy.ts @@ -1,23 +1,20 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamPolicyArnSchema, + iamUserName64Schema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - policyArn: z.string().min(1, 'Policy ARN is required'), + ...iamConnectionShape, + userName: iamUserName64Schema, + policyArn: iamPolicyArnSchema, }) export const awsIamAttachUserPolicyContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-create-access-key.ts b/apps/sim/lib/api/contracts/tools/aws/iam-create-access-key.ts index acdbaef011a..d1a44508221 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-create-access-key.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-create-access-key.ts @@ -1,22 +1,15 @@ import { z } from 'zod' +import { iamConnectionShape, iamUserName128Schema } from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().optional().nullable(), + ...iamConnectionShape, + userName: iamUserName128Schema.optional().nullable(), }) const CreateAccessKeyResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts b/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts index 3b1427e1788..ab70e1d0df4 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts @@ -1,26 +1,31 @@ import { z } from 'zod' +import { + iamAssumeRolePolicyDocumentSchema, + iamConnectionShape, + iamCreatePathSchema, + iamRoleDescriptionSchema, + iamRoleNameSchema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), - assumeRolePolicyDocument: z.string().min(1, 'Assume role policy document is required'), - description: z.string().optional().nullable(), - path: z.string().optional().nullable(), - maxSessionDuration: z.number().int().min(3600).max(43200).optional().nullable(), + ...iamConnectionShape, + roleName: iamRoleNameSchema, + assumeRolePolicyDocument: iamAssumeRolePolicyDocumentSchema, + description: iamRoleDescriptionSchema.optional().nullable(), + path: iamCreatePathSchema.optional().nullable(), + maxSessionDuration: z + .number() + .int('Max session duration must be a whole number of seconds') + .min(3600, 'Max session duration must be at least 3600 seconds (1 hour)') + .max(43200, 'Max session duration cannot exceed 43200 seconds (12 hours)') + .optional() + .nullable(), }) const CreateRoleResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts b/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts index 8f925406a68..4e252e679b0 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts @@ -1,23 +1,20 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamCreatePathSchema, + iamUserName64Schema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - path: z.string().optional().nullable(), + ...iamConnectionShape, + userName: iamUserName64Schema, + path: iamCreatePathSchema.optional().nullable(), }) const CreateUserResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-delete-access-key.ts b/apps/sim/lib/api/contracts/tools/aws/iam-delete-access-key.ts index 195862da0f6..7371cfd78ad 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-delete-access-key.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-delete-access-key.ts @@ -1,23 +1,20 @@ import { z } from 'zod' +import { + iamAccessKeyIdentifierSchema, + iamConnectionShape, + iamUserName128Schema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - accessKeyIdToDelete: z.string().min(1, 'Access key ID to delete is required'), - userName: z.string().optional().nullable(), + ...iamConnectionShape, + accessKeyIdToDelete: iamAccessKeyIdentifierSchema, + userName: iamUserName128Schema.optional().nullable(), }) export const awsIamDeleteAccessKeyContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-delete-role.ts b/apps/sim/lib/api/contracts/tools/aws/iam-delete-role.ts index 1e28812b009..1be7856e237 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-delete-role.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-delete-role.ts @@ -1,22 +1,15 @@ import { z } from 'zod' +import { iamConnectionShape, iamRoleNameSchema } from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), + ...iamConnectionShape, + roleName: iamRoleNameSchema, }) export const awsIamDeleteRoleContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-delete-user.ts b/apps/sim/lib/api/contracts/tools/aws/iam-delete-user.ts index e728b06a808..950c525a857 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-delete-user.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-delete-user.ts @@ -1,22 +1,15 @@ import { z } from 'zod' +import { iamConnectionShape, iamUserName128Schema } from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), + ...iamConnectionShape, + userName: iamUserName128Schema, }) export const awsIamDeleteUserContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-detach-role-policy.ts b/apps/sim/lib/api/contracts/tools/aws/iam-detach-role-policy.ts index c09f802af05..d00e0825914 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-detach-role-policy.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-detach-role-policy.ts @@ -1,23 +1,20 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamPolicyArnSchema, + iamRoleNameSchema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), - policyArn: z.string().min(1, 'Policy ARN is required'), + ...iamConnectionShape, + roleName: iamRoleNameSchema, + policyArn: iamPolicyArnSchema, }) export const awsIamDetachRolePolicyContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-detach-user-policy.ts b/apps/sim/lib/api/contracts/tools/aws/iam-detach-user-policy.ts index 2ded4cfc918..61942cfd881 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-detach-user-policy.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-detach-user-policy.ts @@ -1,23 +1,20 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamPolicyArnSchema, + iamUserName64Schema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - policyArn: z.string().min(1, 'Policy ARN is required'), + ...iamConnectionShape, + userName: iamUserName64Schema, + policyArn: iamPolicyArnSchema, }) export const awsIamDetachUserPolicyContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-get-policy.ts b/apps/sim/lib/api/contracts/tools/aws/iam-get-policy.ts new file mode 100644 index 00000000000..c43a932edcb --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/iam-get-policy.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import { iamConnectionShape, iamPolicyArnSchema } from '@/lib/api/contracts/tools/aws/iam-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const Schema = z.object({ + ...iamConnectionShape, + policyArn: iamPolicyArnSchema, +}) + +const GetPolicyResponseSchema = z.object({ + policyName: z.string(), + policyId: z.string(), + arn: z.string(), + path: z.string(), + attachmentCount: z.number(), + isAttachable: z.boolean(), + createDate: z.string().nullable(), + updateDate: z.string().nullable(), + description: z.string().nullable(), + defaultVersionId: z.string().nullable(), + permissionsBoundaryUsageCount: z.number(), + tags: z.array( + z.object({ + key: z.string(), + value: z.string(), + }) + ), +}) + +export const awsIamGetPolicyContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/iam/get-policy', + body: Schema, + response: { mode: 'json', schema: GetPolicyResponseSchema }, +}) +export type AwsIamGetPolicyRequest = ContractBodyInput +export type AwsIamGetPolicyBody = ContractBody +export type AwsIamGetPolicyResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts b/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts index 2f9113b615d..8ee1d93e5c7 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts @@ -1,22 +1,15 @@ import { z } from 'zod' +import { iamConnectionShape, iamRoleNameSchema } from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), + ...iamConnectionShape, + roleName: iamRoleNameSchema, }) const GetRoleResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-get-user.ts b/apps/sim/lib/api/contracts/tools/aws/iam-get-user.ts index faa6383fb69..fc56cded29c 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-get-user.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-get-user.ts @@ -1,22 +1,15 @@ import { z } from 'zod' +import { iamConnectionShape, iamUserName128Schema } from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1).optional().nullable(), + ...iamConnectionShape, + userName: iamUserName128Schema.optional().nullable(), }) const GetUserResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-list-access-keys.ts b/apps/sim/lib/api/contracts/tools/aws/iam-list-access-keys.ts new file mode 100644 index 00000000000..3b2def57475 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/iam-list-access-keys.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import { + iamConnectionShape, + iamMarkerSchema, + iamMaxItemsSchema, + iamPaginationResponseShape, + iamUserName128Schema, +} from '@/lib/api/contracts/tools/aws/iam-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const Schema = z.object({ + ...iamConnectionShape, + userName: iamUserName128Schema.optional().nullable(), + maxItems: iamMaxItemsSchema.optional().nullable(), + marker: iamMarkerSchema.optional().nullable(), +}) + +/** + * The secret access key is never returned by ListAccessKeys; only the key's metadata is. + */ +const ListAccessKeysResponseSchema = z.object({ + accessKeys: z.array( + z.object({ + accessKeyId: z.string(), + userName: z.string(), + status: z.string(), + createDate: z.string().nullable(), + }) + ), + ...iamPaginationResponseShape, +}) + +export const awsIamListAccessKeysContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/iam/list-access-keys', + body: Schema, + response: { mode: 'json', schema: ListAccessKeysResponseSchema }, +}) +export type AwsIamListAccessKeysRequest = ContractBodyInput +export type AwsIamListAccessKeysBody = ContractBody +export type AwsIamListAccessKeysResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-list-attached-role-policies.ts b/apps/sim/lib/api/contracts/tools/aws/iam-list-attached-role-policies.ts index eee4da1387c..260a94ef7fe 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-list-attached-role-policies.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-list-attached-role-policies.ts @@ -1,37 +1,35 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamMarkerSchema, + iamMaxItemsSchema, + iamPaginationResponseShape, + iamPolicyPathPrefixSchema, + iamRoleNameSchema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - roleName: z.string().min(1, 'Role name is required'), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), + ...iamConnectionShape, + roleName: iamRoleNameSchema, + pathPrefix: iamPolicyPathPrefixSchema.optional().nullable(), + maxItems: iamMaxItemsSchema.optional().nullable(), + marker: iamMarkerSchema.optional().nullable(), +}) + +const AttachedPolicySchema = z.object({ + policyName: z.string(), + policyArn: z.string(), }) const ListAttachedRolePoliciesResponseSchema = z.object({ - attachedPolicies: z.array( - z.object({ - policyName: z.string(), - policyArn: z.string(), - }) - ), - isTruncated: z.boolean(), - marker: z.string().nullable(), - count: z.number(), + attachedPolicies: z.array(AttachedPolicySchema), + ...iamPaginationResponseShape, }) export const awsIamListAttachedRolePoliciesContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-list-attached-user-policies.ts b/apps/sim/lib/api/contracts/tools/aws/iam-list-attached-user-policies.ts index b8304da115f..2b148a269f2 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-list-attached-user-policies.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-list-attached-user-policies.ts @@ -1,37 +1,35 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamMarkerSchema, + iamMaxItemsSchema, + iamPaginationResponseShape, + iamPolicyPathPrefixSchema, + iamUserName64Schema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), + ...iamConnectionShape, + userName: iamUserName64Schema, + pathPrefix: iamPolicyPathPrefixSchema.optional().nullable(), + maxItems: iamMaxItemsSchema.optional().nullable(), + marker: iamMarkerSchema.optional().nullable(), +}) + +const AttachedPolicySchema = z.object({ + policyName: z.string(), + policyArn: z.string(), }) const ListAttachedUserPoliciesResponseSchema = z.object({ - attachedPolicies: z.array( - z.object({ - policyName: z.string(), - policyArn: z.string(), - }) - ), - isTruncated: z.boolean(), - marker: z.string().nullable(), - count: z.number(), + attachedPolicies: z.array(AttachedPolicySchema), + ...iamPaginationResponseShape, }) export const awsIamListAttachedUserPoliciesContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-list-groups.ts b/apps/sim/lib/api/contracts/tools/aws/iam-list-groups.ts index 63882b6f974..a98717860e2 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-list-groups.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-list-groups.ts @@ -1,24 +1,23 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamEntityListPathPrefixSchema, + iamMarkerSchema, + iamMaxItemsSchema, + iamPaginationResponseShape, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), + ...iamConnectionShape, + pathPrefix: iamEntityListPathPrefixSchema.optional().nullable(), + maxItems: iamMaxItemsSchema.optional().nullable(), + marker: iamMarkerSchema.optional().nullable(), }) const ListGroupsResponseSchema = z.object({ @@ -31,9 +30,7 @@ const ListGroupsResponseSchema = z.object({ createDate: z.string().nullable(), }) ), - isTruncated: z.boolean(), - marker: z.string().nullable(), - count: z.number(), + ...iamPaginationResponseShape, }) export const awsIamListGroupsContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts b/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts index 9b898792617..42d0f5a7bba 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts @@ -1,28 +1,32 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamMarkerSchema, + iamMaxItemsSchema, + iamPaginationResponseShape, + iamPolicyPathPrefixSchema, + iamPolicyScopeSchema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - scope: z.string().optional().nullable(), + ...iamConnectionShape, + scope: iamPolicyScopeSchema.optional().nullable(), onlyAttached: z.boolean().optional().nullable(), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), + pathPrefix: iamPolicyPathPrefixSchema.optional().nullable(), + maxItems: iamMaxItemsSchema.optional().nullable(), + marker: iamMarkerSchema.optional().nullable(), }) +/** + * `description` is deliberately absent: AWS documents it as returned by GetPolicy and + * never by ListPolicies, so surfacing it here would always be null. Use `iam_get_policy`. + */ const ListPoliciesResponseSchema = z.object({ policies: z.array( z.object({ @@ -34,14 +38,11 @@ const ListPoliciesResponseSchema = z.object({ isAttachable: z.boolean(), createDate: z.string().nullable(), updateDate: z.string().nullable(), - description: z.string().nullable(), defaultVersionId: z.string().nullable(), permissionsBoundaryUsageCount: z.number(), }) ), - isTruncated: z.boolean(), - marker: z.string().nullable(), - count: z.number(), + ...iamPaginationResponseShape, }) export const awsIamListPoliciesContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-list-roles.ts b/apps/sim/lib/api/contracts/tools/aws/iam-list-roles.ts index 3d50a47a954..483d45e094c 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-list-roles.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-list-roles.ts @@ -1,24 +1,23 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamEntityListPathPrefixSchema, + iamMarkerSchema, + iamMaxItemsSchema, + iamPaginationResponseShape, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), + ...iamConnectionShape, + pathPrefix: iamEntityListPathPrefixSchema.optional().nullable(), + maxItems: iamMaxItemsSchema.optional().nullable(), + marker: iamMarkerSchema.optional().nullable(), }) const ListRolesResponseSchema = z.object({ @@ -33,9 +32,7 @@ const ListRolesResponseSchema = z.object({ maxSessionDuration: z.number().nullable(), }) ), - isTruncated: z.boolean(), - marker: z.string().nullable(), - count: z.number(), + ...iamPaginationResponseShape, }) export const awsIamListRolesContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-list-users.ts b/apps/sim/lib/api/contracts/tools/aws/iam-list-users.ts index 86afec61acb..553f621f7ae 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-list-users.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-list-users.ts @@ -1,40 +1,37 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamEntityListPathPrefixSchema, + iamMarkerSchema, + iamMaxItemsSchema, + iamPaginationResponseShape, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - pathPrefix: z.string().optional().nullable(), - maxItems: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), + ...iamConnectionShape, + pathPrefix: iamEntityListPathPrefixSchema.optional().nullable(), + maxItems: iamMaxItemsSchema.optional().nullable(), + marker: iamMarkerSchema.optional().nullable(), +}) + +const UserSchema = z.object({ + userName: z.string(), + userId: z.string(), + arn: z.string(), + path: z.string(), + createDate: z.string().nullable(), + passwordLastUsed: z.string().nullable(), }) const ListUsersResponseSchema = z.object({ - users: z.array( - z.object({ - userName: z.string(), - userId: z.string(), - arn: z.string(), - path: z.string(), - createDate: z.string().nullable(), - passwordLastUsed: z.string().nullable(), - }) - ), - isTruncated: z.boolean(), - marker: z.string().nullable(), - count: z.number(), + users: z.array(UserSchema), + ...iamPaginationResponseShape, }) export const awsIamListUsersContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-remove-user-from-group.ts b/apps/sim/lib/api/contracts/tools/aws/iam-remove-user-from-group.ts index dfaea8b5619..84995bb67b1 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-remove-user-from-group.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-remove-user-from-group.ts @@ -1,23 +1,20 @@ import { z } from 'zod' +import { + iamConnectionShape, + iamGroupNameSchema, + iamUserName128Schema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - userName: z.string().min(1, 'User name is required'), - groupName: z.string().min(1, 'Group name is required'), + ...iamConnectionShape, + userName: iamUserName128Schema, + groupName: iamGroupNameSchema, }) export const awsIamRemoveUserFromGroupContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-shared.test.ts b/apps/sim/lib/api/contracts/tools/aws/iam-shared.test.ts new file mode 100644 index 00000000000..77bb6f2e07a --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/iam-shared.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { iamRegionSchema } from '@/lib/api/contracts/tools/aws/iam-shared' + +describe('iamRegionSchema', () => { + it.each([ + 'us-east-1', + 'eu-west-2', + 'ap-southeast-4', + 'sa-east-1', + 'il-central-1', + 'mx-central-1', + 'ca-west-1', + ])('accepts the commercial region %s', (region) => { + expect(iamRegionSchema.safeParse(region).success).toBe(true) + }) + + it.each(['us-gov-east-1', 'us-gov-west-1', 'cn-north-1', 'cn-northwest-1'])( + 'accepts the partitioned region %s', + (region) => { + expect(iamRegionSchema.safeParse(region).success).toBe(true) + } + ) + + it.each(['us-iso-east-1', 'us-isob-east-1', 'eu-isoe-west-1', 'eusc-de-east-1'])( + 'accepts the isolated region %s', + (region) => { + expect(iamRegionSchema.safeParse(region).success).toBe(true) + } + ) + + /** + * The pinned `@aws-sdk/client-iam` endpoint ruleset maps the ISO-F partition to + * `https://iam.us-isof-south-1.csp.hci.ic.gov`, so IAM demonstrably exists there. + */ + it.each(['us-isof-south-1', 'us-isof-east-1'])('accepts the ISO-F region %s', (region) => { + expect(iamRegionSchema.safeParse(region).success).toBe(true) + }) + + it('rejects an empty region', () => { + expect(iamRegionSchema.safeParse('').success).toBe(false) + }) + + it.each([ + 'us_east_1', + 'US-EAST-1', + 'us east 1', + 'useast1', + 'us-east-1.evil.example.com', + 'us-east-1/../admin', + 'us-east-1:443', + 'user@us-east-1', + 'us-east-1\nx-injected: 1', + '-us-east-1', + 'us-east-1-', + ])('rejects the host-unsafe or malformed value %j', (region) => { + expect(iamRegionSchema.safeParse(region).success).toBe(false) + }) + + it('rejects a region longer than 64 characters', () => { + expect(iamRegionSchema.safeParse(`us-${'a'.repeat(70)}-1`).success).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts b/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts new file mode 100644 index 00000000000..26559165348 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts @@ -0,0 +1,268 @@ +import { z } from 'zod' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +/** + * Boundary primitives for the AWS IAM tool contracts. + * + * Every bound and pattern here is transcribed from the IAM API Reference. AWS documents + * different bounds for the same-named parameter across actions — `UserName` is 1-64 on + * CreateUser/AttachUserPolicy/ListAttachedUserPolicies but 1-128 on GetUser, DeleteUser, + * and the access-key and group-membership actions — so each contract picks the builder + * that matches its own action rather than sharing one bound. + */ + +/** IAM friendly-name character class, shared by user, role, and group names. */ +const IAM_NAME_PATTERN = /^[\w+=,.@-]+$/ + +/** `PathPrefix` on the entity list actions: ListUsers, ListRoles, ListGroups. */ +const ENTITY_LIST_PATH_PREFIX_PATTERN = /^\u002F[\u0021-\u007F]*$/ + +/** `Path` on the create actions: CreateUser, CreateRole. Requires a trailing slash. */ +const CREATE_PATH_PATTERN = /^(?:\u002F|\u002F[\u0021-\u007E]+\u002F)$/ + +/** `PathPrefix` on the policy family: ListPolicies and the ListAttached*Policies actions. */ +const POLICY_PATH_PREFIX_PATTERN = /^(?:\u002F[A-Za-z0-9.,+@=_-]+)*\u002F$/ + +const MARKER_PATTERN = /^[\u0020-\u00FF]+$/ + +const ACCESS_KEY_ID_PATTERN = /^[\w]+$/ + +const POLICY_DOCUMENT_PATTERN = /^[\u0009\u000A\u000D\u0020-\u00FF]+$/ + +const ROLE_DESCRIPTION_PATTERN = /^[\u0009\u000A\u000D\u0020-\u007E\u00A1-\u00FF]*$/ + +export const iamRegionSchema = z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }) + +export const iamAccessKeyIdSchema = z.string().min(1, 'AWS access key ID is required') + +export const iamSecretAccessKeySchema = z.string().min(1, 'AWS secret access key is required') + +/** The credential slice every IAM tool contract carries. */ +export const iamConnectionShape = { + region: iamRegionSchema, + accessKeyId: iamAccessKeyIdSchema, + secretAccessKey: iamSecretAccessKeySchema, +} + +/** `UserName` where AWS documents 1-64: CreateUser, AttachUserPolicy, DetachUserPolicy, ListAttachedUserPolicies. */ +export const iamUserName64Schema = z + .string() + .min(1, 'User name cannot be empty') + .max(64, 'User name cannot exceed 64 characters') + .regex(IAM_NAME_PATTERN, 'User name may contain only letters, digits, and _+=,.@-') + +/** `UserName` where AWS documents 1-128: GetUser, DeleteUser, the access-key actions, group membership. */ +export const iamUserName128Schema = z + .string() + .min(1, 'User name cannot be empty') + .max(128, 'User name cannot exceed 128 characters') + .regex(IAM_NAME_PATTERN, 'User name may contain only letters, digits, and _+=,.@-') + +/** `RoleName` is 1-64 on every IAM action that accepts it. */ +export const iamRoleNameSchema = z + .string() + .min(1, 'Role name cannot be empty') + .max(64, 'Role name cannot exceed 64 characters') + .regex(IAM_NAME_PATTERN, 'Role name may contain only letters, digits, and _+=,.@-') + +/** `GroupName` is 1-128 on AddUserToGroup and RemoveUserFromGroup. */ +export const iamGroupNameSchema = z + .string() + .min(1, 'Group name cannot be empty') + .max(128, 'Group name cannot exceed 128 characters') + .regex(IAM_NAME_PATTERN, 'Group name may contain only letters, digits, and _+=,.@-') + +/** `PolicyArn` on the attach/detach and GetPolicy actions: 20-2048, no documented pattern. */ +export const iamPolicyArnSchema = z + .string() + .min(20, 'Policy ARN must be at least 20 characters') + .max(2048, 'Policy ARN cannot exceed 2048 characters') + +/** `PolicySourceArn` on SimulatePrincipalPolicy: 20-2048, no documented pattern. */ +export const iamPolicySourceArnSchema = z + .string() + .min(20, 'Principal ARN must be at least 20 characters') + .max(2048, 'Principal ARN cannot exceed 2048 characters') + +export const iamAssumeRolePolicyDocumentSchema = z + .string() + .min(1, 'Trust policy document cannot be empty') + .max(131072, 'Trust policy document cannot exceed 131072 characters') + .regex(POLICY_DOCUMENT_PATTERN, 'Trust policy document contains unsupported characters') + +/** CreateRole `Description` has no documented minimum and a max of 1000. */ +export const iamRoleDescriptionSchema = z + .string() + .max(1000, 'Role description cannot exceed 1000 characters') + .regex(ROLE_DESCRIPTION_PATTERN, 'Role description contains unsupported characters') + +/** `Path` on CreateUser and CreateRole: 1-512, must begin and end with a slash. */ +export const iamCreatePathSchema = z + .string() + .min(1, 'Path cannot be empty') + .max(512, 'Path cannot exceed 512 characters') + .regex(CREATE_PATH_PATTERN, 'Path must be "/" or begin and end with "/" (e.g., "/division_abc/")') + +/** `PathPrefix` on ListUsers, ListRoles, and ListGroups: 1-512, must begin with a slash. */ +export const iamEntityListPathPrefixSchema = z + .string() + .min(1, 'Path prefix cannot be empty') + .max(512, 'Path prefix cannot exceed 512 characters') + .regex(ENTITY_LIST_PATH_PREFIX_PATTERN, 'Path prefix must begin with "/"') + +/** `PathPrefix` on the policy family, whose documented regex is narrower than the entity list one. */ +export const iamPolicyPathPrefixSchema = z + .string() + .min(1, 'Path prefix cannot be empty') + .max(512, 'Path prefix cannot exceed 512 characters') + .regex( + POLICY_PATH_PREFIX_PATTERN, + 'Path prefix must end with "/" and may contain only letters, digits, and .,+@=_-' + ) + +/** `AccessKeyId` on DeleteAccessKey and UpdateAccessKey: 16-128 word characters. */ +export const iamAccessKeyIdentifierSchema = z + .string() + .min(16, 'Access key ID must be at least 16 characters') + .max(128, 'Access key ID cannot exceed 128 characters') + .regex(ACCESS_KEY_ID_PATTERN, 'Access key ID may contain only letters, digits, and underscores') + +/** `MaxItems` is documented as 1-1000 on every paginated IAM action. */ +export const iamMaxItemsSchema = z + .number() + .int('Max items must be a whole number') + .min(1, 'Max items must be at least 1') + .max(1000, 'Max items cannot exceed 1000') + +/** `Marker` has a documented minimum of 1 and no documented maximum. */ +export const iamMarkerSchema = z + .string() + .min(1, 'Pagination marker cannot be empty') + .regex(MARKER_PATTERN, 'Pagination marker contains unsupported characters') + +export const iamPolicyScopeSchema = z.enum(['All', 'AWS', 'Local'], { + message: 'Policy scope must be one of: All, AWS, Local', +}) + +/** UpdateAccessKey accepts `Expired` on the wire, but only Active/Inactive are settable. */ +export const iamAccessKeyStatusSchema = z.enum(['Active', 'Inactive'], { + message: 'Access key status must be either Active or Inactive', +}) + +export const iamContextKeyTypeSchema = z.enum( + [ + 'binary', + 'binaryList', + 'boolean', + 'booleanList', + 'date', + 'dateList', + 'ip', + 'ipList', + 'numeric', + 'numericList', + 'string', + 'stringList', + ], + { message: 'Context key type must be a documented IAM context key type (e.g., string, ip, date)' } +) + +/** + * Payload guards, not AWS constraints. `SimulatePrincipalPolicy` documents per-member + * bounds but no array count limit, so these are set far above any realistic simulate + * call: they bound request memory without narrowing what previously validated. + */ +const MAX_SIMULATED_ACTIONS = 1000 +const MAX_SIMULATED_RESOURCES = 1000 + +/** + * `ActionNames` reaches the tool as one comma-separated field. AWS documents each member + * as 3-128 characters, so validate the members rather than the joined string. + */ +export const iamActionNamesSchema = z + .string() + .min(1, 'At least one action name is required') + .superRefine((value, ctx) => { + const actions = value + .split(',') + .map((a) => a.trim()) + .filter(Boolean) + if (actions.length === 0) { + ctx.addIssue({ code: 'custom', message: 'At least one action name is required' }) + return + } + if (actions.length > MAX_SIMULATED_ACTIONS) { + ctx.addIssue({ + code: 'custom', + message: `Cannot simulate more than ${MAX_SIMULATED_ACTIONS} actions in one request`, + }) + } + for (const action of actions) { + if (action.length < 3 || action.length > 128) { + ctx.addIssue({ + code: 'custom', + message: `Action name "${action}" must be between 3 and 128 characters (e.g., s3:GetObject)`, + }) + } + } + }) + +/** `ResourceArns` members are documented as 1-2048 characters each. */ +export const iamResourceArnsSchema = z + .string() + .min(1, 'Resource ARNs cannot be empty') + .superRefine((value, ctx) => { + const arns = value + .split(',') + .map((r) => r.trim()) + .filter(Boolean) + if (arns.length === 0) { + ctx.addIssue({ + code: 'custom', + message: 'Resource ARNs must contain at least one ARN, or be omitted to simulate against *', + }) + return + } + if (arns.length > MAX_SIMULATED_RESOURCES) { + ctx.addIssue({ + code: 'custom', + message: `Cannot simulate more than ${MAX_SIMULATED_RESOURCES} resource ARNs in one request`, + }) + } + for (const arn of arns) { + if (arn.length > 2048) { + ctx.addIssue({ + code: 'custom', + message: `Resource ARN "${arn.slice(0, 40)}..." cannot exceed 2048 characters`, + }) + } + } + }) + +export const iamContextEntrySchema = z.object({ + contextKeyName: z + .string() + .min(5, 'Context key name must be at least 5 characters (e.g., aws:SourceIp)') + .max(256, 'Context key name cannot exceed 256 characters'), + contextKeyValues: z + .array(z.string().min(1, 'Context key values cannot contain empty strings')) + .min(1, 'Provide at least one value for each context key') + .max(64, 'A context key cannot carry more than 64 values'), + contextKeyType: iamContextKeyTypeSchema, +}) + +export const iamContextEntriesSchema = z + .array(iamContextEntrySchema) + .max(64, 'Cannot supply more than 64 context entries in one simulation') + +/** The response slice every paginated IAM contract returns. */ +export const iamPaginationResponseShape = { + isTruncated: z.boolean(), + marker: z.string().nullable(), + count: z.number(), +} diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-simulate-principal-policy.ts b/apps/sim/lib/api/contracts/tools/aws/iam-simulate-principal-policy.ts index 0a1edd1d0d0..aff736b0bb1 100644 --- a/apps/sim/lib/api/contracts/tools/aws/iam-simulate-principal-policy.ts +++ b/apps/sim/lib/api/contracts/tools/aws/iam-simulate-principal-policy.ts @@ -1,46 +1,62 @@ import { z } from 'zod' +import { + iamActionNamesSchema, + iamConnectionShape, + iamContextEntriesSchema, + iamMarkerSchema, + iamMaxItemsSchema, + iamPaginationResponseShape, + iamPolicySourceArnSchema, + iamResourceArnsSchema, +} from '@/lib/api/contracts/tools/aws/iam-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - policySourceArn: z.string().min(1, 'Policy source ARN is required'), - actionNames: z.string().min(1, 'Action names are required'), - resourceArns: z.string().optional().nullable(), - maxResults: z.number().int().min(1).max(1000).optional().nullable(), - marker: z.string().optional().nullable(), + ...iamConnectionShape, + policySourceArn: iamPolicySourceArnSchema, + actionNames: iamActionNamesSchema, + resourceArns: iamResourceArnsSchema.optional().nullable(), + contextEntries: iamContextEntriesSchema.optional().nullable(), + maxResults: iamMaxItemsSchema.optional().nullable(), + marker: iamMarkerSchema.optional().nullable(), +}) + +const MatchedStatementSchema = z.object({ + sourcePolicyId: z.string(), + sourcePolicyType: z.string(), +}) + +/** + * The decision for one concrete resource ARN. AWS returns a single evaluation result per + * action no matter how many resource ARNs were simulated, so per-ARN truth lives only + * here — and when concrete ARNs are supplied, so do the missing context values. + */ +const ResourceSpecificResultSchema = z.object({ + evalResourceName: z.string(), + evalResourceDecision: z.string(), + matchedStatements: z.array(MatchedStatementSchema), + missingContextValues: z.array(z.string()), + permissionsBoundaryAllowed: z.boolean().nullable(), +}) + +const EvaluationResultSchema = z.object({ + evalActionName: z.string(), + evalResourceName: z.string(), + evalDecision: z.string(), + matchedStatements: z.array(MatchedStatementSchema), + missingContextValues: z.array(z.string()), + permissionsBoundaryAllowed: z.boolean().nullable(), + resourceSpecificResults: z.array(ResourceSpecificResultSchema), }) const SimulatePrincipalPolicyResponseSchema = z.object({ - evaluationResults: z.array( - z.object({ - evalActionName: z.string(), - evalResourceName: z.string(), - evalDecision: z.string(), - matchedStatements: z.array( - z.object({ - sourcePolicyId: z.string(), - sourcePolicyType: z.string(), - }) - ), - missingContextValues: z.array(z.string()), - }) - ), - isTruncated: z.boolean(), - marker: z.string().nullable(), - count: z.number(), + evaluationResults: z.array(EvaluationResultSchema), + ...iamPaginationResponseShape, }) export const awsIamSimulatePrincipalPolicyContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-update-access-key.ts b/apps/sim/lib/api/contracts/tools/aws/iam-update-access-key.ts new file mode 100644 index 00000000000..aa83d72e85f --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/iam-update-access-key.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' +import { + iamAccessKeyIdentifierSchema, + iamAccessKeyStatusSchema, + iamConnectionShape, + iamUserName128Schema, +} from '@/lib/api/contracts/tools/aws/iam-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const Schema = z.object({ + ...iamConnectionShape, + accessKeyIdToUpdate: iamAccessKeyIdentifierSchema, + status: iamAccessKeyStatusSchema, + userName: iamUserName128Schema.optional().nullable(), +}) + +export const awsIamUpdateAccessKeyContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/iam/update-access-key', + body: Schema, + response: { mode: 'json', schema: z.object({ message: z.string() }) }, +}) +export type AwsIamUpdateAccessKeyRequest = ContractBodyInput +export type AwsIamUpdateAccessKeyBody = ContractBody +export type AwsIamUpdateAccessKeyResponse = ContractJsonResponse< + typeof awsIamUpdateAccessKeyContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-check-assignment-deletion-status.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-check-assignment-deletion-status.ts index 15c0b61ff5d..5dc2f54d65e 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-check-assignment-deletion-status.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-check-assignment-deletion-status.ts @@ -1,42 +1,28 @@ import { z } from 'zod' +import { + identityCenterAssignmentStatusResponseSchema, + identityCenterConnectionShape, + identityCenterInstanceArnSchema, + identityCenterRequestIdSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - requestId: z.string().min(1, 'Request ID is required'), -}) - -const ResponseSchema = z.object({ - message: z.string(), - status: z.string(), - requestId: z.string(), - accountId: z.string().nullable(), - permissionSetArn: z.string().nullable(), - principalType: z.string().nullable(), - principalId: z.string().nullable(), - failureReason: z.string().nullable(), - createdDate: z.string().nullable(), + ...identityCenterConnectionShape, + instanceArn: identityCenterInstanceArnSchema, + requestId: identityCenterRequestIdSchema, }) export const awsIdentityCenterCheckAssignmentDeletionStatusContract = defineRouteContract({ method: 'POST', path: '/api/tools/identity-center/check-assignment-deletion-status', body: Schema, - response: { mode: 'json', schema: ResponseSchema }, + response: { mode: 'json', schema: identityCenterAssignmentStatusResponseSchema }, }) export type AwsIdentityCenterCheckAssignmentDeletionStatusRequest = ContractBodyInput< typeof awsIdentityCenterCheckAssignmentDeletionStatusContract diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-check-assignment-status.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-check-assignment-status.ts index d2108e1a949..f591ccef8d9 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-check-assignment-status.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-check-assignment-status.ts @@ -1,42 +1,28 @@ import { z } from 'zod' +import { + identityCenterAssignmentStatusResponseSchema, + identityCenterConnectionShape, + identityCenterInstanceArnSchema, + identityCenterRequestIdSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - requestId: z.string().min(1, 'Request ID is required'), -}) - -const ResponseSchema = z.object({ - message: z.string(), - status: z.string(), - requestId: z.string(), - accountId: z.string().nullable(), - permissionSetArn: z.string().nullable(), - principalType: z.string().nullable(), - principalId: z.string().nullable(), - failureReason: z.string().nullable(), - createdDate: z.string().nullable(), + ...identityCenterConnectionShape, + instanceArn: identityCenterInstanceArnSchema, + requestId: identityCenterRequestIdSchema, }) export const awsIdentityCenterCheckAssignmentStatusContract = defineRouteContract({ method: 'POST', path: '/api/tools/identity-center/check-assignment-status', body: Schema, - response: { mode: 'json', schema: ResponseSchema }, + response: { mode: 'json', schema: identityCenterAssignmentStatusResponseSchema }, }) export type AwsIdentityCenterCheckAssignmentStatusRequest = ContractBodyInput< typeof awsIdentityCenterCheckAssignmentStatusContract diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-create-account-assignment.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-create-account-assignment.ts index 04279dc2c6d..ffc20a716b8 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-create-account-assignment.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-create-account-assignment.ts @@ -1,45 +1,34 @@ import { z } from 'zod' +import { + identityCenterAccountIdSchema, + identityCenterAssignmentStatusResponseSchema, + identityCenterConnectionShape, + identityCenterInstanceArnSchema, + identityCenterPermissionSetArnSchema, + identityCenterPrincipalIdSchema, + identityCenterPrincipalTypeSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - accountId: z.string().min(1, 'Account ID is required'), - permissionSetArn: z.string().min(1, 'Permission set ARN is required'), - principalType: z.enum(['USER', 'GROUP']), - principalId: z.string().min(1, 'Principal ID is required'), -}) - -const ResponseSchema = z.object({ - message: z.string(), - status: z.string(), - requestId: z.string(), - accountId: z.string().nullable(), - permissionSetArn: z.string().nullable(), - principalType: z.string().nullable(), - principalId: z.string().nullable(), - failureReason: z.string().nullable(), - createdDate: z.string().nullable(), + ...identityCenterConnectionShape, + instanceArn: identityCenterInstanceArnSchema, + accountId: identityCenterAccountIdSchema, + permissionSetArn: identityCenterPermissionSetArnSchema, + principalType: identityCenterPrincipalTypeSchema, + principalId: identityCenterPrincipalIdSchema, }) export const awsIdentityCenterCreateAccountAssignmentContract = defineRouteContract({ method: 'POST', path: '/api/tools/identity-center/create-account-assignment', body: Schema, - response: { mode: 'json', schema: ResponseSchema }, + response: { mode: 'json', schema: identityCenterAssignmentStatusResponseSchema }, }) export type AwsIdentityCenterCreateAccountAssignmentRequest = ContractBodyInput< typeof awsIdentityCenterCreateAccountAssignmentContract diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-delete-account-assignment.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-delete-account-assignment.ts index 97af0baf98b..7c0b9e5a46a 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-delete-account-assignment.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-delete-account-assignment.ts @@ -1,45 +1,34 @@ import { z } from 'zod' +import { + identityCenterAccountIdSchema, + identityCenterAssignmentStatusResponseSchema, + identityCenterConnectionShape, + identityCenterInstanceArnSchema, + identityCenterPermissionSetArnSchema, + identityCenterPrincipalIdSchema, + identityCenterPrincipalTypeSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - accountId: z.string().min(1, 'Account ID is required'), - permissionSetArn: z.string().min(1, 'Permission set ARN is required'), - principalType: z.enum(['USER', 'GROUP']), - principalId: z.string().min(1, 'Principal ID is required'), -}) - -const ResponseSchema = z.object({ - message: z.string(), - status: z.string(), - requestId: z.string(), - accountId: z.string().nullable(), - permissionSetArn: z.string().nullable(), - principalType: z.string().nullable(), - principalId: z.string().nullable(), - failureReason: z.string().nullable(), - createdDate: z.string().nullable(), + ...identityCenterConnectionShape, + instanceArn: identityCenterInstanceArnSchema, + accountId: identityCenterAccountIdSchema, + permissionSetArn: identityCenterPermissionSetArnSchema, + principalType: identityCenterPrincipalTypeSchema, + principalId: identityCenterPrincipalIdSchema, }) export const awsIdentityCenterDeleteAccountAssignmentContract = defineRouteContract({ method: 'POST', path: '/api/tools/identity-center/delete-account-assignment', body: Schema, - response: { mode: 'json', schema: ResponseSchema }, + response: { mode: 'json', schema: identityCenterAssignmentStatusResponseSchema }, }) export type AwsIdentityCenterDeleteAccountAssignmentRequest = ContractBodyInput< typeof awsIdentityCenterDeleteAccountAssignmentContract diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-account.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-account.ts index 361a2ae4acf..061d80d9488 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-account.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-account.ts @@ -1,22 +1,18 @@ import { z } from 'zod' +import { + identityCenterAccountIdSchema, + identityCenterConnectionShape, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - accountId: z.string().min(12, 'Account ID must be 12 digits').max(12), + ...identityCenterConnectionShape, + accountId: identityCenterAccountIdSchema, }) const ResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-group.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-group.ts new file mode 100644 index 00000000000..0e837743ff9 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-group.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { + identityCenterConnectionShape, + identityCenterGroupIdSchema, + identityCenterIdentityStoreIdSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const Schema = z.object({ + ...identityCenterConnectionShape, + identityStoreId: identityCenterIdentityStoreIdSchema, + groupId: identityCenterGroupIdSchema, +}) + +const ResponseSchema = z.object({ + groupId: z.string(), + displayName: z.string().nullable(), + description: z.string().nullable(), + externalIds: z.array(z.object({ issuer: z.string(), id: z.string() })), +}) + +export const awsIdentityCenterDescribeGroupContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/identity-center/describe-group', + body: Schema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsIdentityCenterDescribeGroupRequest = ContractBodyInput< + typeof awsIdentityCenterDescribeGroupContract +> +export type AwsIdentityCenterDescribeGroupBody = ContractBody< + typeof awsIdentityCenterDescribeGroupContract +> +export type AwsIdentityCenterDescribeGroupResponse = ContractJsonResponse< + typeof awsIdentityCenterDescribeGroupContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts new file mode 100644 index 00000000000..66915a7dbc5 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { + identityCenterConnectionShape, + identityCenterIdentityStoreIdSchema, + identityCenterUserIdSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const Schema = z.object({ + ...identityCenterConnectionShape, + identityStoreId: identityCenterIdentityStoreIdSchema, + userId: identityCenterUserIdSchema, +}) + +const ResponseSchema = z.object({ + userId: z.string(), + userName: z.string(), + displayName: z.string().nullable(), + email: z.string().nullable(), + userStatus: z.string().nullable(), + title: z.string().nullable(), + externalIds: z.array(z.object({ issuer: z.string(), id: z.string() })), +}) + +export const awsIdentityCenterDescribeUserContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/identity-center/describe-user', + body: Schema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsIdentityCenterDescribeUserRequest = ContractBodyInput< + typeof awsIdentityCenterDescribeUserContract +> +export type AwsIdentityCenterDescribeUserBody = ContractBody< + typeof awsIdentityCenterDescribeUserContract +> +export type AwsIdentityCenterDescribeUserResponse = ContractJsonResponse< + typeof awsIdentityCenterDescribeUserContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts index 8eb385ee922..a4caa36bc6d 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts @@ -1,23 +1,19 @@ import { z } from 'zod' +import { + identityCenterConnectionShape, + identityCenterIdentityStoreIdSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - identityStoreId: z.string().min(1, 'Identity Store ID is required'), - displayName: z.string().min(1, 'Group display name is required'), + ...identityCenterConnectionShape, + identityStoreId: identityCenterIdentityStoreIdSchema, + displayName: z.string().min(1, 'Group display name is required').max(1024), }) const ResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts index 61f12c7b3aa..8ed5810ef28 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts @@ -1,22 +1,18 @@ import { z } from 'zod' +import { + identityCenterConnectionShape, + identityCenterIdentityStoreIdSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - identityStoreId: z.string().min(1, 'Identity Store ID is required'), + ...identityCenterConnectionShape, + identityStoreId: identityCenterIdentityStoreIdSchema, email: z.string().email('Valid email address is required'), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts index f16cd2598c1..a1c19b2cf47 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts @@ -1,26 +1,26 @@ import { z } from 'zod' +import { + identityCenterConnectionShape, + identityCenterInstanceArnSchema, + identityCenterMaxResultsSchema, + identityCenterNextTokenSchema, + identityCenterPrincipalIdSchema, + identityCenterPrincipalTypeSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - principalId: z.string().min(1, 'Principal ID is required'), - principalType: z.enum(['USER', 'GROUP']), - maxResults: z.number().min(1).max(100).optional(), - nextToken: z.string().optional(), + ...identityCenterConnectionShape, + instanceArn: identityCenterInstanceArnSchema, + principalId: identityCenterPrincipalIdSchema, + principalType: identityCenterPrincipalTypeSchema, + maxResults: identityCenterMaxResultsSchema.optional(), + nextToken: identityCenterNextTokenSchema.optional(), }) const ResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-accounts.test.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-accounts.test.ts new file mode 100644 index 00000000000..b4ab3417798 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-accounts.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { awsIdentityCenterListAccountsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-accounts' +import { awsIdentityCenterListAssignmentsForAccountContract } from '@/lib/api/contracts/tools/aws/identity-center-list-assignments-for-account' + +const connection = { + region: 'us-east-1', + accessKeyId: 'AKIAEXAMPLE', + secretAccessKey: 'secret', +} + +/** + * Organizations documents a 100,000-character maximum for the `ListAccounts` + * continuation token, well past the Identity Store bound the rest of the family + * shares. + * + * @see https://docs.aws.amazon.com/organizations/latest/APIReference/API_ListAccounts.html + */ +describe('identity center list-accounts nextToken bound', () => { + it('accepts an Organizations token longer than the Identity Store bound', () => { + const parsed = awsIdentityCenterListAccountsContract.body?.safeParse({ + ...connection, + nextToken: 'a'.repeat(100_000), + }) + expect(parsed?.success).toBe(true) + }) + + it('still rejects a token past the documented Organizations maximum', () => { + const parsed = awsIdentityCenterListAccountsContract.body?.safeParse({ + ...connection, + nextToken: 'a'.repeat(100_001), + }) + expect(parsed?.success).toBe(false) + }) + + it('leaves the SSO Admin token bound where it was', () => { + const parsed = awsIdentityCenterListAssignmentsForAccountContract.body?.safeParse({ + ...connection, + instanceArn: 'arn:aws:sso:::instance/ssoins-0123456789abcdef', + accountId: '111111111111', + permissionSetArn: 'arn:aws:sso:::permissionSet/ssoins-0123456789abcdef/ps-0123456789abcdef', + nextToken: 'a'.repeat(100_000), + }) + expect(parsed?.success).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-accounts.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-accounts.ts index cbb981266c5..b18abce3939 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-accounts.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-accounts.ts @@ -1,23 +1,20 @@ import { z } from 'zod' +import { + identityCenterAccountsMaxResultsSchema, + identityCenterConnectionShape, + identityCenterOrganizationsNextTokenSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - maxResults: z.number().min(1).max(20).optional(), - nextToken: z.string().optional(), + ...identityCenterConnectionShape, + maxResults: identityCenterAccountsMaxResultsSchema.optional(), + nextToken: identityCenterOrganizationsNextTokenSchema.optional(), }) const ResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-assignments-for-account.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-assignments-for-account.ts new file mode 100644 index 00000000000..82285a54ae3 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-assignments-for-account.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' +import { + identityCenterAccountIdSchema, + identityCenterConnectionShape, + identityCenterInstanceArnSchema, + identityCenterMaxResultsSchema, + identityCenterNextTokenSchema, + identityCenterPermissionSetArnSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const Schema = z.object({ + ...identityCenterConnectionShape, + instanceArn: identityCenterInstanceArnSchema, + accountId: identityCenterAccountIdSchema, + permissionSetArn: identityCenterPermissionSetArnSchema, + maxResults: identityCenterMaxResultsSchema.optional(), + nextToken: identityCenterNextTokenSchema.optional(), +}) + +const ResponseSchema = z.object({ + assignments: z.array( + z.object({ + accountId: z.string(), + permissionSetArn: z.string(), + principalType: z.string(), + principalId: z.string(), + }) + ), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsIdentityCenterListAssignmentsForAccountContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/identity-center/list-assignments-for-account', + body: Schema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsIdentityCenterListAssignmentsForAccountRequest = ContractBodyInput< + typeof awsIdentityCenterListAssignmentsForAccountContract +> +export type AwsIdentityCenterListAssignmentsForAccountBody = ContractBody< + typeof awsIdentityCenterListAssignmentsForAccountContract +> +export type AwsIdentityCenterListAssignmentsForAccountResponse = ContractJsonResponse< + typeof awsIdentityCenterListAssignmentsForAccountContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-group-memberships.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-group-memberships.ts new file mode 100644 index 00000000000..5af0fb0715f --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-group-memberships.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' +import { + identityCenterConnectionShape, + identityCenterGroupIdSchema, + identityCenterIdentityStoreIdSchema, + identityCenterMaxResultsSchema, + identityCenterNextTokenSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const Schema = z.object({ + ...identityCenterConnectionShape, + identityStoreId: identityCenterIdentityStoreIdSchema, + groupId: identityCenterGroupIdSchema, + maxResults: identityCenterMaxResultsSchema.optional(), + nextToken: identityCenterNextTokenSchema.optional(), +}) + +const ResponseSchema = z.object({ + memberships: z.array( + z.object({ + membershipId: z.string(), + groupId: z.string(), + userId: z.string().nullable(), + }) + ), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsIdentityCenterListGroupMembershipsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/identity-center/list-group-memberships', + body: Schema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsIdentityCenterListGroupMembershipsRequest = ContractBodyInput< + typeof awsIdentityCenterListGroupMembershipsContract +> +export type AwsIdentityCenterListGroupMembershipsBody = ContractBody< + typeof awsIdentityCenterListGroupMembershipsContract +> +export type AwsIdentityCenterListGroupMembershipsResponse = ContractJsonResponse< + typeof awsIdentityCenterListGroupMembershipsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-groups.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-groups.ts index ae147dc8ddf..e7c61aa5233 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-groups.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-groups.ts @@ -1,24 +1,22 @@ import { z } from 'zod' +import { + identityCenterConnectionShape, + identityCenterIdentityStoreIdSchema, + identityCenterMaxResultsSchema, + identityCenterNextTokenSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - identityStoreId: z.string().min(1, 'Identity Store ID is required'), - maxResults: z.number().min(1).max(100).optional(), - nextToken: z.string().optional(), + ...identityCenterConnectionShape, + identityStoreId: identityCenterIdentityStoreIdSchema, + maxResults: identityCenterMaxResultsSchema.optional(), + nextToken: identityCenterNextTokenSchema.optional(), }) const ResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-instances.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-instances.ts index bfeb996a4ea..2c679932a47 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-instances.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-instances.ts @@ -1,23 +1,20 @@ import { z } from 'zod' +import { + identityCenterConnectionShape, + identityCenterMaxResultsSchema, + identityCenterNextTokenSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - maxResults: z.number().min(1).max(100).optional(), - nextToken: z.string().optional(), + ...identityCenterConnectionShape, + maxResults: identityCenterMaxResultsSchema.optional(), + nextToken: identityCenterNextTokenSchema.optional(), }) const ResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-permission-sets.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-permission-sets.ts index 10788fa49ca..1d978e70bc4 100644 --- a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-permission-sets.ts +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-permission-sets.ts @@ -1,24 +1,22 @@ import { z } from 'zod' +import { + identityCenterConnectionShape, + identityCenterInstanceArnSchema, + identityCenterMaxResultsSchema, + identityCenterNextTokenSchema, +} from '@/lib/api/contracts/tools/aws/identity-center-shared' import type { ContractBody, ContractBodyInput, ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import { validateAwsRegion } from '@/lib/core/security/input-validation' const Schema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - instanceArn: z.string().min(1, 'Instance ARN is required'), - maxResults: z.number().min(1).max(100).optional(), - nextToken: z.string().optional(), + ...identityCenterConnectionShape, + instanceArn: identityCenterInstanceArnSchema, + maxResults: identityCenterMaxResultsSchema.optional(), + nextToken: identityCenterNextTokenSchema.optional(), }) const ResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-shared.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-shared.ts new file mode 100644 index 00000000000..5cda71392ea --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-shared.ts @@ -0,0 +1,148 @@ +import { z } from 'zod' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +/** + * Shared boundary schemas for the AWS IAM Identity Center tool family. + * + * Every pattern and bound below is the one AWS publishes for the shape, so a + * malformed identifier is rejected at the boundary with a readable message + * instead of surfacing as an opaque AWS `ValidationException`. + */ + +export const identityCenterRegionSchema = z + .string() + .min(1, 'AWS region is required') + .refine((value) => validateAwsRegion(value).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2, us-gov-west-1)', + }) + +/** Region plus static credentials, present on every tool in the family. */ +export const identityCenterConnectionShape = { + region: identityCenterRegionSchema, + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), +} + +/** @see https://docs.aws.amazon.com/singlesignon/latest/APIReference/API_ListAccountAssignments.html */ +export const identityCenterInstanceArnSchema = z + .string() + .min(10, 'Instance ARN is required') + .max(1224, 'Instance ARN must be at most 1224 characters') + .regex( + /^arn:aws(-[a-z]{1,5}){0,3}:sso:::instance\/(sso)?ins-[a-zA-Z0-9-.]{16}$/, + 'Instance ARN must look like arn:aws:sso:::instance/ssoins-0123456789abcdef' + ) + +/** @see https://docs.aws.amazon.com/singlesignon/latest/APIReference/API_AccountAssignment.html */ +export const identityCenterPermissionSetArnSchema = z + .string() + .min(10, 'Permission set ARN is required') + .max(1224, 'Permission set ARN must be at most 1224 characters') + .regex( + /^arn:aws(-[a-z]{1,5}){0,3}:sso:::permissionSet\/(sso)?ins-[a-zA-Z0-9-.]{16}\/ps-[a-zA-Z0-9-./]{16}$/, + 'Permission set ARN must look like arn:aws:sso:::permissionSet/ssoins-0123456789abcdef/ps-0123456789abcdef' + ) + +/** + * Identity Store user or group id, as accepted by SSO Admin `PrincipalId` and + * by Identity Store `UserId` / `GroupId`. + * + * @see https://docs.aws.amazon.com/singlesignon/latest/APIReference/API_AccountAssignment.html + */ +const identityStoreObjectIdPattern = + /^([0-9a-f]{10}-)?[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$/ + +export const identityCenterPrincipalIdSchema = z + .string() + .min(1, 'Principal ID is required') + .max(47, 'Principal ID must be at most 47 characters') + .regex( + identityStoreObjectIdPattern, + 'Principal ID must be an Identity Store user or group ID (e.g., 9067b2d8-8021-70f8-1234-5c6d7e8f9012)' + ) + +export const identityCenterUserIdSchema = z + .string() + .min(1, 'User ID is required') + .max(47, 'User ID must be at most 47 characters') + .regex(identityStoreObjectIdPattern, 'User ID must be an Identity Store user ID') + +export const identityCenterGroupIdSchema = z + .string() + .min(1, 'Group ID is required') + .max(47, 'Group ID must be at most 47 characters') + .regex(identityStoreObjectIdPattern, 'Group ID must be an Identity Store group ID') + +/** + * Identity Store service id. Narrower than the SSO Admin `IdentityStoreId` + * shape — every tool in this family calls the Identity Store API with it. + * + * @see https://docs.aws.amazon.com/singlesignon/latest/IdentityStoreAPIReference/API_DescribeUser.html + */ +export const identityCenterIdentityStoreIdSchema = z + .string() + .min(1, 'Identity Store ID is required') + .max(36, 'Identity Store ID must be at most 36 characters') + .regex( + /^(d-[0-9a-f]{10}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/, + 'Identity Store ID must look like d-1234567890' + ) + +/** @see https://docs.aws.amazon.com/singlesignon/latest/APIReference/API_DescribeAccountAssignmentCreationStatus.html */ +export const identityCenterRequestIdSchema = z + .string() + .length(36, 'Request ID must be a 36-character UUID') + .regex( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + 'Request ID must be a UUID returned by a create or delete assignment call' + ) + +/** @see https://docs.aws.amazon.com/organizations/latest/APIReference/API_Account.html */ +export const identityCenterAccountIdSchema = z + .string() + .regex(/^\d{12}$/, 'AWS account ID must be exactly 12 digits') + +export const identityCenterPrincipalTypeSchema = z.enum(['USER', 'GROUP']) + +export const identityCenterNextTokenSchema = z + .string() + .min(1, 'Pagination token cannot be empty') + .max(65535, 'Pagination token is too long') + +/** + * Organizations pagination tokens are documented far longer than the Identity + * Store bound above, so `ListAccounts` gets its own ceiling rather than sharing + * one that would reject a valid continuation token before AWS sees it. + * + * @see https://docs.aws.amazon.com/organizations/latest/APIReference/API_ListAccounts.html + */ +export const identityCenterOrganizationsNextTokenSchema = z + .string() + .min(1, 'Pagination token cannot be empty') + .max(100000, 'Pagination token is too long') + +/** Every list operation in the family except Organizations `ListAccounts`. */ +export const identityCenterMaxResultsSchema = z + .number() + .int('Max results must be a whole number') + .min(1, 'Max results must be at least 1') + .max(100, 'Max results must be at most 100') + +/** @see https://docs.aws.amazon.com/organizations/latest/APIReference/API_ListAccounts.html */ +export const identityCenterAccountsMaxResultsSchema = z + .number() + .int('Max results must be a whole number') + .min(1, 'Max results must be at least 1') + .max(20, 'AWS Organizations ListAccounts allows at most 20 results per page') + +export const identityCenterAssignmentStatusResponseSchema = z.object({ + message: z.string(), + status: z.string(), + requestId: z.string(), + accountId: z.string().nullable(), + permissionSetArn: z.string().nullable(), + principalType: z.string().nullable(), + principalId: z.string().nullable(), + failureReason: z.string().nullable(), + createdDate: z.string().nullable(), +}) diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-cancel-message-move-task.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-cancel-message-move-task.ts new file mode 100644 index 00000000000..ffc1ccdbf9f --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-cancel-message-move-task.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import { sqsConnectionFields } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const CancelMessageMoveTaskSchema = z.object({ + ...sqsConnectionFields, + taskHandle: z.string().min(1, 'Task handle is required'), +}) + +const CancelMessageMoveTaskResponseSchema = z.object({ + message: z.string(), + approximateNumberOfMessagesMoved: z.number().nullable(), +}) + +export const awsSqsCancelMessageMoveTaskContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/cancel-message-move-task', + body: CancelMessageMoveTaskSchema, + response: { mode: 'json', schema: CancelMessageMoveTaskResponseSchema }, +}) +export type AwsSqsCancelMessageMoveTaskRequest = ContractBodyInput< + typeof awsSqsCancelMessageMoveTaskContract +> +export type AwsSqsCancelMessageMoveTaskBody = ContractBody< + typeof awsSqsCancelMessageMoveTaskContract +> +export type AwsSqsCancelMessageMoveTaskResponse = ContractJsonResponse< + typeof awsSqsCancelMessageMoveTaskContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-change-message-visibility-batch.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-change-message-visibility-batch.ts new file mode 100644 index 00000000000..4976298913a --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-change-message-visibility-batch.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import { + hasDistinctBatchEntryIds, + SQS_DISTINCT_BATCH_ENTRY_IDS_MESSAGE, + SQS_MAX_BATCH_ENTRIES, + sqsBatchEntryIdSchema, + sqsBatchResultErrorEntrySchema, + sqsConnectionFields, + sqsQueueUrlField, +} from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const ChangeMessageVisibilityBatchSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + entries: z + .array( + z.object({ + id: sqsBatchEntryIdSchema, + receiptHandle: z.string().min(1, 'Receipt handle is required'), + visibilityTimeout: z + .number() + .int() + .min(0, 'visibilityTimeout must be at least 0') + .max(43200, 'visibilityTimeout cannot exceed 43200 seconds (12 hours)') + .nullish(), + }) + ) + .min(1, 'At least one entry is required') + .max(SQS_MAX_BATCH_ENTRIES, `A batch can hold at most ${SQS_MAX_BATCH_ENTRIES} entries`) + .refine(hasDistinctBatchEntryIds, SQS_DISTINCT_BATCH_ENTRY_IDS_MESSAGE), +}) + +const ChangeMessageVisibilityBatchResponseSchema = z.object({ + message: z.string(), + successful: z.array(z.object({ id: z.string().nullable() })), + failed: z.array(sqsBatchResultErrorEntrySchema), + successCount: z.number(), + failureCount: z.number(), +}) + +export const awsSqsChangeMessageVisibilityBatchContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/change-message-visibility-batch', + body: ChangeMessageVisibilityBatchSchema, + response: { mode: 'json', schema: ChangeMessageVisibilityBatchResponseSchema }, +}) +export type AwsSqsChangeMessageVisibilityBatchRequest = ContractBodyInput< + typeof awsSqsChangeMessageVisibilityBatchContract +> +export type AwsSqsChangeMessageVisibilityBatchBody = ContractBody< + typeof awsSqsChangeMessageVisibilityBatchContract +> +export type AwsSqsChangeMessageVisibilityBatchResponse = ContractJsonResponse< + typeof awsSqsChangeMessageVisibilityBatchContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-change-message-visibility.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-change-message-visibility.ts new file mode 100644 index 00000000000..c185a772808 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-change-message-visibility.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' +import { sqsConnectionFields, sqsQueueUrlField } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const ChangeMessageVisibilitySchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + receiptHandle: z.string().min(1, 'Receipt handle is required'), + visibilityTimeout: z + .number() + .int() + .min(0, 'visibilityTimeout must be at least 0') + .max(43200, 'visibilityTimeout cannot exceed 43200 seconds (12 hours)'), +}) + +const ChangeMessageVisibilityResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSqsChangeMessageVisibilityContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/change-message-visibility', + body: ChangeMessageVisibilitySchema, + response: { mode: 'json', schema: ChangeMessageVisibilityResponseSchema }, +}) +export type AwsSqsChangeMessageVisibilityRequest = ContractBodyInput< + typeof awsSqsChangeMessageVisibilityContract +> +export type AwsSqsChangeMessageVisibilityBody = ContractBody< + typeof awsSqsChangeMessageVisibilityContract +> +export type AwsSqsChangeMessageVisibilityResponse = ContractJsonResponse< + typeof awsSqsChangeMessageVisibilityContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-create-queue.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-create-queue.ts new file mode 100644 index 00000000000..30c7113ada6 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-create-queue.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { + sqsConnectionFields, + sqsCreateQueueAttributesSchema, + sqsQueueNameField, + sqsTagsSchema, +} from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const CreateQueueSchema = z.object({ + ...sqsConnectionFields, + queueName: sqsQueueNameField, + attributes: sqsCreateQueueAttributesSchema.nullish(), + tags: sqsTagsSchema.nullish(), +}) + +const CreateQueueResponseSchema = z.object({ + message: z.string(), + queueUrl: z.string().nullable(), +}) + +export const awsSqsCreateQueueContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/create-queue', + body: CreateQueueSchema, + response: { mode: 'json', schema: CreateQueueResponseSchema }, +}) +export type AwsSqsCreateQueueRequest = ContractBodyInput +export type AwsSqsCreateQueueBody = ContractBody +export type AwsSqsCreateQueueResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-delete-message-batch.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-delete-message-batch.ts new file mode 100644 index 00000000000..43cdbe25e78 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-delete-message-batch.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' +import { + hasDistinctBatchEntryIds, + SQS_DISTINCT_BATCH_ENTRY_IDS_MESSAGE, + SQS_MAX_BATCH_ENTRIES, + sqsBatchEntryIdSchema, + sqsBatchResultErrorEntrySchema, + sqsConnectionFields, + sqsQueueUrlField, +} from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const DeleteMessageBatchSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + entries: z + .array( + z.object({ + id: sqsBatchEntryIdSchema, + receiptHandle: z.string().min(1, 'Receipt handle is required'), + }) + ) + .min(1, 'At least one entry is required') + .max(SQS_MAX_BATCH_ENTRIES, `A batch can hold at most ${SQS_MAX_BATCH_ENTRIES} entries`) + .refine(hasDistinctBatchEntryIds, SQS_DISTINCT_BATCH_ENTRY_IDS_MESSAGE), +}) + +const DeleteMessageBatchResponseSchema = z.object({ + message: z.string(), + successful: z.array(z.object({ id: z.string().nullable() })), + failed: z.array(sqsBatchResultErrorEntrySchema), + successCount: z.number(), + failureCount: z.number(), +}) + +export const awsSqsDeleteMessageBatchContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/delete-message-batch', + body: DeleteMessageBatchSchema, + response: { mode: 'json', schema: DeleteMessageBatchResponseSchema }, +}) +export type AwsSqsDeleteMessageBatchRequest = ContractBodyInput< + typeof awsSqsDeleteMessageBatchContract +> +export type AwsSqsDeleteMessageBatchBody = ContractBody +export type AwsSqsDeleteMessageBatchResponse = ContractJsonResponse< + typeof awsSqsDeleteMessageBatchContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-delete-message.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-delete-message.ts new file mode 100644 index 00000000000..8cee8746fb3 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-delete-message.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' +import { sqsConnectionFields, sqsQueueUrlField } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const DeleteMessageSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + receiptHandle: z.string().min(1, 'Receipt handle is required'), +}) + +const DeleteMessageResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSqsDeleteMessageContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/delete-message', + body: DeleteMessageSchema, + response: { mode: 'json', schema: DeleteMessageResponseSchema }, +}) +export type AwsSqsDeleteMessageRequest = ContractBodyInput +export type AwsSqsDeleteMessageBody = ContractBody +export type AwsSqsDeleteMessageResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-delete-queue.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-delete-queue.ts new file mode 100644 index 00000000000..1522fa12d17 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-delete-queue.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import { sqsConnectionFields, sqsQueueUrlField } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const DeleteQueueSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, +}) + +const DeleteQueueResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSqsDeleteQueueContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/delete-queue', + body: DeleteQueueSchema, + response: { mode: 'json', schema: DeleteQueueResponseSchema }, +}) +export type AwsSqsDeleteQueueRequest = ContractBodyInput +export type AwsSqsDeleteQueueBody = ContractBody +export type AwsSqsDeleteQueueResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-get-queue-attributes.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-get-queue-attributes.ts new file mode 100644 index 00000000000..2963f648644 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-get-queue-attributes.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { + sqsConnectionFields, + sqsQueueAttributeNameSchema, + sqsQueueUrlField, +} from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const GetQueueAttributesSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + attributeNames: z.array(sqsQueueAttributeNameSchema).nullish(), +}) + +const GetQueueAttributesResponseSchema = z.object({ + attributes: z.record(z.string(), z.string()), +}) + +export const awsSqsGetQueueAttributesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/get-queue-attributes', + body: GetQueueAttributesSchema, + response: { mode: 'json', schema: GetQueueAttributesResponseSchema }, +}) +export type AwsSqsGetQueueAttributesRequest = ContractBodyInput< + typeof awsSqsGetQueueAttributesContract +> +export type AwsSqsGetQueueAttributesBody = ContractBody +export type AwsSqsGetQueueAttributesResponse = ContractJsonResponse< + typeof awsSqsGetQueueAttributesContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-get-queue-url.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-get-queue-url.ts new file mode 100644 index 00000000000..df326d44dff --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-get-queue-url.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' +import { + sqsAwsAccountIdSchema, + sqsConnectionFields, + sqsQueueNameField, +} from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const GetQueueUrlSchema = z.object({ + ...sqsConnectionFields, + queueName: sqsQueueNameField, + queueOwnerAwsAccountId: sqsAwsAccountIdSchema.nullish(), +}) + +const GetQueueUrlResponseSchema = z.object({ + queueUrl: z.string().nullable(), +}) + +export const awsSqsGetQueueUrlContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/get-queue-url', + body: GetQueueUrlSchema, + response: { mode: 'json', schema: GetQueueUrlResponseSchema }, +}) +export type AwsSqsGetQueueUrlRequest = ContractBodyInput +export type AwsSqsGetQueueUrlBody = ContractBody +export type AwsSqsGetQueueUrlResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-list-dead-letter-source-queues.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-list-dead-letter-source-queues.ts new file mode 100644 index 00000000000..aab8f7d6847 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-list-dead-letter-source-queues.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import { sqsConnectionFields, sqsQueueUrlField } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const ListDeadLetterSourceQueuesSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + maxResults: z + .number() + .int() + .min(1, 'maxResults must be at least 1') + .max(1000, 'maxResults cannot exceed 1000') + .nullish(), + nextToken: z.string().nullish(), +}) + +const ListDeadLetterSourceQueuesResponseSchema = z.object({ + queueUrls: z.array(z.string()), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSqsListDeadLetterSourceQueuesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/list-dead-letter-source-queues', + body: ListDeadLetterSourceQueuesSchema, + response: { mode: 'json', schema: ListDeadLetterSourceQueuesResponseSchema }, +}) +export type AwsSqsListDeadLetterSourceQueuesRequest = ContractBodyInput< + typeof awsSqsListDeadLetterSourceQueuesContract +> +export type AwsSqsListDeadLetterSourceQueuesBody = ContractBody< + typeof awsSqsListDeadLetterSourceQueuesContract +> +export type AwsSqsListDeadLetterSourceQueuesResponse = ContractJsonResponse< + typeof awsSqsListDeadLetterSourceQueuesContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-list-message-move-tasks.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-list-message-move-tasks.ts new file mode 100644 index 00000000000..1128f82d487 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-list-message-move-tasks.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' +import { sqsConnectionFields, sqsQueueArnSchema } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const ListMessageMoveTasksSchema = z.object({ + ...sqsConnectionFields, + sourceArn: sqsQueueArnSchema, + maxResults: z + .number() + .int() + .min(1, 'maxResults must be at least 1') + .max(10, 'maxResults cannot exceed 10') + .nullish(), +}) + +const ListMessageMoveTasksResponseSchema = z.object({ + results: z.array( + z.object({ + taskHandle: z.string().nullable(), + status: z.string().nullable(), + sourceArn: z.string().nullable(), + destinationArn: z.string().nullable(), + maxNumberOfMessagesPerSecond: z.number().nullable(), + approximateNumberOfMessagesMoved: z.number().nullable(), + approximateNumberOfMessagesToMove: z.number().nullable(), + failureReason: z.string().nullable(), + startedTimestamp: z.number().nullable(), + }) + ), + count: z.number(), +}) + +export const awsSqsListMessageMoveTasksContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/list-message-move-tasks', + body: ListMessageMoveTasksSchema, + response: { mode: 'json', schema: ListMessageMoveTasksResponseSchema }, +}) +export type AwsSqsListMessageMoveTasksRequest = ContractBodyInput< + typeof awsSqsListMessageMoveTasksContract +> +export type AwsSqsListMessageMoveTasksBody = ContractBody +export type AwsSqsListMessageMoveTasksResponse = ContractJsonResponse< + typeof awsSqsListMessageMoveTasksContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-list-queue-tags.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-list-queue-tags.ts new file mode 100644 index 00000000000..e445a8b90bf --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-list-queue-tags.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import { sqsConnectionFields, sqsQueueUrlField } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const ListQueueTagsSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, +}) + +const ListQueueTagsResponseSchema = z.object({ + tags: z.record(z.string(), z.string()), +}) + +export const awsSqsListQueueTagsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/list-queue-tags', + body: ListQueueTagsSchema, + response: { mode: 'json', schema: ListQueueTagsResponseSchema }, +}) +export type AwsSqsListQueueTagsRequest = ContractBodyInput +export type AwsSqsListQueueTagsBody = ContractBody +export type AwsSqsListQueueTagsResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-list-queues.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-list-queues.ts new file mode 100644 index 00000000000..fa571ccfeaa --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-list-queues.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { sqsConnectionFields } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const ListQueuesSchema = z.object({ + ...sqsConnectionFields, + queueNamePrefix: z.string().nullish(), + maxResults: z + .number() + .int() + .min(1, 'maxResults must be at least 1') + .max(1000, 'maxResults cannot exceed 1000') + .nullish(), + nextToken: z.string().nullish(), +}) + +const ListQueuesResponseSchema = z.object({ + queueUrls: z.array(z.string()), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSqsListQueuesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/list-queues', + body: ListQueuesSchema, + response: { mode: 'json', schema: ListQueuesResponseSchema }, +}) +export type AwsSqsListQueuesRequest = ContractBodyInput +export type AwsSqsListQueuesBody = ContractBody +export type AwsSqsListQueuesResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-purge-queue.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-purge-queue.ts new file mode 100644 index 00000000000..d77c61e14b1 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-purge-queue.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import { sqsConnectionFields, sqsQueueUrlField } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const PurgeQueueSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, +}) + +const PurgeQueueResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSqsPurgeQueueContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/purge-queue', + body: PurgeQueueSchema, + response: { mode: 'json', schema: PurgeQueueResponseSchema }, +}) +export type AwsSqsPurgeQueueRequest = ContractBodyInput +export type AwsSqsPurgeQueueBody = ContractBody +export type AwsSqsPurgeQueueResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-receive-message.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-receive-message.ts new file mode 100644 index 00000000000..26f9de68847 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-receive-message.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import { + sqsConnectionFields, + sqsMessageAttributesOutputSchema, + sqsMessageSystemAttributeNameSchema, + sqsQueueUrlField, +} from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const ReceiveMessageSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + maxNumberOfMessages: z + .number() + .int() + .min(1, 'maxNumberOfMessages must be at least 1') + .max(10, 'maxNumberOfMessages cannot exceed 10') + .nullish(), + visibilityTimeout: z + .number() + .int() + .min(0, 'visibilityTimeout must be at least 0') + .max(43200, 'visibilityTimeout cannot exceed 43200 seconds (12 hours)') + .nullish(), + waitTimeSeconds: z + .number() + .int() + .min(0, 'waitTimeSeconds must be at least 0') + .max(20, 'waitTimeSeconds cannot exceed 20') + .nullish(), + messageAttributeNames: z + .array(z.string().min(1, 'Message attribute name cannot be empty')) + .nullish(), + messageSystemAttributeNames: z.array(sqsMessageSystemAttributeNameSchema).nullish(), + receiveRequestAttemptId: z + .string() + .max(128, 'receiveRequestAttemptId must be at most 128 characters') + .nullish(), +}) + +const ReceiveMessageResponseSchema = z.object({ + messages: z.array( + z.object({ + messageId: z.string().nullable(), + receiptHandle: z.string().nullable(), + body: z.string().nullable(), + md5OfBody: z.string().nullable(), + md5OfMessageAttributes: z.string().nullable(), + attributes: z.record(z.string(), z.string()), + messageAttributes: sqsMessageAttributesOutputSchema, + }) + ), + count: z.number(), +}) + +export const awsSqsReceiveMessageContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/receive-message', + body: ReceiveMessageSchema, + response: { mode: 'json', schema: ReceiveMessageResponseSchema }, +}) +export type AwsSqsReceiveMessageRequest = ContractBodyInput +export type AwsSqsReceiveMessageBody = ContractBody +export type AwsSqsReceiveMessageResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-send-message-batch.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-send-message-batch.ts new file mode 100644 index 00000000000..2b65d99b7bc --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-send-message-batch.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { + hasDistinctBatchEntryIds, + SQS_DISTINCT_BATCH_ENTRY_IDS_MESSAGE, + SQS_MAX_BATCH_ENTRIES, + sqsBatchEntryIdSchema, + sqsBatchResultErrorEntrySchema, + sqsConnectionFields, + sqsMessageAttributesInputSchema, + sqsMessageDeduplicationIdField, + sqsMessageGroupIdField, + sqsQueueUrlField, +} from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const SendMessageBatchEntrySchema = z.object({ + id: sqsBatchEntryIdSchema, + data: z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, { + message: 'Each entry data object must have at least one field', + }), + delaySeconds: z + .number() + .int() + .min(0, 'delaySeconds must be at least 0') + .max(900, 'delaySeconds cannot exceed 900') + .nullish(), + messageAttributes: sqsMessageAttributesInputSchema.nullish(), + messageGroupId: sqsMessageGroupIdField.nullish(), + messageDeduplicationId: sqsMessageDeduplicationIdField.nullish(), +}) + +const SendMessageBatchSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + entries: z + .array(SendMessageBatchEntrySchema) + .min(1, 'At least one entry is required') + .max(SQS_MAX_BATCH_ENTRIES, `A batch can hold at most ${SQS_MAX_BATCH_ENTRIES} entries`) + .refine(hasDistinctBatchEntryIds, SQS_DISTINCT_BATCH_ENTRY_IDS_MESSAGE), +}) + +const SendMessageBatchResponseSchema = z.object({ + message: z.string(), + successful: z.array( + z.object({ + id: z.string().nullable(), + messageId: z.string().nullable(), + md5OfMessageBody: z.string().nullable(), + md5OfMessageAttributes: z.string().nullable(), + sequenceNumber: z.string().nullable(), + }) + ), + failed: z.array(sqsBatchResultErrorEntrySchema), + successCount: z.number(), + failureCount: z.number(), +}) + +export const awsSqsSendMessageBatchContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/send-message-batch', + body: SendMessageBatchSchema, + response: { mode: 'json', schema: SendMessageBatchResponseSchema }, +}) +export type AwsSqsSendMessageBatchRequest = ContractBodyInput +export type AwsSqsSendMessageBatchBody = ContractBody +export type AwsSqsSendMessageBatchResponse = ContractJsonResponse< + typeof awsSqsSendMessageBatchContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-send-message.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-send-message.ts new file mode 100644 index 00000000000..9be531fafa2 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-send-message.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' +import { + sqsConnectionFields, + sqsMessageAttributesInputSchema, + sqsMessageDeduplicationIdField, + sqsMessageGroupIdField, + sqsQueueUrlField, +} from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const SendMessageSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + data: z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, { + message: 'Data object must have at least one field', + }), + delaySeconds: z + .number() + .int() + .min(0, 'delaySeconds must be at least 0') + .max(900, 'delaySeconds cannot exceed 900') + .nullish(), + messageAttributes: sqsMessageAttributesInputSchema.nullish(), + messageGroupId: sqsMessageGroupIdField.nullish(), + messageDeduplicationId: sqsMessageDeduplicationIdField.nullish(), +}) + +const SendMessageResponseSchema = z.object({ + message: z.string(), + id: z.string().nullable(), + md5OfMessageBody: z.string().nullable(), + md5OfMessageAttributes: z.string().nullable(), + sequenceNumber: z.string().nullable(), +}) + +export const awsSqsSendMessageContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/send-message', + body: SendMessageSchema, + response: { mode: 'json', schema: SendMessageResponseSchema }, +}) +export type AwsSqsSendMessageRequest = ContractBodyInput +export type AwsSqsSendMessageBody = ContractBody +export type AwsSqsSendMessageResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-set-queue-attributes.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-set-queue-attributes.ts new file mode 100644 index 00000000000..853ce567606 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-set-queue-attributes.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' +import { + sqsConnectionFields, + sqsQueueUrlField, + sqsSetQueueAttributesSchema, +} from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const SetQueueAttributesSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + attributes: sqsSetQueueAttributesSchema.refine( + (value) => Object.keys(value).length > 0, + 'At least one queue attribute is required' + ), +}) + +const SetQueueAttributesResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSqsSetQueueAttributesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/set-queue-attributes', + body: SetQueueAttributesSchema, + response: { mode: 'json', schema: SetQueueAttributesResponseSchema }, +}) +export type AwsSqsSetQueueAttributesRequest = ContractBodyInput< + typeof awsSqsSetQueueAttributesContract +> +export type AwsSqsSetQueueAttributesBody = ContractBody +export type AwsSqsSetQueueAttributesResponse = ContractJsonResponse< + typeof awsSqsSetQueueAttributesContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-shared.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-shared.ts new file mode 100644 index 00000000000..8f911796127 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-shared.ts @@ -0,0 +1,253 @@ +import { z } from 'zod' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +/** + * Connection fields every Amazon SQS tool contract requires. Spread into each + * operation's body schema so the credential shape stays identical across all of them. + */ +export const sqsConnectionFields = { + region: z + .string() + .min(1, 'AWS region is required') + .refine((value) => validateAwsRegion(value).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), +} + +/** `QueueUrl`, required by every action that targets an existing queue. */ +export const sqsQueueUrlField = z.string().min(1, 'Queue URL is required') + +/** + * `QueueName`, required by CreateQueue and GetQueueUrl. Documented as up to 80 + * characters of alphanumerics, hyphens, and underscores; a FIFO queue name ends + * with the `.fifo` suffix. + */ +export const sqsQueueNameField = z + .string() + .min(1, 'Queue name is required') + .max(80, 'Queue name must be at most 80 characters') + .regex( + /^[A-Za-z0-9_-]+(\.fifo)?$/, + 'Queue name may only contain letters, digits, hyphens, and underscores, optionally ending in .fifo' + ) + +/** + * A queue ARN, in the documented `arn::sqs:::` + * form used by the message move task actions. + */ +export const sqsQueueArnSchema = z + .string() + .regex( + /^arn:[a-z0-9-]+:sqs:[a-z0-9-]+:\d{12}:[A-Za-z0-9_-]+(\.fifo)?$/, + 'Must be a queue ARN (e.g., arn:aws:sqs:us-east-1:123456789012:my-queue)' + ) + +/** A 12-digit AWS account ID. */ +export const sqsAwsAccountIdSchema = z + .string() + .regex(/^\d{12}$/, 'AWS account ID must be 12 digits') + +/** + * `Id` of a batch request entry. Documented as up to 80 characters of + * alphanumerics, hyphens, and underscores, unique within the request. + */ +export const sqsBatchEntryIdSchema = z + .string() + .min(1, 'Batch entry id is required') + .max(80, 'Batch entry id must be at most 80 characters') + .regex( + /^[A-Za-z0-9_-]+$/, + 'Batch entry id may only contain letters, digits, hyphens, underscores' + ) + +/** + * The documented batch size for SendMessageBatch, DeleteMessageBatch, and + * ChangeMessageVisibilityBatch. AWS states the limit in the + * `TooManyEntriesInBatchRequest` error rather than on the `Entries` parameter. + */ +export const SQS_MAX_BATCH_ENTRIES = 10 + +/** + * AWS rejects a batch whose entries reuse an `Id` with `BatchEntryIdsNotDistinct` + * ("Two or more batch entries in the request have the same `Id`"), failing the + * whole request rather than the offending entry. Every batch contract refines its + * `entries` array with this so the caller gets a local field error instead of + * losing the batch at the provider. + */ +export function hasDistinctBatchEntryIds(entries: readonly { id: string }[]) { + return new Set(entries.map((entry) => entry.id)).size === entries.length +} + +/** Message reported when {@link hasDistinctBatchEntryIds} rejects a batch. */ +export const SQS_DISTINCT_BATCH_ENTRY_IDS_MESSAGE = + 'Batch entry ids must be unique within a request' + +/** + * `QueueAttributeName` values AWS documents as settable, taken from the "special + * request parameters that the action uses" list shared by CreateQueue and + * SetQueueAttributes. + * + * The read-only names carried by the shared `Valid Keys` enum + * (`ApproximateNumberOfMessages`, `ApproximateNumberOfMessagesDelayed`, + * `ApproximateNumberOfMessagesNotVisible`, `CreatedTimestamp`, + * `LastModifiedTimestamp`, `QueueArn`) and the `All` pseudo-name are deliberately + * absent: neither action documents them as settable, and AWS answers a write with + * `InvalidAttributeName`. + */ +const sqsSettableQueueAttributeNames = [ + 'ContentBasedDeduplication', + 'DeduplicationScope', + 'DelaySeconds', + 'FifoThroughputLimit', + 'KmsDataKeyReusePeriodSeconds', + 'KmsMasterKeyId', + 'MaximumMessageSize', + 'MessageRetentionPeriod', + 'Policy', + 'ReceiveMessageWaitTimeSeconds', + 'RedriveAllowPolicy', + 'RedrivePolicy', + 'SqsManagedSseEnabled', + 'VisibilityTimeout', +] as const + +/** + * Attribute names CreateQueue accepts. `FifoQueue` is create-only, because AWS + * documents that "You can provide this attribute only during queue creation. You + * can't change it for an existing queue." + */ +export const sqsCreateQueueAttributeNameSchema = z.enum([ + ...sqsSettableQueueAttributeNames, + 'FifoQueue', +]) + +/** Attribute names SetQueueAttributes accepts, which excludes create-only `FifoQueue`. */ +export const sqsSetQueueAttributeNameSchema = z.enum(sqsSettableQueueAttributeNames) + +/** Documented `QueueAttributeName` values, including the read-only `All` pseudo-name. */ +export const sqsQueueAttributeNameSchema = z.enum([ + 'All', + 'ApproximateNumberOfMessages', + 'ApproximateNumberOfMessagesDelayed', + 'ApproximateNumberOfMessagesNotVisible', + 'ContentBasedDeduplication', + 'CreatedTimestamp', + 'DeduplicationScope', + 'DelaySeconds', + 'FifoQueue', + 'FifoThroughputLimit', + 'KmsDataKeyReusePeriodSeconds', + 'KmsMasterKeyId', + 'LastModifiedTimestamp', + 'MaximumMessageSize', + 'MessageRetentionPeriod', + 'Policy', + 'QueueArn', + 'ReceiveMessageWaitTimeSeconds', + 'RedriveAllowPolicy', + 'RedrivePolicy', + 'SqsManagedSseEnabled', + 'VisibilityTimeout', +]) + +/** Documented `MessageSystemAttributeName` values accepted by ReceiveMessage. */ +export const sqsMessageSystemAttributeNameSchema = z.enum([ + 'All', + 'ApproximateFirstReceiveTimestamp', + 'ApproximateReceiveCount', + 'AWSTraceHeader', + 'DeadLetterQueueSourceArn', + 'MessageDeduplicationId', + 'MessageGroupId', + 'SenderId', + 'SentTimestamp', + 'SequenceNumber', +]) + +/** Attribute map accepted by CreateQueue, which alone may set `FifoQueue`. */ +export const sqsCreateQueueAttributesSchema = z.partialRecord( + sqsCreateQueueAttributeNameSchema, + z.string({ error: 'Queue attribute values must be strings' }) +) + +/** Attribute map accepted by SetQueueAttributes. */ +export const sqsSetQueueAttributesSchema = z.partialRecord( + sqsSetQueueAttributeNameSchema, + z.string({ error: 'Queue attribute values must be strings' }) +) + +/** + * `MessageGroupId` and `MessageDeduplicationId` are FIFO tokens documented as up + * to 128 characters of alphanumerics and punctuation. Both operations forward the + * value verbatim, so an empty string reaches SQS as a malformed token; the block + * already drops a blank field before mapping, so only an explicitly empty string + * is refused here. + */ +const sqsFifoTokenField = (fieldName: string) => + z + .string() + .min(1, `${fieldName} cannot be empty`) + .max(128, `${fieldName} must be at most 128 characters`) + +/** `MessageGroupId`, shared by SendMessage and each SendMessageBatch entry. */ +export const sqsMessageGroupIdField = sqsFifoTokenField('messageGroupId') + +/** `MessageDeduplicationId`, shared by SendMessage and each SendMessageBatch entry. */ +export const sqsMessageDeduplicationIdField = sqsFifoTokenField('messageDeduplicationId') + +/** + * The documented cap on user-supplied message attributes: "Each message can have + * up to 10 attributes." + */ +export const SQS_MAX_MESSAGE_ATTRIBUTES = 10 + +/** + * User-supplied message attributes. Only the string-valued data types are + * accepted: a `Binary` attribute needs a `BinaryValue` byte array, which cannot + * cross the JSON tool boundary. AWS allows a custom label suffix on the logical + * type, e.g. `Number.float`. + */ +export const sqsMessageAttributesInputSchema = z + .record( + z.string().min(1, 'Message attribute name is required'), + z.object({ + dataType: z + .string() + .min(1, 'Message attribute dataType is required') + .regex( + /^(String|Number)(\.[\w.-]+)?$/, + 'Message attribute dataType must be String or Number, optionally with a custom label such as Number.float. Binary attributes are not supported.' + ), + stringValue: z.string().min(1, 'Message attribute stringValue is required'), + }) + ) + .refine( + (value) => Object.keys(value).length <= SQS_MAX_MESSAGE_ATTRIBUTES, + `A message can have at most ${SQS_MAX_MESSAGE_ATTRIBUTES} message attributes` + ) + +/** Message attributes as projected from a received message. */ +export const sqsMessageAttributesOutputSchema = z.record( + z.string(), + z.object({ + dataType: z.string().nullable(), + stringValue: z.string().nullable(), + stringListValues: z.array(z.string()), + }) +) + +/** `BatchResultErrorEntry`, identical across all three SQS batch actions. */ +export const sqsBatchResultErrorEntrySchema = z.object({ + id: z.string().nullable(), + senderFault: z.boolean().nullable(), + code: z.string().nullable(), + message: z.string().nullable(), +}) + +/** Tag keys and values applied to a queue. */ +export const sqsTagsSchema = z.record( + z.string().min(1, 'Tag key is required').max(128, 'Tag key must be at most 128 characters'), + z.string().max(256, 'Tag value must be at most 256 characters') +) diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-start-message-move-task.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-start-message-move-task.ts new file mode 100644 index 00000000000..fcbe23d2beb --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-start-message-move-task.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' +import { sqsConnectionFields, sqsQueueArnSchema } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const StartMessageMoveTaskSchema = z.object({ + ...sqsConnectionFields, + sourceArn: sqsQueueArnSchema, + destinationArn: sqsQueueArnSchema.nullish(), + maxNumberOfMessagesPerSecond: z + .number() + .int() + .min(1, 'maxNumberOfMessagesPerSecond must be at least 1') + .max(500, 'maxNumberOfMessagesPerSecond cannot exceed 500') + .nullish(), +}) + +const StartMessageMoveTaskResponseSchema = z.object({ + message: z.string(), + taskHandle: z.string().nullable(), +}) + +export const awsSqsStartMessageMoveTaskContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/start-message-move-task', + body: StartMessageMoveTaskSchema, + response: { mode: 'json', schema: StartMessageMoveTaskResponseSchema }, +}) +export type AwsSqsStartMessageMoveTaskRequest = ContractBodyInput< + typeof awsSqsStartMessageMoveTaskContract +> +export type AwsSqsStartMessageMoveTaskBody = ContractBody +export type AwsSqsStartMessageMoveTaskResponse = ContractJsonResponse< + typeof awsSqsStartMessageMoveTaskContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-tag-queue.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-tag-queue.ts new file mode 100644 index 00000000000..e4a7f241c15 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-tag-queue.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { + sqsConnectionFields, + sqsQueueUrlField, + sqsTagsSchema, +} from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const TagQueueSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + tags: sqsTagsSchema.refine( + (value) => Object.keys(value).length > 0, + 'At least one tag is required' + ), +}) + +const TagQueueResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSqsTagQueueContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/tag-queue', + body: TagQueueSchema, + response: { mode: 'json', schema: TagQueueResponseSchema }, +}) +export type AwsSqsTagQueueRequest = ContractBodyInput +export type AwsSqsTagQueueBody = ContractBody +export type AwsSqsTagQueueResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sqs-untag-queue.ts b/apps/sim/lib/api/contracts/tools/aws/sqs-untag-queue.ts new file mode 100644 index 00000000000..53ef7bf304d --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sqs-untag-queue.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import { sqsConnectionFields, sqsQueueUrlField } from '@/lib/api/contracts/tools/aws/sqs-shared' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const UntagQueueSchema = z.object({ + ...sqsConnectionFields, + queueUrl: sqsQueueUrlField, + tagKeys: z + .array(z.string().min(1, 'Tag key cannot be empty')) + .min(1, 'At least one tag key is required'), +}) + +const UntagQueueResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSqsUntagQueueContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sqs/untag-queue', + body: UntagQueueSchema, + response: { mode: 'json', schema: UntagQueueResponseSchema }, +}) +export type AwsSqsUntagQueueRequest = ContractBodyInput +export type AwsSqsUntagQueueBody = ContractBody +export type AwsSqsUntagQueueResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-cancel-command.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-cancel-command.ts new file mode 100644 index 00000000000..168329a7f82 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-cancel-command.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const INSTANCE_ID_PATTERN = /^(i-(\w{8}|\w{17})|mi-\w{17})$/ + +const COMMAND_ID_PATTERN = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + commandId: z.string().regex(COMMAND_ID_PATTERN, 'commandId must be a 36-character command ID'), + instanceIds: z + .array( + z.string().regex(INSTANCE_ID_PATTERN, 'instanceIds entries must look like i-0abc… or mi-…') + ) + .max(50) + .nullish(), +}) + +const ResponseSchema = z.object({ + message: z.string(), + commandId: z.string(), +}) + +export const awsSsmCancelCommandContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/cancel-command', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmCancelCommandRequest = ContractBodyInput +export type AwsSsmCancelCommandBody = ContractBody +export type AwsSsmCancelCommandResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-delete-parameter.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-delete-parameter.ts new file mode 100644 index 00000000000..5c926edeb00 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-delete-parameter.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + name: z.string().min(1, 'Parameter name is required').max(2048), +}) + +const ResponseSchema = z.object({ + message: z.string(), + name: z.string(), +}) + +export const awsSsmDeleteParameterContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/delete-parameter', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmDeleteParameterRequest = ContractBodyInput +export type AwsSsmDeleteParameterBody = ContractBody +export type AwsSsmDeleteParameterResponse = ContractJsonResponse< + typeof awsSsmDeleteParameterContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-describe-automation-executions.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-describe-automation-executions.ts new file mode 100644 index 00000000000..cfde1946555 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-describe-automation-executions.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const AutomationExecutionFilterSchema = z.object({ + Key: z.enum([ + 'DocumentNamePrefix', + 'ExecutionStatus', + 'ExecutionId', + 'ParentExecutionId', + 'CurrentAction', + 'StartTimeBefore', + 'StartTimeAfter', + 'AutomationType', + 'TagKey', + 'TargetResourceGroup', + 'AutomationSubtype', + 'OpsItemId', + ]), + Values: z.array(z.string().min(1).max(150)).min(1).max(10), +}) + +const AutomationExecutionMetadataSchema = z.object({ + automationExecutionId: z.string(), + documentName: z.string(), + documentVersion: z.string().nullable(), + automationExecutionStatus: z.string(), + executionStartTime: z.string().nullable(), + executionEndTime: z.string().nullable(), + executedBy: z.string().nullable(), + logFile: z.string().nullable(), + mode: z.string().nullable(), + parentAutomationExecutionId: z.string().nullable(), + currentStepName: z.string().nullable(), + currentAction: z.string().nullable(), + failureMessage: z.string().nullable(), + targetParameterName: z.string().nullable(), + target: z.string().nullable(), + automationType: z.string().nullable(), + maxConcurrency: z.string().nullable(), + maxErrors: z.string().nullable(), + outputs: z.record(z.string(), z.array(z.string())).nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + filters: z.array(AutomationExecutionFilterSchema).max(10).nullish(), + maxResults: z.number().int().min(1).max(50).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + automationExecutions: z.array(AutomationExecutionMetadataSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmDescribeAutomationExecutionsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/describe-automation-executions', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmDescribeAutomationExecutionsRequest = ContractBodyInput< + typeof awsSsmDescribeAutomationExecutionsContract +> +export type AwsSsmDescribeAutomationExecutionsBody = ContractBody< + typeof awsSsmDescribeAutomationExecutionsContract +> +export type AwsSsmDescribeAutomationExecutionsResponse = ContractJsonResponse< + typeof awsSsmDescribeAutomationExecutionsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-describe-instance-information.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-describe-instance-information.ts new file mode 100644 index 00000000000..af759b2ad12 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-describe-instance-information.ts @@ -0,0 +1,72 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const InstanceInformationStringFilterSchema = z.object({ + Key: z.string().min(1, 'Filter Key is required'), + Values: z.array(z.string().min(1)).min(1).max(100), +}) + +const InstanceInformationSchema = z.object({ + instanceId: z.string(), + pingStatus: z.string(), + lastPingDateTime: z.string().nullable(), + agentVersion: z.string().nullable(), + isLatestVersion: z.boolean().nullable(), + platformType: z.string().nullable(), + platformName: z.string().nullable(), + platformVersion: z.string().nullable(), + activationId: z.string().nullable(), + iamRole: z.string().nullable(), + registrationDate: z.string().nullable(), + resourceType: z.string().nullable(), + name: z.string().nullable(), + ipAddress: z.string().nullable(), + computerName: z.string().nullable(), + associationStatus: z.string().nullable(), + lastAssociationExecutionDate: z.string().nullable(), + lastSuccessfulAssociationExecutionDate: z.string().nullable(), + sourceId: z.string().nullable(), + sourceType: z.string().nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + filters: z.array(InstanceInformationStringFilterSchema).nullish(), + maxResults: z.number().int().min(5).max(50).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + instances: z.array(InstanceInformationSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmDescribeInstanceInformationContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/describe-instance-information', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmDescribeInstanceInformationRequest = ContractBodyInput< + typeof awsSsmDescribeInstanceInformationContract +> +export type AwsSsmDescribeInstanceInformationBody = ContractBody< + typeof awsSsmDescribeInstanceInformationContract +> +export type AwsSsmDescribeInstanceInformationResponse = ContractJsonResponse< + typeof awsSsmDescribeInstanceInformationContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-describe-instance-patch-states.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-describe-instance-patch-states.ts new file mode 100644 index 00000000000..08af5d4da47 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-describe-instance-patch-states.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const INSTANCE_ID_PATTERN = /^(i-(\w{8}|\w{17})|mi-\w{17})$/ + +const InstancePatchStateSchema = z.object({ + instanceId: z.string(), + patchGroup: z.string(), + baselineId: z.string(), + snapshotId: z.string().nullable(), + ownerInformation: z.string().nullable(), + installedCount: z.number().nullable(), + installedOtherCount: z.number().nullable(), + installedPendingRebootCount: z.number().nullable(), + installedRejectedCount: z.number().nullable(), + missingCount: z.number().nullable(), + failedCount: z.number().nullable(), + unreportedNotApplicableCount: z.number().nullable(), + notApplicableCount: z.number().nullable(), + criticalNonCompliantCount: z.number().nullable(), + securityNonCompliantCount: z.number().nullable(), + otherNonCompliantCount: z.number().nullable(), + operation: z.string(), + operationStartTime: z.string().nullable(), + operationEndTime: z.string().nullable(), + lastNoRebootInstallOperationTime: z.string().nullable(), + rebootOption: z.string().nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + instanceIds: z + .array( + z.string().regex(INSTANCE_ID_PATTERN, 'instanceIds entries must look like i-0abc… or mi-…') + ) + .min(1, 'At least one instance ID is required') + .max(50, 'DescribeInstancePatchStates accepts at most 50 instance IDs'), + maxResults: z.number().int().min(10).max(100).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + instancePatchStates: z.array(InstancePatchStateSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmDescribeInstancePatchStatesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/describe-instance-patch-states', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmDescribeInstancePatchStatesRequest = ContractBodyInput< + typeof awsSsmDescribeInstancePatchStatesContract +> +export type AwsSsmDescribeInstancePatchStatesBody = ContractBody< + typeof awsSsmDescribeInstancePatchStatesContract +> +export type AwsSsmDescribeInstancePatchStatesResponse = ContractJsonResponse< + typeof awsSsmDescribeInstancePatchStatesContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-describe-instance-patches.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-describe-instance-patches.ts new file mode 100644 index 00000000000..8f33b4390a0 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-describe-instance-patches.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const INSTANCE_ID_PATTERN = /^(i-(\w{8}|\w{17})|mi-\w{17})$/ + +const PatchOrchestratorFilterSchema = z.object({ + Key: z.string().min(1, 'Filter Key must not be empty').max(128).optional(), + Values: z.array(z.string().min(1).max(256)).min(1).optional(), +}) + +const PatchComplianceDataSchema = z.object({ + title: z.string(), + kbId: z.string(), + classification: z.string(), + severity: z.string(), + state: z.string(), + installedTime: z.string().nullable(), + cveIds: z.string().nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + instanceId: z.string().regex(INSTANCE_ID_PATTERN, 'instanceId must look like i-0abc… or mi-…'), + filters: z.array(PatchOrchestratorFilterSchema).max(5).nullish(), + maxResults: z.number().int().min(10).max(100).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + patches: z.array(PatchComplianceDataSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmDescribeInstancePatchesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/describe-instance-patches', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmDescribeInstancePatchesRequest = ContractBodyInput< + typeof awsSsmDescribeInstancePatchesContract +> +export type AwsSsmDescribeInstancePatchesBody = ContractBody< + typeof awsSsmDescribeInstancePatchesContract +> +export type AwsSsmDescribeInstancePatchesResponse = ContractJsonResponse< + typeof awsSsmDescribeInstancePatchesContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-describe-parameters.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-describe-parameters.ts new file mode 100644 index 00000000000..85b3f319a8c --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-describe-parameters.ts @@ -0,0 +1,70 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ParameterStringFilterSchema = z.object({ + Key: z.string().min(1, 'Filter Key is required'), + Option: z.string().min(1).max(10).optional(), + Values: z.array(z.string().min(1)).min(1).max(50).optional(), +}) + +const ParameterMetadataSchema = z.object({ + name: z.string(), + arn: z.string(), + type: z.string(), + keyId: z.string().nullable(), + lastModifiedDate: z.string().nullable(), + lastModifiedUser: z.string().nullable(), + description: z.string().nullable(), + allowedPattern: z.string().nullable(), + version: z.number().nullable(), + tier: z.string().nullable(), + dataType: z.string().nullable(), + policies: z.array( + z.object({ + policyText: z.string().nullable(), + policyType: z.string().nullable(), + policyStatus: z.string().nullable(), + }) + ), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + parameterFilters: z.array(ParameterStringFilterSchema).nullish(), + shared: z.boolean().nullish(), + maxResults: z.number().int().min(1).max(50).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + parameters: z.array(ParameterMetadataSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmDescribeParametersContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/describe-parameters', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmDescribeParametersRequest = ContractBodyInput< + typeof awsSsmDescribeParametersContract +> +export type AwsSsmDescribeParametersBody = ContractBody +export type AwsSsmDescribeParametersResponse = ContractJsonResponse< + typeof awsSsmDescribeParametersContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-get-automation-execution.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-get-automation-execution.ts new file mode 100644 index 00000000000..fd45d43edb1 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-get-automation-execution.ts @@ -0,0 +1,80 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const EXECUTION_ID_PATTERN = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ + +const StepExecutionSchema = z.object({ + stepName: z.string().nullable(), + action: z.string().nullable(), + stepStatus: z.string().nullable(), + stepExecutionId: z.string().nullable(), + executionStartTime: z.string().nullable(), + executionEndTime: z.string().nullable(), + failureMessage: z.string().nullable(), + response: z.string().nullable(), + isEnd: z.boolean().nullable(), + nextStep: z.string().nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + automationExecutionId: z + .string() + .regex( + EXECUTION_ID_PATTERN, + 'automationExecutionId must be a 36-character automation execution ID' + ), +}) + +const ResponseSchema = z.object({ + automationExecutionId: z.string(), + documentName: z.string(), + documentVersion: z.string().nullable(), + automationExecutionStatus: z.string(), + executionStartTime: z.string().nullable(), + executionEndTime: z.string().nullable(), + executedBy: z.string().nullable(), + mode: z.string().nullable(), + parentAutomationExecutionId: z.string().nullable(), + currentStepName: z.string().nullable(), + currentAction: z.string().nullable(), + failureMessage: z.string().nullable(), + targetParameterName: z.string().nullable(), + target: z.string().nullable(), + maxConcurrency: z.string().nullable(), + maxErrors: z.string().nullable(), + parameters: z.record(z.string(), z.array(z.string())).nullable(), + outputs: z.record(z.string(), z.array(z.string())).nullable(), + stepExecutions: z.array(StepExecutionSchema), + stepExecutionsTruncated: z.boolean().nullable(), +}) + +export const awsSsmGetAutomationExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/get-automation-execution', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmGetAutomationExecutionRequest = ContractBodyInput< + typeof awsSsmGetAutomationExecutionContract +> +export type AwsSsmGetAutomationExecutionBody = ContractBody< + typeof awsSsmGetAutomationExecutionContract +> +export type AwsSsmGetAutomationExecutionResponse = ContractJsonResponse< + typeof awsSsmGetAutomationExecutionContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-get-command-invocation.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-get-command-invocation.ts new file mode 100644 index 00000000000..051a0f312dd --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-get-command-invocation.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const INSTANCE_ID_PATTERN = /^(i-(\w{8}|\w{17})|mi-\w{17})$/ + +const COMMAND_ID_PATTERN = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + commandId: z.string().regex(COMMAND_ID_PATTERN, 'commandId must be a 36-character command ID'), + instanceId: z.string().regex(INSTANCE_ID_PATTERN, 'instanceId must look like i-0abc… or mi-…'), + pluginName: z.string().min(4).nullish(), +}) + +const ResponseSchema = z.object({ + commandId: z.string(), + instanceId: z.string(), + comment: z.string().nullable(), + documentName: z.string().nullable(), + documentVersion: z.string().nullable(), + pluginName: z.string().nullable(), + responseCode: z.number().nullable(), + executionStartDateTime: z.string().nullable(), + executionElapsedTime: z.string().nullable(), + executionEndDateTime: z.string().nullable(), + status: z.string(), + statusDetails: z.string().nullable(), + standardOutputContent: z.string(), + standardOutputUrl: z.string().nullable(), + standardErrorContent: z.string(), + standardErrorUrl: z.string().nullable(), +}) + +export const awsSsmGetCommandInvocationContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/get-command-invocation', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmGetCommandInvocationRequest = ContractBodyInput< + typeof awsSsmGetCommandInvocationContract +> +export type AwsSsmGetCommandInvocationBody = ContractBody +export type AwsSsmGetCommandInvocationResponse = ContractJsonResponse< + typeof awsSsmGetCommandInvocationContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-get-document.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-get-document.ts new file mode 100644 index 00000000000..45fabff9958 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-get-document.ts @@ -0,0 +1,65 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DOCUMENT_NAME_PATTERN = /^[a-zA-Z0-9_\-.:/]{3,128}$/ + +const DOCUMENT_VERSION_PATTERN = /^(\$LATEST|\$DEFAULT|[1-9][0-9]*)$/ + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + name: z + .string() + .regex(DOCUMENT_NAME_PATTERN, 'name must be 3-128 characters of letters, digits, and _-.:/'), + documentVersion: z + .string() + .regex( + DOCUMENT_VERSION_PATTERN, + 'documentVersion must be $LATEST, $DEFAULT, or a positive version number' + ) + .nullish(), + versionName: z + .string() + .regex( + /^[a-zA-Z0-9_\-.]{1,128}$/, + 'versionName must be 1-128 characters of letters, digits, and _-.' + ) + .nullish(), + documentFormat: z.enum(['YAML', 'JSON', 'TEXT']).nullish(), +}) + +const ResponseSchema = z.object({ + name: z.string(), + displayName: z.string().nullable(), + createdDate: z.string().nullable(), + versionName: z.string().nullable(), + documentVersion: z.string().nullable(), + status: z.string().nullable(), + statusInformation: z.string().nullable(), + content: z.string(), + documentType: z.string().nullable(), + documentFormat: z.string().nullable(), + reviewStatus: z.string().nullable(), +}) + +export const awsSsmGetDocumentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/get-document', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmGetDocumentRequest = ContractBodyInput +export type AwsSsmGetDocumentBody = ContractBody +export type AwsSsmGetDocumentResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-get-parameter.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-get-parameter.ts new file mode 100644 index 00000000000..6f50c793d26 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-get-parameter.ts @@ -0,0 +1,45 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ParameterSchema = z.object({ + name: z.string(), + type: z.string(), + value: z.string(), + version: z.number().nullable(), + selector: z.string().nullable(), + sourceResult: z.string().nullable(), + lastModifiedDate: z.string().nullable(), + arn: z.string(), + dataType: z.string().nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + name: z.string().min(1, 'Parameter name is required').max(2048), + withDecryption: z.boolean().nullish(), +}) + +const ResponseSchema = ParameterSchema + +export const awsSsmGetParameterContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/get-parameter', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmGetParameterRequest = ContractBodyInput +export type AwsSsmGetParameterBody = ContractBody +export type AwsSsmGetParameterResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-get-parameters-by-path.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-get-parameters-by-path.ts new file mode 100644 index 00000000000..13e03441277 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-get-parameters-by-path.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ParameterStringFilterSchema = z.object({ + Key: z.string().min(1, 'Filter Key is required'), + Option: z.string().min(1).max(10).optional(), + Values: z.array(z.string().min(1)).min(1).max(50).optional(), +}) + +const ParameterSchema = z.object({ + name: z.string(), + type: z.string(), + value: z.string(), + version: z.number().nullable(), + selector: z.string().nullable(), + sourceResult: z.string().nullable(), + lastModifiedDate: z.string().nullable(), + arn: z.string(), + dataType: z.string().nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + path: z + .string() + .min(1, 'Parameter path is required') + .max(2048) + .startsWith('/', 'path must start with a forward slash (e.g., /prod/app)'), + recursive: z.boolean().nullish(), + withDecryption: z.boolean().nullish(), + parameterFilters: z.array(ParameterStringFilterSchema).nullish(), + maxResults: z.number().int().min(1).max(10).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + parameters: z.array(ParameterSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmGetParametersByPathContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/get-parameters-by-path', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmGetParametersByPathRequest = ContractBodyInput< + typeof awsSsmGetParametersByPathContract +> +export type AwsSsmGetParametersByPathBody = ContractBody +export type AwsSsmGetParametersByPathResponse = ContractJsonResponse< + typeof awsSsmGetParametersByPathContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-get-parameters.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-get-parameters.ts new file mode 100644 index 00000000000..0e529dfeb7a --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-get-parameters.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ParameterSchema = z.object({ + name: z.string(), + type: z.string(), + value: z.string(), + version: z.number().nullable(), + selector: z.string().nullable(), + sourceResult: z.string().nullable(), + lastModifiedDate: z.string().nullable(), + arn: z.string(), + dataType: z.string().nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + names: z + .array(z.string().min(1).max(2048)) + .min(1, 'At least one parameter name is required') + .max(10, 'GetParameters accepts at most 10 names'), + withDecryption: z.boolean().nullish(), +}) + +const ResponseSchema = z.object({ + parameters: z.array(ParameterSchema), + invalidParameters: z.array(z.string()), + count: z.number(), +}) + +export const awsSsmGetParametersContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/get-parameters', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmGetParametersRequest = ContractBodyInput +export type AwsSsmGetParametersBody = ContractBody +export type AwsSsmGetParametersResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-list-command-invocations.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-list-command-invocations.ts new file mode 100644 index 00000000000..a6a0850384a --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-list-command-invocations.ts @@ -0,0 +1,92 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const INSTANCE_ID_PATTERN = /^(i-(\w{8}|\w{17})|mi-\w{17})$/ + +const COMMAND_ID_PATTERN = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ + +const CommandFilterSchema = z.object({ + key: z.enum(['InvokedAfter', 'InvokedBefore', 'Status', 'DocumentName']), + value: z.string().min(1, 'Filter value is required').max(128), +}) + +const CommandPluginSchema = z.object({ + name: z.string(), + status: z.string(), + statusDetails: z.string().nullable(), + responseCode: z.number().nullable(), + responseStartDateTime: z.string().nullable(), + responseFinishDateTime: z.string().nullable(), + output: z.string().nullable(), + standardOutputUrl: z.string().nullable(), + standardErrorUrl: z.string().nullable(), +}) + +const CommandInvocationSchema = z.object({ + commandId: z.string(), + instanceId: z.string(), + instanceName: z.string().nullable(), + documentName: z.string().nullable(), + documentVersion: z.string().nullable(), + comment: z.string().nullable(), + requestedDateTime: z.string().nullable(), + status: z.string(), + statusDetails: z.string().nullable(), + traceOutput: z.string().nullable(), + standardOutputUrl: z.string().nullable(), + standardErrorUrl: z.string().nullable(), + serviceRole: z.string().nullable(), + commandPlugins: z.array(CommandPluginSchema), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + commandId: z + .string() + .regex(COMMAND_ID_PATTERN, 'commandId must be a 36-character command ID') + .nullish(), + instanceId: z + .string() + .regex(INSTANCE_ID_PATTERN, 'instanceId must look like i-0abc… or mi-…') + .nullish(), + filters: z.array(CommandFilterSchema).max(5).nullish(), + details: z.boolean().nullish(), + maxResults: z.number().int().min(1).max(50).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + commandInvocations: z.array(CommandInvocationSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmListCommandInvocationsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/list-command-invocations', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmListCommandInvocationsRequest = ContractBodyInput< + typeof awsSsmListCommandInvocationsContract +> +export type AwsSsmListCommandInvocationsBody = ContractBody< + typeof awsSsmListCommandInvocationsContract +> +export type AwsSsmListCommandInvocationsResponse = ContractJsonResponse< + typeof awsSsmListCommandInvocationsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-list-commands.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-list-commands.ts new file mode 100644 index 00000000000..5303a1ae734 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-list-commands.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const INSTANCE_ID_PATTERN = /^(i-(\w{8}|\w{17})|mi-\w{17})$/ + +const COMMAND_ID_PATTERN = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ + +const CommandFilterSchema = z.object({ + key: z.enum(['InvokedAfter', 'InvokedBefore', 'Status', 'ExecutionStage', 'DocumentName']), + value: z.string().min(1, 'Filter value is required').max(128), +}) + +const TargetSchema = z.object({ + key: z.string().nullable(), + values: z.array(z.string()), +}) + +const CommandSchema = z.object({ + commandId: z.string(), + documentName: z.string(), + documentVersion: z.string().nullable(), + comment: z.string().nullable(), + status: z.string(), + statusDetails: z.string().nullable(), + requestedDateTime: z.string().nullable(), + expiresAfter: z.string().nullable(), + instanceIds: z.array(z.string()), + targets: z.array(TargetSchema), + maxConcurrency: z.string().nullable(), + maxErrors: z.string().nullable(), + targetCount: z.number().nullable(), + completedCount: z.number().nullable(), + errorCount: z.number().nullable(), + deliveryTimedOutCount: z.number().nullable(), + executionTimeoutSeconds: z.number().nullable(), + outputS3BucketName: z.string().nullable(), + outputS3KeyPrefix: z.string().nullable(), + outputS3Region: z.string().nullable(), + serviceRole: z.string().nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + commandId: z + .string() + .regex(COMMAND_ID_PATTERN, 'commandId must be a 36-character command ID') + .nullish(), + instanceId: z + .string() + .regex(INSTANCE_ID_PATTERN, 'instanceId must look like i-0abc… or mi-…') + .nullish(), + filters: z.array(CommandFilterSchema).max(5).nullish(), + maxResults: z.number().int().min(1).max(50).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + commands: z.array(CommandSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmListCommandsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/list-commands', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmListCommandsRequest = ContractBodyInput +export type AwsSsmListCommandsBody = ContractBody +export type AwsSsmListCommandsResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-list-compliance-items.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-list-compliance-items.ts new file mode 100644 index 00000000000..717e8a61ce6 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-list-compliance-items.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ComplianceStringFilterSchema = z.object({ + Key: z.string().min(1).max(200).optional(), + Values: z.array(z.string().min(1)).min(1).max(20).optional(), + Type: z.enum(['EQUAL', 'NOT_EQUAL', 'BEGIN_WITH', 'LESS_THAN', 'GREATER_THAN']).optional(), +}) + +const ComplianceItemSchema = z.object({ + complianceType: z.string(), + resourceType: z.string(), + resourceId: z.string(), + id: z.string(), + title: z.string(), + status: z.string(), + severity: z.string(), + executionTime: z.string().nullable(), + executionId: z.string().nullable(), + executionType: z.string().nullable(), + details: z.record(z.string(), z.string()).nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + /** + * AWS documents "you can only specify one resource ID per call" for `ResourceIds`. + * `ResourceTypes` publishes a minimum of 1 item and no maximum, so it is left + * unbounded rather than inheriting a cap AWS never documented. + */ + resourceIds: z.array(z.string().min(1)).max(1).nullish(), + resourceTypes: z.array(z.string().min(1)).min(1).nullish(), + filters: z.array(ComplianceStringFilterSchema).nullish(), + maxResults: z.number().int().min(1).max(50).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + complianceItems: z.array(ComplianceItemSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmListComplianceItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/list-compliance-items', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmListComplianceItemsRequest = ContractBodyInput< + typeof awsSsmListComplianceItemsContract +> +export type AwsSsmListComplianceItemsBody = ContractBody +export type AwsSsmListComplianceItemsResponse = ContractJsonResponse< + typeof awsSsmListComplianceItemsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-list-compliance-summaries.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-list-compliance-summaries.ts new file mode 100644 index 00000000000..cb2a5d73168 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-list-compliance-summaries.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ComplianceStringFilterSchema = z.object({ + Key: z.string().min(1).max(200).optional(), + Values: z.array(z.string().min(1)).min(1).max(20).optional(), + Type: z.enum(['EQUAL', 'NOT_EQUAL', 'BEGIN_WITH', 'LESS_THAN', 'GREATER_THAN']).optional(), +}) + +const SeveritySummarySchema = z.object({ + criticalCount: z.number().nullable(), + highCount: z.number().nullable(), + mediumCount: z.number().nullable(), + lowCount: z.number().nullable(), + informationalCount: z.number().nullable(), + unspecifiedCount: z.number().nullable(), +}) + +const ComplianceSummaryItemSchema = z.object({ + complianceType: z.string(), + compliantCount: z.number().nullable(), + compliantSeveritySummary: SeveritySummarySchema.nullable(), + nonCompliantCount: z.number().nullable(), + nonCompliantSeveritySummary: SeveritySummarySchema.nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + filters: z.array(ComplianceStringFilterSchema).nullish(), + maxResults: z.number().int().min(1).max(50).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + complianceSummaryItems: z.array(ComplianceSummaryItemSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmListComplianceSummariesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/list-compliance-summaries', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmListComplianceSummariesRequest = ContractBodyInput< + typeof awsSsmListComplianceSummariesContract +> +export type AwsSsmListComplianceSummariesBody = ContractBody< + typeof awsSsmListComplianceSummariesContract +> +export type AwsSsmListComplianceSummariesResponse = ContractJsonResponse< + typeof awsSsmListComplianceSummariesContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-list-documents.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-list-documents.ts new file mode 100644 index 00000000000..f7ea4a0c1db --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-list-documents.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DocumentKeyValuesFilterSchema = z.object({ + Key: z.string().min(1, 'Filter Key must not be empty').max(128).optional(), + Values: z.array(z.string().min(1).max(256)).optional(), +}) + +const DocumentIdentifierSchema = z.object({ + name: z.string(), + displayName: z.string().nullable(), + owner: z.string().nullable(), + createdDate: z.string().nullable(), + versionName: z.string().nullable(), + documentVersion: z.string().nullable(), + documentType: z.string().nullable(), + documentFormat: z.string().nullable(), + schemaVersion: z.string().nullable(), + platformTypes: z.array(z.string()), + targetType: z.string().nullable(), + reviewStatus: z.string().nullable(), + author: z.string().nullable(), + tags: z.array(z.object({ key: z.string(), value: z.string() })), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + filters: z.array(DocumentKeyValuesFilterSchema).max(6).nullish(), + maxResults: z.number().int().min(1).max(50).nullish(), + nextToken: z.string().nullish(), +}) + +const ResponseSchema = z.object({ + documents: z.array(DocumentIdentifierSchema), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSsmListDocumentsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/list-documents', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmListDocumentsRequest = ContractBodyInput +export type AwsSsmListDocumentsBody = ContractBody +export type AwsSsmListDocumentsResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-put-parameter.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-put-parameter.ts new file mode 100644 index 00000000000..7801e008e24 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-put-parameter.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + name: z.string().min(1, 'Parameter name is required').max(2048), + value: z.string().min(1, 'Parameter value is required'), + type: z.enum(['String', 'StringList', 'SecureString']).nullish(), + description: z.string().max(1024).nullish(), + keyId: z.string().min(1).max(256).nullish(), + overwrite: z.boolean().nullish(), + allowedPattern: z.string().max(1024).nullish(), + tier: z.enum(['Standard', 'Advanced', 'Intelligent-Tiering']).nullish(), + dataType: z.string().max(128).nullish(), + policies: z.string().min(1).max(4096).nullish(), +}) + +const ResponseSchema = z.object({ + message: z.string(), + name: z.string(), + version: z.number().nullable(), + tier: z.string().nullable(), +}) + +export const awsSsmPutParameterContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/put-parameter', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmPutParameterRequest = ContractBodyInput +export type AwsSsmPutParameterBody = ContractBody +export type AwsSsmPutParameterResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-send-command.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-send-command.ts new file mode 100644 index 00000000000..1a6a20d97f2 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-send-command.ts @@ -0,0 +1,129 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const INSTANCE_ID_PATTERN = /^(i-(\w{8}|\w{17})|mi-\w{17})$/ + +const DOCUMENT_NAME_PATTERN = /^[a-zA-Z0-9_\-.:/]{3,128}$/ + +const DOCUMENT_VERSION_PATTERN = /^(\$LATEST|\$DEFAULT|[1-9][0-9]*)$/ + +const MAX_CONCURRENCY_PATTERN = /^([1-9][0-9]*|[1-9][0-9]%|[1-9]%|100%)$/ + +const MAX_ERRORS_PATTERN = /^([1-9][0-9]*|[0]|[1-9][0-9]%|[0-9]%|100%)$/ + +const TargetInputSchema = z.object({ + Key: z.string().min(1, 'Target Key is required'), + Values: z.array(z.string()).min(1, 'Target Values must contain at least one value'), +}) + +const TargetSchema = z.object({ + key: z.string().nullable(), + values: z.array(z.string()), +}) + +const CommandSchema = z.object({ + commandId: z.string(), + documentName: z.string(), + documentVersion: z.string().nullable(), + comment: z.string().nullable(), + status: z.string(), + statusDetails: z.string().nullable(), + requestedDateTime: z.string().nullable(), + expiresAfter: z.string().nullable(), + instanceIds: z.array(z.string()), + targets: z.array(TargetSchema), + maxConcurrency: z.string().nullable(), + maxErrors: z.string().nullable(), + targetCount: z.number().nullable(), + completedCount: z.number().nullable(), + errorCount: z.number().nullable(), + deliveryTimedOutCount: z.number().nullable(), + executionTimeoutSeconds: z.number().nullable(), + outputS3BucketName: z.string().nullable(), + outputS3KeyPrefix: z.string().nullable(), + outputS3Region: z.string().nullable(), + serviceRole: z.string().nullable(), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + documentName: z + .string() + .regex( + DOCUMENT_NAME_PATTERN, + 'documentName must be 3-128 characters of letters, digits, and _-.:/' + ), + documentVersion: z + .string() + .regex( + DOCUMENT_VERSION_PATTERN, + 'documentVersion must be $LATEST, $DEFAULT, or a positive version number' + ) + .nullish(), + instanceIds: z + .array( + z.string().regex(INSTANCE_ID_PATTERN, 'instanceIds entries must look like i-0abc… or mi-…') + ) + .max(50) + .nullish(), + targets: z.array(TargetInputSchema).max(5).nullish(), + comment: z.string().max(100, 'comment must be at most 100 characters').nullish(), + parameters: z.record(z.string(), z.array(z.string())).nullish(), + executionTimeoutSeconds: z + .number() + .int() + .min(30, 'executionTimeoutSeconds must be at least 30') + .max(2592000, 'executionTimeoutSeconds must be at most 2592000') + .nullish(), + maxConcurrency: z + .string() + .max(7, 'maxConcurrency must be at most 7 characters') + .regex( + MAX_CONCURRENCY_PATTERN, + 'maxConcurrency must be a positive number or a percentage such as 10%' + ) + .nullish(), + maxErrors: z + .string() + .max(7, 'maxErrors must be at most 7 characters') + .regex(MAX_ERRORS_PATTERN, 'maxErrors must be a number or a percentage such as 10%') + .nullish(), + outputS3BucketName: z.string().min(3).max(63).nullish(), + outputS3KeyPrefix: z.string().max(500).nullish(), + serviceRoleArn: z.string().nullish(), +}) + +const ResponseSchema = CommandSchema + +const SendCommandSchema = RequestSchema.superRefine((value, ctx) => { + if (!value.instanceIds?.length && !value.targets?.length) { + ctx.addIssue({ + code: 'custom', + path: ['instanceIds'], + message: 'Provide instanceIds or targets to say which managed nodes should run the command', + }) + } +}) + +export const awsSsmSendCommandContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/send-command', + body: SendCommandSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmSendCommandRequest = ContractBodyInput +export type AwsSsmSendCommandBody = ContractBody +export type AwsSsmSendCommandResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-start-automation-execution.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-start-automation-execution.ts new file mode 100644 index 00000000000..ec22acff717 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-start-automation-execution.ts @@ -0,0 +1,106 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DOCUMENT_NAME_PATTERN = /^[a-zA-Z0-9_\-.:/]{3,128}$/ + +const DOCUMENT_VERSION_PATTERN = /^(\$LATEST|\$DEFAULT|[1-9][0-9]*)$/ + +const MAX_CONCURRENCY_PATTERN = /^([1-9][0-9]*|[1-9][0-9]%|[1-9]%|100%)$/ + +const MAX_ERRORS_PATTERN = /^([1-9][0-9]*|[0]|[1-9][0-9]%|[0-9]%|100%)$/ + +const CLIENT_TOKEN_PATTERN = + /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/ + +const TargetInputSchema = z.object({ + Key: z.string().min(1, 'Target Key is required'), + Values: z.array(z.string()).min(1, 'Target Values must contain at least one value'), +}) + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + documentName: z + .string() + .regex( + DOCUMENT_NAME_PATTERN, + 'documentName must be 3-128 characters of letters, digits, and _-.:/' + ), + documentVersion: z + .string() + .regex( + DOCUMENT_VERSION_PATTERN, + 'documentVersion must be $LATEST, $DEFAULT, or a positive version number' + ) + .nullish(), + parameters: z.record(z.string(), z.array(z.string())).nullish(), + mode: z.enum(['Auto', 'Interactive']).nullish(), + targetParameterName: z.string().min(1).max(50).nullish(), + targets: z.array(TargetInputSchema).max(1).nullish(), + maxConcurrency: z + .string() + .max(7, 'maxConcurrency must be at most 7 characters') + .regex( + MAX_CONCURRENCY_PATTERN, + 'maxConcurrency must be a positive number or a percentage such as 10%' + ) + .nullish(), + maxErrors: z + .string() + .max(7, 'maxErrors must be at most 7 characters') + .regex(MAX_ERRORS_PATTERN, 'maxErrors must be a number or a percentage such as 10%') + .nullish(), + clientToken: z + .string() + .regex(CLIENT_TOKEN_PATTERN, 'clientToken must be a 36-character UUID') + .nullish(), +}) + +const ResponseSchema = z.object({ + automationExecutionId: z.string(), +}) + +const StartAutomationExecutionSchema = RequestSchema.superRefine((value, ctx) => { + if (value.targets?.length && !value.targetParameterName) { + ctx.addIssue({ + code: 'custom', + path: ['targetParameterName'], + message: 'targetParameterName is required when targets is set', + }) + } + if (value.targetParameterName && !value.targets?.length) { + ctx.addIssue({ + code: 'custom', + path: ['targets'], + message: 'targets is required when targetParameterName is set', + }) + } +}) + +export const awsSsmStartAutomationExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/start-automation-execution', + body: StartAutomationExecutionSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmStartAutomationExecutionRequest = ContractBodyInput< + typeof awsSsmStartAutomationExecutionContract +> +export type AwsSsmStartAutomationExecutionBody = ContractBody< + typeof awsSsmStartAutomationExecutionContract +> +export type AwsSsmStartAutomationExecutionResponse = ContractJsonResponse< + typeof awsSsmStartAutomationExecutionContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ssm-stop-automation-execution.ts b/apps/sim/lib/api/contracts/tools/aws/ssm-stop-automation-execution.ts new file mode 100644 index 00000000000..b8cfb2571d4 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ssm-stop-automation-execution.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const EXECUTION_ID_PATTERN = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ + +const RequestSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + automationExecutionId: z + .string() + .regex( + EXECUTION_ID_PATTERN, + 'automationExecutionId must be a 36-character automation execution ID' + ), + stopType: z.enum(['Complete', 'Cancel']).nullish(), +}) + +const ResponseSchema = z.object({ + message: z.string(), + automationExecutionId: z.string(), +}) + +export const awsSsmStopAutomationExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ssm/stop-automation-execution', + body: RequestSchema, + response: { mode: 'json', schema: ResponseSchema }, +}) +export type AwsSsmStopAutomationExecutionRequest = ContractBodyInput< + typeof awsSsmStopAutomationExecutionContract +> +export type AwsSsmStopAutomationExecutionBody = ContractBody< + typeof awsSsmStopAutomationExecutionContract +> +export type AwsSsmStopAutomationExecutionResponse = ContractJsonResponse< + typeof awsSsmStopAutomationExecutionContract +> diff --git a/apps/sim/lib/api/contracts/tools/quickbooks.ts b/apps/sim/lib/api/contracts/tools/quickbooks.ts new file mode 100644 index 00000000000..394b14d42b6 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/quickbooks.ts @@ -0,0 +1,200 @@ +import { z } from 'zod' +import { userFileSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' + +const quickBooksAuthSchema = z.object({ + accessToken: z + .string() + .trim() + .min(1, 'Access token is required') + .max(8192, 'Access token is too long') + .refine((value) => !/[\r\n]/.test(value), 'Access token is invalid'), + realmId: z + .string() + .trim() + .max(64, 'QuickBooks company ID is too long') + .regex(/^[1-9]\d*$/, 'QuickBooks company ID is invalid'), + quickBooksEnvironment: z.enum(['sandbox', 'production']), +}) + +const documentTransactionTypeSchema = z.enum([ + 'credit_memo', + 'estimate', + 'invoice', + 'payment', + 'purchase_order', + 'refund_receipt', + 'sales_receipt', +]) + +const attachmentTargetTypeSchema = z.enum([ + 'bill', + 'bill_payment', + 'credit_memo', + 'deposit', + 'estimate', + 'invoice', + 'item', + 'journal_entry', + 'payment', + 'purchase', + 'purchase_order', + 'refund_receipt', + 'sales_receipt', + 'vendor_credit', +]) + +const optionalFileName = z.string().trim().max(1000, 'Filename is too long').optional().nullable() +const optionalContentType = z + .string() + .trim() + .max(100, 'Content type is too long') + .optional() + .nullable() +const optionalDescription = z + .string() + .trim() + .max(2000, 'Description is too long') + .optional() + .nullable() +const optionalNote = z.string().trim().max(2000, 'Note is too long').optional().nullable() +const boundedId = z.string().trim().min(1, 'ID is required').max(256, 'ID is too long') +const routeErrorSchema = z.object({ success: z.literal(false), error: z.string().min(1) }) +const attachableSchema = z + .object({ + Id: z.string().min(1), + FileName: z.string().optional(), + ContentType: z.string().optional(), + Size: z.number().optional(), + Note: z.string().optional(), + }) + .passthrough() + +/** + * Both QuickBooks document downloads share one internal operation schema. + * `documentKind` selects whether Intuit returns attachment bytes or a rendered PDF. + */ +export const quickBooksDownloadDocumentBodySchema = z.discriminatedUnion('documentKind', [ + quickBooksAuthSchema.extend({ + documentKind: z.literal('attachment'), + attachmentId: boundedId, + fileName: optionalFileName, + }), + quickBooksAuthSchema.extend({ + documentKind: z.literal('transaction_pdf'), + transactionType: documentTransactionTypeSchema, + transactionId: boundedId, + fileName: optionalFileName, + }), +]) + +export type QuickBooksDownloadDocumentBody = z.output + +export const quickBooksAddAttachmentBodySchema = quickBooksAuthSchema + .extend({ + attachmentKind: z.enum(['file', 'note']), + targetType: attachmentTargetTypeSchema, + targetId: boundedId, + file: RawFileInputSchema.optional().nullable(), + fileName: optionalFileName, + contentType: optionalContentType, + description: optionalDescription, + note: optionalNote, + }) + .superRefine((value, context) => { + if (value.attachmentKind === 'file') { + if (!value.file) + context.addIssue({ + code: 'custom', + path: ['file'], + message: 'File is required in File mode', + }) + if (value.note) + context.addIssue({ + code: 'custom', + path: ['note'], + message: 'Note-only content is not allowed in File mode', + }) + } else { + if (!value.note) + context.addIssue({ + code: 'custom', + path: ['note'], + message: 'Note is required in Note mode', + }) + if (value.file) + context.addIssue({ + code: 'custom', + path: ['file'], + message: 'A file is not allowed in Note mode', + }) + if (value.fileName || value.contentType || value.description) { + context.addIssue({ + code: 'custom', + path: ['attachmentKind'], + message: 'File-only fields are not allowed in Note mode', + }) + } + } + }) + +export type QuickBooksAddAttachmentBody = z.output + +const quickBooksStoredFileShape = { + file: userFileSchema, + fileName: z.string().min(1).max(1000), + size: z.number().int().positive(), +} + +export const quickBooksDownloadDocumentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/download-document', + body: quickBooksDownloadDocumentBodySchema, + response: { + mode: 'json', + schema: z.union([ + z.object({ + success: z.literal(true), + output: z.object({ + ...quickBooksStoredFileShape, + attachmentId: boundedId, + mimeType: z.string().min(1).max(255), + }), + }), + z.object({ + success: z.literal(true), + output: z.object({ + ...quickBooksStoredFileShape, + transactionType: documentTransactionTypeSchema, + transactionId: boundedId, + mimeType: z.literal('application/pdf'), + }), + }), + routeErrorSchema, + ]), + }, +}) + +export const quickBooksAddAttachmentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/add-attachment', + body: quickBooksAddAttachmentBodySchema, + response: { + mode: 'json', + schema: z.union([ + z.object({ + success: z.literal(true), + output: z.object({ + attachment: attachableSchema, + attachmentId: boundedId, + attachmentKind: z.enum(['file', 'note']), + targetType: attachmentTargetTypeSchema, + targetId: boundedId, + time: z.string().nullable(), + }), + }), + routeErrorSchema, + ]), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/credentials.test.ts b/apps/sim/lib/api/contracts/v2/credentials.test.ts new file mode 100644 index 00000000000..b6f9a89af1a --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/credentials.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { + V2_OAUTH_CONNECTION_PROVIDER_IDS, + v2CreateCredentialConnectionBodySchema, +} from '@/lib/api/contracts/v2/credentials' +import { getAllOAuthServices } from '@/lib/oauth/utils' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const QUICKBOOKS_CONFIG = { + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox' as const, + webhookVerifierToken: 'verifier-token', +} + +interface CredentialConnectionJsonSchema { + anyOf?: CredentialConnectionJsonSchema[] + properties?: { + credentialId?: { description?: string } + oauthClientConfig?: { + description?: string + properties?: { + clientSecret?: { writeOnly?: boolean } + webhookVerifierToken?: { writeOnly?: boolean } + } + } + } +} + +describe('v2CreateCredentialConnectionBodySchema', () => { + it('keeps the documented provider enum in sync with provider discovery', () => { + const discoveredProviderIds = getAllOAuthServices() + .filter((service) => service.authType === 'oauth') + .flatMap((service) => [service.providerId, ...(service.additionalProviderIds ?? [])]) + + expect(V2_OAUTH_CONNECTION_PROVIDER_IDS).toEqual(discoveredProviderIds) + }) + + it('requires app credentials for a new QuickBooks connection', () => { + expect( + v2CreateCredentialConnectionBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + providerId: 'quickbooks', + displayName: 'Accounting', + }).success + ).toBe(false) + expect( + v2CreateCredentialConnectionBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + providerId: 'quickbooks', + displayName: 'Accounting', + oauthClientConfig: QUICKBOOKS_CONFIG, + }).success + ).toBe(true) + }) + + it('rejects QuickBooks-only app credentials for another provider', () => { + expect( + v2CreateCredentialConnectionBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Mail', + oauthClientConfig: QUICKBOOKS_CONFIG, + }).success + ).toBe(false) + }) + + it('allows reconnect configuration because the use case resolves its provider', () => { + expect( + v2CreateCredentialConnectionBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + oauthClientConfig: QUICKBOOKS_CONFIG, + }).success + ).toBe(true) + }) + + it('publishes QuickBooks reconnect requirements and secret fields accurately', () => { + const published = z.toJSONSchema(v2CreateCredentialConnectionBodySchema, { + io: 'input', + unrepresentable: 'any', + }) as CredentialConnectionJsonSchema + const newQuickBooksConfig = published.anyOf?.[0]?.anyOf?.[0]?.properties?.oauthClientConfig + const reconnect = published.anyOf?.[1] + const reconnectConfig = reconnect?.properties?.oauthClientConfig + + expect(reconnect?.properties?.credentialId?.description).toContain( + 'QuickBooks reconnects also require oauthClientConfig' + ) + expect(reconnectConfig?.description).toContain('Required when credentialId identifies') + for (const config of [newQuickBooksConfig, reconnectConfig]) { + expect(config?.properties?.clientSecret?.writeOnly).toBe(true) + expect(config?.properties?.webhookVerifierToken?.writeOnly).toBe(true) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 8c2bf8bc3d9..a5fc8144d37 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -1,5 +1,8 @@ import { z } from 'zod' -import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' +import { + quickBooksOAuthClientConfigSchema, + workspaceCredentialRoleSchema, +} from '@/lib/api/contracts/credentials' import { missingFieldError, noInputSchema, @@ -126,6 +129,10 @@ export const v2OAuthCredentialProviderSchema = z .min(1) .max(10) .describe('Authorization servers available for this OAuth service.'), + fields: z + .array(v2CredentialProviderFieldSchema) + .max(20) + .describe('Write-only setup fields required before starting this OAuth flow.'), }) .strict() @@ -179,8 +186,13 @@ export const v2ListCredentialsQuerySchema = z search: v2SearchSchema.describe( 'Case-insensitive substring match against the credential display name.' ), - ...v2SortFields(v2CredentialSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), - ...v2PaginationFields({ description: 'Maximum credentials to return per page.' }), + ...v2SortFields(v2CredentialSortFields, { + sortBy: 'createdAt', + sortOrder: 'desc', + }), + ...v2PaginationFields({ + description: 'Maximum credentials to return per page.', + }), }) .strict() export type V2ListCredentialsQuery = z.output @@ -217,24 +229,110 @@ export const v2ListCredentialProvidersContract = defineRouteContract({ }, }) -const v2CreateCredentialConnectionByProviderSchema = z +export const V2_OAUTH_CONNECTION_PROVIDER_IDS = [ + 'google-email', + 'google-drive', + 'google-docs', + 'google-sheets', + 'google-forms', + 'google-calendar', + 'google-contacts', + 'google-ads', + 'google-bigquery', + 'google-tasks', + 'google-vault', + 'google-groups', + 'google-chat', + 'google-meet', + 'vertex-ai', + 'microsoft-ad', + 'microsoft-dataverse', + 'microsoft-excel', + 'microsoft-planner', + 'microsoft-teams', + 'microsoft-word', + 'outlook', + 'onedrive', + 'sharepoint', + 'x', + 'tiktok', + 'confluence', + 'jira', + 'airtable', + 'bitbucket', + 'notion', + 'clickup', + 'linear', + 'manageengine-sdp', + 'monday', + 'box', + 'dropbox', + 'shopify', + 'slack', + 'reddit', + 'wealthbox', + 'webflow', + 'trello', + 'asana', + 'attio', + 'calcom', + 'docusign', + 'pipedrive', + 'quickbooks', + 'hubspot', + 'linkedin', + 'instagram', + 'salesforce', + 'salesforce-sandbox', + 'zoho-desk', + 'zoom', + 'wordpress', + 'spotify', +] as const + +const V2_NON_QUICKBOOKS_OAUTH_CONNECTION_PROVIDER_IDS = V2_OAUTH_CONNECTION_PROVIDER_IDS.filter( + ( + providerId + ): providerId is Exclude<(typeof V2_OAUTH_CONNECTION_PROVIDER_IDS)[number], 'quickbooks'> => + providerId !== 'quickbooks' +) + +const v2CredentialConnectionBaseFields = { + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + displayName: z + .string({ error: 'displayName is required' }) + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .describe('Name shown for the new credential in Sim.'), +} + +const v2CreateQuickBooksCredentialConnectionSchema = z .object({ - workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + ...v2CredentialConnectionBaseFields, providerId: z - .string({ error: 'providerId is required' }) - .trim() - .min(1, 'providerId cannot be empty') - .max(255, 'providerId must be at most 255 characters') + .literal('quickbooks') + .describe('QuickBooks OAuth provider ID returned by credential-provider discovery.'), + oauthClientConfig: quickBooksOAuthClientConfigSchema.describe( + 'Write-only caller-managed Intuit OAuth app configuration.' + ), + }) + .strict() + +const v2CreateStandardOAuthCredentialConnectionSchema = z + .object({ + ...v2CredentialConnectionBaseFields, + providerId: z + .enum(V2_NON_QUICKBOOKS_OAUTH_CONNECTION_PROVIDER_IDS) .describe('Exact OAuth provider ID returned by credential-provider discovery.'), - displayName: z - .string({ error: 'displayName is required' }) - .trim() - .min(1, 'displayName cannot be empty') - .max(255, 'displayName must be at most 255 characters') - .describe('Name shown for the new credential in Sim.'), }) .strict() +const v2CreateCredentialConnectionByProviderSchema = z.union([ + v2CreateQuickBooksCredentialConnectionSchema, + v2CreateStandardOAuthCredentialConnectionSchema, +]) + const v2CreateCredentialConnectionByCredentialSchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), @@ -243,7 +341,14 @@ const v2CreateCredentialConnectionByCredentialSchema = z .trim() .min(1, 'credentialId cannot be empty') .max(255, 'credentialId must be at most 255 characters') - .describe('Existing OAuth credential to reconnect in place.'), + .describe( + 'Existing OAuth credential to reconnect in place. QuickBooks reconnects also require oauthClientConfig with the Intuit client ID, client secret, environment, and webhook verifier token.' + ), + oauthClientConfig: quickBooksOAuthClientConfigSchema + .optional() + .describe( + 'Write-only Intuit OAuth app configuration. Required when credentialId identifies a QuickBooks credential; omit it for other providers.' + ), }) .strict() @@ -376,12 +481,18 @@ const v2ServiceAccountCredentialsJsonSchema = z try { parsed = JSON.parse(value) } catch { - ctx.addIssue({ code: 'custom', message: 'credentials must be valid JSON' }) + ctx.addIssue({ + code: 'custom', + message: 'credentials must be valid JSON', + }) return z.NEVER } if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - ctx.addIssue({ code: 'custom', message: 'credentials must be a JSON object' }) + ctx.addIssue({ + code: 'custom', + message: 'credentials must be a JSON object', + }) return z.NEVER } @@ -389,7 +500,11 @@ const v2ServiceAccountCredentialsJsonSchema = z if (result.success) return result.data for (const issue of result.error.issues) { - ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }) + ctx.addIssue({ + code: 'custom', + path: issue.path, + message: issue.message, + }) } return z.NEVER }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index b5808398a3f..9dd52d9eeb4 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -424,6 +424,7 @@ const CREDENTIAL_PROVIDER_EXAMPLE = { providerFamily: 'salesforce', available: true, supportsReconnect: true, + fields: [], authorizationOptions: [ { providerId: 'salesforce', label: 'Production' }, { providerId: 'salesforce-sandbox', label: 'Sandbox' }, diff --git a/apps/sim/lib/api/contracts/webhooks.ts b/apps/sim/lib/api/contracts/webhooks.ts index 47bd6ea4c9e..b2c0b04ba79 100644 --- a/apps/sim/lib/api/contracts/webhooks.ts +++ b/apps/sim/lib/api/contracts/webhooks.ts @@ -333,3 +333,49 @@ export const tiktokWebhookResponseSchema = z.union([ z.object({ ok: z.literal(true) }), z.object({ error: z.string().min(1) }), ]) + +export const tiktokWebhookContract = defineRouteContract({ + method: 'POST', + path: '/api/webhooks/tiktok', + headers: tiktokWebhookHeadersSchema, + // Body is validated after HMAC verification against the raw payload. + body: tiktokWebhookEnvelopeSchema, + response: { + mode: 'json', + schema: tiktokWebhookResponseSchema, + }, +}) + +/** Intuit's app-level QuickBooks CloudEvent envelope. */ +export const quickBooksWebhookEventSchema = z.object({ + specversion: z.string().min(1).max(32), + id: z.string().min(1).max(255), + source: z.string().min(1).max(2048), + type: z.string().min(1).max(255), + datacontenttype: z.string().min(1).max(255).optional(), + time: z.string().datetime({ offset: true }), + intuitentityid: z.string().min(1).max(255), + intuitaccountid: z.string().min(1).max(255), + data: z.unknown().optional(), +}) + +export const quickBooksWebhookEventsSchema = z.array(quickBooksWebhookEventSchema).min(1).max(1000) + +export type QuickBooksWebhookEvent = z.input + +export const quickBooksWebhookParamsSchema = z.object({ + appKey: z.string().regex(/^[A-Za-z0-9_-]{43}$/, 'Invalid QuickBooks webhook app key'), +}) + +export const quickBooksWebhookContract = defineRouteContract({ + method: 'POST', + path: '/api/webhooks/quickbooks/[appKey]', + params: quickBooksWebhookParamsSchema, + headers: z.object({ 'intuit-signature': z.string().min(1) }), + // Body is validated after HMAC verification against the raw payload. + body: quickBooksWebhookEventsSchema, + response: { + mode: 'json', + schema: z.union([z.object({ ok: z.literal(true) }), z.object({ error: z.string().min(1) })]), + }, +}) diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index b32e97d493c..28f9e48599f 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -58,6 +58,8 @@ export const updateWorkspaceFileContentBodySchema = z .object({ content: z.string().max(70_000_000, 'Content is too large'), encoding: z.enum(['base64', 'utf-8']).optional(), + /** The content-version timestamp returned with the bytes this edit was based on. */ + expectedUpdatedAt: z.iso.datetime().optional(), }) .superRefine(({ content, encoding }, ctx) => { if (encoding === 'base64' && !isCanonicalBase64(content)) { @@ -95,6 +97,8 @@ export const createWorkspaceFileBodySchema = z export type CreateWorkspaceFileBody = z.input +export type UpdateWorkspaceFileContentBody = z.input + /** No real image approaches this; the bound rejects absurd or hostile values on the backfill path. */ const IMAGE_DIMENSION_MAX = 100_000 @@ -131,6 +135,8 @@ export const workspaceFileRecordSchema = z.object({ deletedAt: z.coerce.date().nullable().optional(), uploadedAt: z.coerce.date(), updatedAt: z.coerce.date(), + /** Advances only when file bytes change; metadata edits do not invalidate text drafts. */ + contentUpdatedAt: z.coerce.date().nullable().optional(), storageContext: z.enum(['workspace', 'mothership']).optional(), share: shareRecordSchema.nullable().optional(), }) diff --git a/apps/sim/lib/billing/calculations/usage-reservation.test.ts b/apps/sim/lib/billing/calculations/usage-reservation.test.ts index ca71b7cd1e4..8a020ce48e7 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation.test.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation.test.ts @@ -339,13 +339,62 @@ describe('usage-reservation', () => { }) describe('refreshExecutionSlotExpiry', () => { - it('rethrows the original error object rather than the diagnostic wrapper', async () => { - const original = Object.assign(new Error('Command timed out'), { code: 'ETIMEDOUT' }) + it.each([ + new Error('Command timed out'), + Object.assign(new Error('connect ETIMEDOUT'), { code: 'ETIMEDOUT' }), + ])('classifies a Redis transport failure while preserving its cause: %s', async (original) => { getMock.mockRejectedValueOnce(original) - await expect(refreshExecutionSlotExpiry('exec-1', Date.now() + 60_000)).rejects.toBe(original) + await expect(refreshExecutionSlotExpiry('exec-1', Date.now() + 60_000)).rejects.toMatchObject( + { + name: 'UsageReservationUnavailableError', + code: 'SERVICE_OVERLOADED', + cause: original, + } + ) + expect(evalMock).not.toHaveBeenCalled() + }) + + it.each(['local', 'pointer'])('classifies a timeout extending the %s lease', async (stage) => { + evalMock.mockResolvedValueOnce(1).mockResolvedValueOnce(1) + await reserveExecutionSlot(memberParams) + const descriptor = String(evalMock.mock.calls[1][3]) + vi.clearAllMocks() + getMock.mockResolvedValueOnce(descriptor) + if (stage === 'pointer') evalMock.mockResolvedValueOnce(1) + const original = new Error('Command timed out') + evalMock.mockRejectedValueOnce(original) + + await expect(refreshExecutionSlotExpiry('exec-1', Date.now() + 60_000)).rejects.toMatchObject( + { + name: 'UsageReservationUnavailableError', + code: 'SERVICE_OVERLOADED', + cause: original, + } + ) + expect(evalMock).toHaveBeenCalledTimes(stage === 'pointer' ? 2 : 1) }) + it.each([ + new TypeError('invalid Redis command argument'), + Object.assign(new Error('NOAUTH Authentication required.'), { name: 'ReplyError' }), + Object.assign( + new Error('WRONGTYPE Operation against a key holding the wrong kind of value'), + { + name: 'ReplyError', + } + ), + ])( + 'does not classify programming or server configuration failures as transient: %s', + async (original) => { + getMock.mockRejectedValueOnce(original) + + await expect(refreshExecutionSlotExpiry('exec-1', Date.now() + 60_000)).rejects.toBe( + original + ) + } + ) + it('refreshes only the locally owned slot and matching pointer', async () => { evalMock.mockResolvedValueOnce(1).mockResolvedValueOnce(1) await reserveExecutionSlot(memberParams) diff --git a/apps/sim/lib/billing/calculations/usage-reservation.ts b/apps/sim/lib/billing/calculations/usage-reservation.ts index 89f9df98edf..f7a818b3955 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation.ts @@ -10,6 +10,7 @@ import { } from '@/lib/core/admission/transient-failure' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { describeRedisConnection, getRedisClient } from '@/lib/core/config/redis' +import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { getExecutionReservationTtlMs } from '@/lib/core/execution-limits' const logger = createLogger('UsageReservation') @@ -426,7 +427,7 @@ export type ReserveExecutionSlotResult = } /** - * Records connection state alongside a failed slot operation. + * Records connection state and classifies transient Redis refresh failures. * * These three functions are the first Redis calls a queued workflow makes, so * when the connection is not usable they are where it surfaces — as an @@ -449,6 +450,16 @@ async function withReservationDiagnostics( error: toError(error).message, redis: describeRedisConnection(), }) + /** ioredis commandTimeout rejects with an uncoded Error; match it only at the Redis boundary. */ + if ( + isRetryableInfrastructureError(error) || + (error instanceof Error && error.name === 'Error' && error.message === 'Command timed out') + ) { + throw new UsageReservationUnavailableError( + 'Usage reservation refresh is temporarily unavailable. Please retry.', + error + ) + } throw error } } diff --git a/apps/sim/lib/chunkers/constants.ts b/apps/sim/lib/chunkers/constants.ts index 0e67a927407..62cab1953e7 100644 --- a/apps/sim/lib/chunkers/constants.ts +++ b/apps/sim/lib/chunkers/constants.ts @@ -15,3 +15,12 @@ export const MAX_CHUNKING_SEPARATORS = 32 /** Max characters in a single chunking separator. Real delimiters are a few characters. */ export const MAX_CHUNKING_SEPARATOR_LENGTH = 100 + +/** + * Width of Sim's own documentation index (`docs_embeddings`), which is a single + * fixed-width pgvector column independent of the `EMBEDDING_OUTPUT_DIMS` a + * deployment chooses for its knowledge bases. Both the indexer and the docs + * search path pin this, so a deployment configured for another width cannot + * write vectors the docs column will not accept or query it at the wrong width. + */ +export const DOCS_EMBEDDING_DIMENSIONS = 1536 as const diff --git a/apps/sim/lib/chunkers/docs-chunker.test.ts b/apps/sim/lib/chunkers/docs-chunker.test.ts index cacd431c02f..58d99aa0031 100644 --- a/apps/sim/lib/chunkers/docs-chunker.test.ts +++ b/apps/sim/lib/chunkers/docs-chunker.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('@/lib/knowledge/embeddings', () => ({ generateEmbeddings: vi.fn(async () => ({ embeddings: [] })), - getConfiguredEmbeddingModel: vi.fn(() => 'test-model'), })) import { ChunkLimitExceededError } from '@/lib/chunkers/chunk-budget' diff --git a/apps/sim/lib/chunkers/docs-chunker.ts b/apps/sim/lib/chunkers/docs-chunker.ts index cbd0b581ee7..728ee155f4d 100644 --- a/apps/sim/lib/chunkers/docs-chunker.ts +++ b/apps/sim/lib/chunkers/docs-chunker.ts @@ -2,10 +2,12 @@ import fs from 'fs/promises' import path from 'path' import { createLogger } from '@sim/logger' import { ChunkBudget } from '@/lib/chunkers/chunk-budget' +import { DOCS_EMBEDDING_DIMENSIONS } from '@/lib/chunkers/constants' import { TextChunker } from '@/lib/chunkers/text-chunker' import type { DocChunk, DocsChunkerOptions } from '@/lib/chunkers/types' import { estimateTokens } from '@/lib/chunkers/utils' -import { generateEmbeddings, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { DEFAULT_EMBEDDING_MODEL } from '@/lib/knowledge/embedding-models' +import { generateEmbeddings } from '@/lib/knowledge/embeddings' interface HeaderInfo { level: number @@ -116,9 +118,22 @@ export class DocsChunker { const headers = this.extractHeaders(cleanedContent) logger.info(`Generating embeddings for ${textChunks.length} chunks in ${relativePath}`) - const embeddingModel = getConfiguredEmbeddingModel() + /** + * Pinned to the platform default rather than the deployment's configured + * knowledge-base model: `docs_embeddings` is one fixed-width column that + * every Sim install queries, so a deployment-specific model or width would + * write vectors it cannot store. + */ + const embeddingModel = DEFAULT_EMBEDDING_MODEL const embeddings: number[][] = - textChunks.length > 0 ? (await generateEmbeddings(textChunks, embeddingModel)).embeddings : [] + textChunks.length > 0 + ? ( + await generateEmbeddings(textChunks, { + model: embeddingModel, + dimensions: DOCS_EMBEDDING_DIMENSIONS, + }) + ).embeddings + : [] const chunks: DocChunk[] = [] let currentPosition = 0 diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index e506dff48ef..66b36aa9316 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -3,6 +3,7 @@ import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, like, ne, notLike, or, sql } from 'drizzle-orm' import { escapeLikePattern } from '@/lib/api/list-query' +import { DOCS_EMBEDDING_DIMENSIONS } from '@/lib/chunkers/constants' import { docsPathForSourceDocument, isDocsDir, @@ -11,6 +12,7 @@ import { } from '@/lib/copilot/docs/docs-corpus' import { docsSourceCandidates, UNMOUNTED_DOCS_SECTIONS } from '@/lib/copilot/docs/docs-path' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DEFAULT_EMBEDDING_MODEL } from '@/lib/knowledge/embedding-models' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' const logger = createLogger('DocsSearch') @@ -152,7 +154,10 @@ export async function searchDocs( path: options?.path ?? null, }) - const { embedding: queryEmbedding } = await generateSearchEmbedding(query) + const { embedding: queryEmbedding } = await generateSearchEmbedding(query, { + model: DEFAULT_EMBEDDING_MODEL, + dimensions: DOCS_EMBEDDING_DIMENSIONS, + }) if (!queryEmbedding || queryEmbedding.length === 0) { return { results: [], candidatesConsidered: 0, droppedBelowThreshold: 0, droppedStale: 0 } } diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 1b9e432d050..86005762478 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -51,6 +51,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/workflow-mcp-servers.mdx', 'cli/workflows.mdx', 'cli/workspaces.mdx', + 'desktop.mdx', 'files.mdx', 'files/editor.mdx', 'files/generating.mdx', @@ -102,6 +103,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/clickup.mdx', 'integrations/cloudflare.mdx', 'integrations/cloudformation.mdx', + 'integrations/cloudtrail.mdx', 'integrations/cloudwatch.mdx', 'integrations/codepipeline.mdx', 'integrations/confluence.mdx', @@ -263,6 +265,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/pulse.mdx', 'integrations/qdrant.mdx', 'integrations/quartr.mdx', + 'integrations/quickbooks.mdx', 'integrations/quiver.mdx', 'integrations/rabbitmq.mdx', 'integrations/railway.mdx', @@ -306,6 +309,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/sqs.mdx', 'integrations/square.mdx', 'integrations/ssh.mdx', + 'integrations/ssm.mdx', 'integrations/stagehand.mdx', 'integrations/stripe.mdx', 'integrations/sts.mdx', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 98e690d883f..64fa3a99f92 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -15,10 +15,12 @@ export interface ToolCatalogEntry { | 'browser_close_tab' | 'browser_drag' | 'browser_extract' + | 'browser_find' | 'browser_go_back' | 'browser_go_forward' | 'browser_hover' | 'browser_insert_text' + | 'browser_list_downloads' | 'browser_list_sessions' | 'browser_list_tabs' | 'browser_navigate' @@ -26,13 +28,16 @@ export interface ToolCatalogEntry { | 'browser_open_url' | 'browser_press_key' | 'browser_read_text' + | 'browser_reload' | 'browser_screenshot' | 'browser_scroll' | 'browser_select_option' + | 'browser_set_checked' | 'browser_snapshot' | 'browser_switch_tab' | 'browser_type' | 'browser_wait_for' + | 'browser_zoom' | 'call_integration_tool' | 'cancel_workflow_run' | 'connect_slack_bot' @@ -145,10 +150,12 @@ export interface ToolCatalogEntry { | 'browser_close_tab' | 'browser_drag' | 'browser_extract' + | 'browser_find' | 'browser_go_back' | 'browser_go_forward' | 'browser_hover' | 'browser_insert_text' + | 'browser_list_downloads' | 'browser_list_sessions' | 'browser_list_tabs' | 'browser_navigate' @@ -156,13 +163,16 @@ export interface ToolCatalogEntry { | 'browser_open_url' | 'browser_press_key' | 'browser_read_text' + | 'browser_reload' | 'browser_screenshot' | 'browser_scroll' | 'browser_select_option' + | 'browser_set_checked' | 'browser_snapshot' | 'browser_switch_tab' | 'browser_type' | 'browser_wait_for' + | 'browser_zoom' | 'call_integration_tool' | 'cancel_workflow_run' | 'connect_slack_bot' @@ -484,7 +494,7 @@ 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 the screenshot's scale.", + '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.', }, y: { type: 'number', @@ -801,6 +811,71 @@ export const BrowserExtract: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserFind: ToolCatalogEntry = { + id: 'browser_find', + name: 'browser_find', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + 'Optional current top-page element id to observe only that element and its subtree, such as a known form, row, or card. Returns scoped: true and fresh refs; all previous snapshot refs are invalidated. Cross-origin frame contents are omitted and mark the result truncated. Framed roots are rejected. Omit for a full-page snapshot or when the previous scope has disappeared.', + }, + maxResults: { + type: 'number', + description: 'Maximum matches to return (default 20, capped at 50).', + }, + query: { + type: 'string', + description: 'Literal case-insensitive text to find in ref-bearing snapshot lines.', + }, + }, + required: ['query'], + }, + resultSchema: { + type: 'object', + properties: { + matches: { + type: 'array', + description: 'Bounded matching snapshot lines with valid element ids.', + items: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'Fresh element id from the captured snapshot.', + }, + line: { type: 'string', description: 'Matching ref-bearing snapshot line.' }, + }, + }, + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + query: { type: 'string', description: 'Literal query that was searched.' }, + scoped: { + type: 'boolean', + description: 'Whether observation was limited to the requested element subtree.', + }, + title: { type: 'string', description: 'Top-page title when available.' }, + totalMatches: { type: 'number', description: 'Total matches before applying maxResults.' }, + truncated: { + type: 'boolean', + description: 'Whether snapshot coverage or matching results were truncated.', + }, + url: { type: 'string', description: 'Top-page URL.' }, + }, + required: ['query', 'matches', 'totalMatches', 'truncated'], + }, + clientExecutable: true, +} + export const BrowserGoBack: ToolCatalogEntry = { id: 'browser_go_back', name: 'browser_go_back', @@ -1012,6 +1087,47 @@ export const BrowserInsertText: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserListDownloads: ToolCatalogEntry = { + id: 'browser_list_downloads', + name: 'browser_list_downloads', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + resultSchema: { + type: 'object', + properties: { + downloads: { + type: 'array', + description: 'Recent downloads for this browser session, newest first.', + items: { + type: 'object', + properties: { + filename: { + type: 'string', + description: 'Sanitized filename; no local filesystem path is exposed.', + }, + id: { type: 'string', description: 'Session-local download id.' }, + receivedBytes: { type: 'number', description: 'Bytes received so far.' }, + startedAt: { type: 'string', description: 'ISO timestamp when the download started.' }, + state: { + type: 'string', + description: 'Current download lifecycle state.', + enum: ['progressing', 'completed', 'interrupted', 'cancelled'], + }, + totalBytes: { + type: 'number', + description: 'Expected total bytes when known, otherwise zero.', + }, + }, + }, + }, + scopeId: { type: 'string', description: 'Browser session scope that owns these downloads.' }, + }, + required: ['downloads', 'scopeId'], + }, + clientExecutable: true, +} + export const BrowserListSessions: ToolCatalogEntry = { id: 'browser_list_sessions', name: 'browser_list_sessions', @@ -1041,7 +1157,7 @@ export const BrowserNavigate: ToolCatalogEntry = { url: { type: 'string', description: - 'The absolute URL to navigate to, including scheme (https:// or http://). Must resolve to a public address — localhost and private/internal hosts are rejected.', + 'The absolute URL to navigate to, including scheme (https:// or http://). Public websites and localhost/loopback are supported; other private/internal hosts are rejected.', }, }, required: ['url'], @@ -1227,12 +1343,44 @@ export const BrowserReadText: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserReload: ToolCatalogEntry = { + id: 'browser_reload', + name: 'browser_reload', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + resultSchema: { + type: 'object', + properties: { + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + title: { type: 'string', description: 'Top-page title when available.' }, + url: { type: 'string', description: 'Top-page URL.' }, + }, + required: ['url', 'title'], + }, + clientExecutable: true, +} + export const BrowserScreenshot: ToolCatalogEntry = { id: 'browser_screenshot', name: 'browser_screenshot', route: 'client', mode: 'async', - parameters: { type: 'object', properties: {} }, + parameters: { + type: 'object', + properties: { + 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.", + }, + }, + }, clientExecutable: true, } @@ -1350,12 +1498,71 @@ export const BrowserSelectOption: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserSetChecked: ToolCatalogEntry = { + id: 'browser_set_checked', + name: 'browser_set_checked', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + checked: { + type: 'boolean', + description: + 'Desired checked state. Radio buttons cannot be unchecked directly; select another radio in the group instead.', + }, + 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.", + }, + }, + required: ['elementId', 'checked'], + }, + resultSchema: { + type: 'object', + properties: { + changed: { type: 'boolean', description: 'Whether the control needed to change.' }, + checked: { type: 'boolean', description: 'Settled checked state after the operation.' }, + dispatched: { type: 'boolean', description: 'Whether input was dispatched.' }, + element: { type: 'string', description: 'Resolved checkable control kind.' }, + note: { type: 'string', description: 'Readback or follow-up guidance.' }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + refRecovered: { + type: 'boolean', + description: + 'Whether a stale detached ref was safely rebound to one unique semantic match.', + }, + trusted: { + type: 'boolean', + description: "Whether Chromium's trusted pointer pipeline dispatched the change.", + }, + }, + required: ['checked', 'changed', 'dispatched'], + }, + clientExecutable: true, +} + export const BrowserSnapshot: ToolCatalogEntry = { id: 'browser_snapshot', name: 'browser_snapshot', route: 'client', mode: 'async', - parameters: { type: 'object', properties: {} }, + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + 'Optional current top-page element id to observe only that element and its subtree, such as a known form, row, or card. Returns scoped: true and fresh refs; all previous snapshot refs are invalidated. Cross-origin frame contents are omitted and mark the result truncated. Framed roots are rejected. Omit for a full-page snapshot or when the previous scope has disappeared.', + }, + }, + }, resultSchema: { type: 'object', properties: { @@ -1379,6 +1586,10 @@ export const BrowserSnapshot: ToolCatalogEntry = { description: 'Mounted DOM/frame outline containing model-visible [ref=N] ids.', }, pageHeight: { type: 'number', description: 'Top-page document height.' }, + scoped: { + type: 'boolean', + description: 'Whether observation was limited to the requested element subtree.', + }, scrollY: { type: 'number', description: 'Top-page window scroll offset.' }, title: { type: 'string', description: 'Captured top-page title.' }, truncated: { @@ -1551,11 +1762,39 @@ export const BrowserWaitFor: ToolCatalogEntry = { parameters: { type: 'object', properties: { + elementId: { + type: 'number', + description: + "Optional top-page element id from the current tab's latest browser_snapshot. Supply with state to inspect that exact registered DOM node; waits do not recover replacement nodes. Framed refs and navigation during an element wait are rejected; use text/URL conditions for those flows.", + }, + state: { + type: 'string', + description: + 'Optional semantic condition for elementId. attached/detached inspect DOM presence; visible/hidden inspect rendering; enabled/disabled include native and ARIA state; checked, expanded, and selected conditions inspect native or ARIA state. All supplied text, URL, and element conditions must pass.', + enum: [ + 'attached', + 'detached', + 'visible', + 'hidden', + 'enabled', + 'disabled', + 'checked', + 'unchecked', + 'expanded', + 'collapsed', + 'selected', + 'unselected', + ], + }, text: { type: 'string', description: 'Optional visible text to wait for.' }, timeoutMs: { type: 'number', description: 'Maximum time to wait, in milliseconds (default 10000, capped at 120000).', }, + urlContains: { + type: 'string', + description: "Optional substring that the active tab's URL must contain.", + }, }, }, resultSchema: { @@ -1564,13 +1803,18 @@ export const BrowserWaitFor: ToolCatalogEntry = { elapsedMs: { type: 'number', description: 'Elapsed wait duration.' }, found: { type: 'boolean', - description: 'Whether the requested text appeared before timeout.', + description: 'Whether every supplied wait condition passed before timeout.', }, foundInFrame: { type: 'boolean', description: 'Whether the match was found in an eligible visible child frame.', }, - note: { type: 'string', description: 'Timeout/recovery guidance.' }, + matched: { + type: 'array', + description: 'Condition categories that passed: text, url, and/or element.', + items: { type: 'string' }, + }, + note: { type: 'string', description: 'Timeout or recovery guidance.' }, notices: { type: 'array', description: @@ -1586,6 +1830,39 @@ export const BrowserWaitFor: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserZoom: ToolCatalogEntry = { + id: 'browser_zoom', + name: 'browser_zoom', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Zoom action: in, out, or reset.', + enum: ['in', 'out', 'reset'], + }, + }, + required: ['action'], + }, + resultSchema: { + type: 'object', + properties: { + action: { type: 'string', description: 'Applied zoom action.' }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + zoomPercent: { type: 'number', description: 'Settled tab zoom as a percentage.' }, + }, + required: ['action', 'zoomPercent'], + }, + clientExecutable: true, +} + export const CallIntegrationTool: ToolCatalogEntry = { id: 'call_integration_tool', name: 'call_integration_tool', @@ -7061,10 +7338,12 @@ export const TOOL_CATALOG: Record = { [BrowserCloseTab.id]: BrowserCloseTab, [BrowserDrag.id]: BrowserDrag, [BrowserExtract.id]: BrowserExtract, + [BrowserFind.id]: BrowserFind, [BrowserGoBack.id]: BrowserGoBack, [BrowserGoForward.id]: BrowserGoForward, [BrowserHover.id]: BrowserHover, [BrowserInsertText.id]: BrowserInsertText, + [BrowserListDownloads.id]: BrowserListDownloads, [BrowserListSessions.id]: BrowserListSessions, [BrowserListTabs.id]: BrowserListTabs, [BrowserNavigate.id]: BrowserNavigate, @@ -7072,13 +7351,16 @@ export const TOOL_CATALOG: Record = { [BrowserOpenUrl.id]: BrowserOpenUrl, [BrowserPressKey.id]: BrowserPressKey, [BrowserReadText.id]: BrowserReadText, + [BrowserReload.id]: BrowserReload, [BrowserScreenshot.id]: BrowserScreenshot, [BrowserScroll.id]: BrowserScroll, [BrowserSelectOption.id]: BrowserSelectOption, + [BrowserSetChecked.id]: BrowserSetChecked, [BrowserSnapshot.id]: BrowserSnapshot, [BrowserSwitchTab.id]: BrowserSwitchTab, [BrowserType.id]: BrowserType, [BrowserWaitFor.id]: BrowserWaitFor, + [BrowserZoom.id]: BrowserZoom, [CallIntegrationTool.id]: CallIntegrationTool, [CancelWorkflowRun.id]: CancelWorkflowRun, [ConnectSlackBot.id]: ConnectSlackBot, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 6af4096bf1c..e749e7d4663 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -225,7 +225,7 @@ 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 the screenshot's scale.", + '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.', }, y: { type: 'number', @@ -612,6 +612,82 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + browser_find: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + 'Optional current top-page element id to observe only that element and its subtree, such as a known form, row, or card. Returns scoped: true and fresh refs; all previous snapshot refs are invalidated. Cross-origin frame contents are omitted and mark the result truncated. Framed roots are rejected. Omit for a full-page snapshot or when the previous scope has disappeared.', + }, + maxResults: { + type: 'number', + description: 'Maximum matches to return (default 20, capped at 50).', + }, + query: { + type: 'string', + description: 'Literal case-insensitive text to find in ref-bearing snapshot lines.', + }, + }, + required: ['query'], + }, + resultSchema: { + type: 'object', + properties: { + matches: { + type: 'array', + description: 'Bounded matching snapshot lines with valid element ids.', + items: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'Fresh element id from the captured snapshot.', + }, + line: { + type: 'string', + description: 'Matching ref-bearing snapshot line.', + }, + }, + }, + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + query: { + type: 'string', + description: 'Literal query that was searched.', + }, + scoped: { + type: 'boolean', + description: 'Whether observation was limited to the requested element subtree.', + }, + title: { + type: 'string', + description: 'Top-page title when available.', + }, + totalMatches: { + type: 'number', + description: 'Total matches before applying maxResults.', + }, + truncated: { + type: 'boolean', + description: 'Whether snapshot coverage or matching results were truncated.', + }, + url: { + type: 'string', + description: 'Top-page URL.', + }, + }, + required: ['query', 'matches', 'totalMatches', 'truncated'], + }, + }, browser_go_back: { parameters: { type: 'object', @@ -865,6 +941,56 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['dispatched'], }, }, + browser_list_downloads: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: { + type: 'object', + properties: { + downloads: { + type: 'array', + description: 'Recent downloads for this browser session, newest first.', + items: { + type: 'object', + properties: { + filename: { + type: 'string', + description: 'Sanitized filename; no local filesystem path is exposed.', + }, + id: { + type: 'string', + description: 'Session-local download id.', + }, + receivedBytes: { + type: 'number', + description: 'Bytes received so far.', + }, + startedAt: { + type: 'string', + description: 'ISO timestamp when the download started.', + }, + state: { + type: 'string', + description: 'Current download lifecycle state.', + enum: ['progressing', 'completed', 'interrupted', 'cancelled'], + }, + totalBytes: { + type: 'number', + description: 'Expected total bytes when known, otherwise zero.', + }, + }, + }, + }, + scopeId: { + type: 'string', + description: 'Browser session scope that owns these downloads.', + }, + }, + required: ['downloads', 'scopeId'], + }, + }, browser_list_sessions: { parameters: { type: 'object', @@ -886,7 +1012,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { url: { type: 'string', description: - 'The absolute URL to navigate to, including scheme (https:// or http://). Must resolve to a public address — localhost and private/internal hosts are rejected.', + 'The absolute URL to navigate to, including scheme (https:// or http://). Public websites and localhost/loopback are supported; other private/internal hosts are rejected.', }, }, required: ['url'], @@ -1103,11 +1229,45 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, - browser_screenshot: { + browser_reload: { parameters: { type: 'object', properties: {}, }, + resultSchema: { + type: 'object', + properties: { + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + title: { + type: 'string', + description: 'Top-page title when available.', + }, + url: { + type: 'string', + description: 'Top-page URL.', + }, + }, + required: ['url', 'title'], + }, + }, + browser_screenshot: { + parameters: { + type: 'object', + properties: { + 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.", + }, + }, + }, resultSchema: undefined, }, browser_scroll: { @@ -1250,10 +1410,77 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['selected'], }, }, + browser_set_checked: { + parameters: { + type: 'object', + properties: { + checked: { + type: 'boolean', + description: + 'Desired checked state. Radio buttons cannot be unchecked directly; select another radio in the group instead.', + }, + 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.", + }, + }, + required: ['elementId', 'checked'], + }, + resultSchema: { + type: 'object', + properties: { + changed: { + type: 'boolean', + description: 'Whether the control needed to change.', + }, + checked: { + type: 'boolean', + description: 'Settled checked state after the operation.', + }, + dispatched: { + type: 'boolean', + description: 'Whether input was dispatched.', + }, + element: { + type: 'string', + description: 'Resolved checkable control kind.', + }, + note: { + type: 'string', + description: 'Readback or follow-up guidance.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + refRecovered: { + type: 'boolean', + description: + 'Whether a stale detached ref was safely rebound to one unique semantic match.', + }, + trusted: { + type: 'boolean', + description: "Whether Chromium's trusted pointer pipeline dispatched the change.", + }, + }, + required: ['checked', 'changed', 'dispatched'], + }, + }, browser_snapshot: { parameters: { type: 'object', - properties: {}, + properties: { + elementId: { + type: 'number', + description: + 'Optional current top-page element id to observe only that element and its subtree, such as a known form, row, or card. Returns scoped: true and fresh refs; all previous snapshot refs are invalidated. Cross-origin frame contents are omitted and mark the result truncated. Framed roots are rejected. Omit for a full-page snapshot or when the previous scope has disappeared.', + }, + }, }, resultSchema: { type: 'object', @@ -1283,6 +1510,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'number', description: 'Top-page document height.', }, + scoped: { + type: 'boolean', + description: 'Whether observation was limited to the requested element subtree.', + }, scrollY: { type: 'number', description: 'Top-page window scroll offset.', @@ -1488,6 +1719,30 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + elementId: { + type: 'number', + description: + "Optional top-page element id from the current tab's latest browser_snapshot. Supply with state to inspect that exact registered DOM node; waits do not recover replacement nodes. Framed refs and navigation during an element wait are rejected; use text/URL conditions for those flows.", + }, + state: { + type: 'string', + description: + 'Optional semantic condition for elementId. attached/detached inspect DOM presence; visible/hidden inspect rendering; enabled/disabled include native and ARIA state; checked, expanded, and selected conditions inspect native or ARIA state. All supplied text, URL, and element conditions must pass.', + enum: [ + 'attached', + 'detached', + 'visible', + 'hidden', + 'enabled', + 'disabled', + 'checked', + 'unchecked', + 'expanded', + 'collapsed', + 'selected', + 'unselected', + ], + }, text: { type: 'string', description: 'Optional visible text to wait for.', @@ -1496,6 +1751,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'number', description: 'Maximum time to wait, in milliseconds (default 10000, capped at 120000).', }, + urlContains: { + type: 'string', + description: "Optional substring that the active tab's URL must contain.", + }, }, }, resultSchema: { @@ -1507,15 +1766,22 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, found: { type: 'boolean', - description: 'Whether the requested text appeared before timeout.', + description: 'Whether every supplied wait condition passed before timeout.', }, foundInFrame: { type: 'boolean', description: 'Whether the match was found in an eligible visible child frame.', }, + matched: { + type: 'array', + description: 'Condition categories that passed: text, url, and/or element.', + items: { + type: 'string', + }, + }, note: { type: 'string', - description: 'Timeout/recovery guidance.', + description: 'Timeout or recovery guidance.', }, notices: { type: 'array', @@ -1532,6 +1798,41 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + browser_zoom: { + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Zoom action: in, out, or reset.', + enum: ['in', 'out', 'reset'], + }, + }, + required: ['action'], + }, + resultSchema: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Applied zoom action.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + zoomPercent: { + type: 'number', + description: 'Settled tab zoom as a percentage.', + }, + }, + required: ['action', 'zoomPercent'], + }, + }, call_integration_tool: { parameters: { properties: { diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 359d594252e..a378f1b290e 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -139,16 +139,35 @@ describe('pendingToolWaitBudgetMs', () => { }) it('matches the requested browser_wait_for renderer budget', () => { - expect(pendingToolWaitBudgetMs({ name: 'browser_wait_for', status: 'executing' })).toBe(25_000) + expect(pendingToolWaitBudgetMs({ name: 'browser_wait_for', status: 'executing' })).toBe(85_000) expect( pendingToolWaitBudgetMs({ name: 'browser_wait_for', status: 'executing', params: { timeoutMs: 120_000 }, }) - ).toBe(135_000) + ).toBe(195_000) }) + it.each([ + 'browser_navigate', + 'browser_open_url', + 'browser_go_back', + 'browser_go_forward', + 'browser_reload', + 'browser_open_tab', + 'browser_switch_tab', + ])('includes authorization, queueing, and navigation in the %s budget', (name) => { + expect(pendingToolWaitBudgetMs({ name, status: 'executing' })).toBe(130_000) + }) + + it.each(['browser_snapshot', 'browser_find', 'browser_set_checked', 'browser_click'])( + 'allows the renderer queue budget for %s', + (name) => { + expect(pendingToolWaitBudgetMs({ name, status: 'executing' })).toBe(90_000) + } + ) + it('falls back to the tool\u2019s own watchdog once it is actually executing', () => { expect(pendingToolWaitBudgetMs({ name: 'terminal_run', status: 'executing' })).toBe( TOOL_WATCHDOG_DEFAULT_MS diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index d3a44749b6f..02a364eef68 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -1,7 +1,4 @@ -import { - BROWSER_WAIT_FOR_RENDERER_GRACE_MS, - normalizeBrowserWaitForTimeoutMs, -} from '@sim/browser-protocol' +import { browserToolRendererTimeoutMs, isCurrentBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' @@ -255,8 +252,8 @@ export function toolWatchdogTimeoutMs(toolName: string | undefined): number { /** * How long the resume gate may wait on one pending tool call. Permission - * prompts receive the long-running budget, while `browser_wait_for` receives - * its normalized requested timeout plus renderer delivery grace. + * prompts receive the long-running budget. Browser calls share the renderer's + * budget so authorization and native queueing cannot outlive the resume gate. */ export function pendingToolWaitBudgetMs( toolCall: @@ -264,11 +261,8 @@ export function pendingToolWaitBudgetMs( | undefined ): number { if (toolCall?.status === 'awaiting_approval') return TOOL_WATCHDOG_LONG_RUNNING_MS - if (toolCall?.name === 'browser_wait_for') { - return ( - normalizeBrowserWaitForTimeoutMs(toolCall.params?.timeoutMs) + - BROWSER_WAIT_FOR_RENDERER_GRACE_MS - ) + if (toolCall?.name && isCurrentBrowserToolName(toolCall.name)) { + return browserToolRendererTimeoutMs(toolCall.name, toolCall.params) } return toolWatchdogTimeoutMs(toolCall?.name) } 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 82520de71c3..44da20fb3ca 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 @@ -1205,6 +1205,22 @@ describe('executeBrowserToolOnClient', () => { expect(reported.note).toContain('could not be encoded') }) + it('preserves cropped screenshot offsets and explains coordinate conversion', async () => { + mockExecuteBrowserTool.mockResolvedValue({ + dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', + clip: { x: 20, y: 30, width: 200, height: 100 }, + scale: 2, + }) + executeBrowserToolOnClient(nextToolCallId(), 'browser_screenshot', { elementId: 0 }) + await flush() + + 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') + }) + it('gives restored-tab switching the renderer navigation budget', async () => { mockExecuteBrowserTool.mockResolvedValue({ tabId: '2', url: 'https://example.com' }) const toolCallId = nextToolCallId() 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 2ae56e08bba..f3f698f304e 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts @@ -7,13 +7,7 @@ * browser and reports the outcome via the confirm endpoint, which wakes the * server-side waiter. */ -import { - BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS, - BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS, - BROWSER_WAIT_FOR_RENDERER_GRACE_MS, - type BrowserToolName, - normalizeBrowserWaitForTimeoutMs, -} from '@sim/browser-protocol' +import { type BrowserToolName, browserToolRendererTimeoutMs } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' @@ -38,8 +32,6 @@ import { getBrowserSession, useBrowserSessionStore } from '@/stores/browser-sess const logger = createLogger('CopilotBrowserToolExecution') -const DEFAULT_TOOL_TIMEOUT_MS = BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS + 30_000 - /** * Tools that do not require an existing live page. Most create a new page; * `browser_list_sessions` reads the desktop's profile-level session registry. @@ -52,6 +44,7 @@ const LIVE_PAGE_OPTIONAL_TOOLS: ReadonlySet = new Set> @@ -543,21 +541,7 @@ function isOutcomeUnknownError(error: unknown): boolean { function timeoutForTool(toolName: BrowserToolName, params: Record): number | null { if (toolName === 'browser_request_takeover') return null - if ( - toolName === 'browser_navigate' || - toolName === 'browser_open_url' || - toolName === 'browser_go_back' || - toolName === 'browser_go_forward' || - toolName === 'browser_open_tab' || - toolName === 'browser_switch_tab' - ) { - return BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS - } - if (toolName === 'browser_wait_for') { - const requested = normalizeBrowserWaitForTimeoutMs(params.timeoutMs) - return BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS + requested + BROWSER_WAIT_FOR_RENDERER_GRACE_MS - } - return DEFAULT_TOOL_TIMEOUT_MS + return browserToolRendererTimeoutMs(toolName, params) } /** Splits a `data:;base64,` URL into its parts. */ @@ -600,9 +584,10 @@ function sanitizeResultForModel( ? viewport.url : '' const location = screenshotUrl ? ` of ${screenshotUrl}` : '' + const isElementCapture = isRecordLike(rest.clip) return { ...rest, - content: `Screenshot${location}. This is the rendered viewport only — it carries no element ids, so use browser_snapshot before interacting.`, + 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 }, diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 8c3757a4cb2..04e3fd9747b 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -25,6 +25,7 @@ const { mockCreateKnowledgeConnector, mockCreateKnowledgeTag, mockListKnowledgeTags, + mockIsKnowledgeMemberAccessAvailable, knowledgeOperations, } = vi.hoisted(() => { const defineOperation = (id: string, minimumRole: 'read' | 'write') => @@ -58,6 +59,7 @@ const { mockCreateKnowledgeConnector: vi.fn(), mockCreateKnowledgeTag: vi.fn(), mockListKnowledgeTags: vi.fn(), + mockIsKnowledgeMemberAccessAvailable: vi.fn(), knowledgeOperations: { addWorkspaceFiles: defineOperation('knowledge.documents.add_workspace_files', 'write'), bulkDelete: defineOperation('knowledge.bulk_delete', 'write'), @@ -98,6 +100,9 @@ vi.mock('@/lib/core/telemetry', () => ({ })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) vi.mock('@/lib/knowledge/application/operations', () => ({ knowledgeOperations })) +vi.mock('@/lib/knowledge/access/availability', () => ({ + isKnowledgeMemberAccessAvailable: mockIsKnowledgeMemberAccessAvailable, +})) vi.mock('@/lib/knowledge/application/add-workspace-files', () => ({ addWorkspaceFilesToKnowledgeBase: { operation: knowledgeOperations.addWorkspaceFiles, @@ -260,6 +265,7 @@ describe('manage_knowledge_base trusted application delegation', () => { ], failed: [], }) + mockIsKnowledgeMemberAccessAvailable.mockResolvedValue(true) mockSearchKnowledge.mockResolvedValue({ results: [], query: 'query', @@ -407,6 +413,42 @@ describe('manage_knowledge_base trusted application delegation', () => { expect(mockReadKnowledgeBase).not.toHaveBeenCalled() }) + it('asks for citations where per-member access is on', async () => { + const result = await knowledgeBaseServerTool.execute( + { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, + { ...CONTEXT, resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry() } + ) + + expect(result.message).toContain('') + expect(mockIsKnowledgeMemberAccessAvailable).toHaveBeenCalledWith({ + workspaceId: 'workspace-paid', + }) + }) + + it('asks for no citation where the workspace cannot render one', async () => { + mockIsKnowledgeMemberAccessAvailable.mockResolvedValue(false) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, + { ...CONTEXT, resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry() } + ) + + expect(result).toMatchObject({ success: true }) + expect(result.message).toBe('Found 0 result(s) for query "query".') + }) + + it('answers without citations when the eligibility lookup fails, rather than failing the query', async () => { + mockIsKnowledgeMemberAccessAvailable.mockRejectedValueOnce(new Error('billing unavailable')) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, + { ...CONTEXT, resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry() } + ) + + expect(result).toMatchObject({ success: true }) + expect(result.message).toBe('Found 0 result(s) for query "query".') + }) + it('returns a safe model result for search infrastructure failures', async () => { mockSearchKnowledge.mockRejectedValueOnce(new Error('database unavailable')) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index db952b6a830..50fecea3410 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -20,6 +20,7 @@ import { import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { @@ -64,6 +65,12 @@ const DEFAULT_QUERY_TOP_K = 5 * How the model cites a knowledge result in its reply. The `` tag is * what the chat renders as a link back to the document, so a result without * a source URL is quoted by name instead. + * + * Asked for only where per-member access is on. The chip and the sources strip + * that render the tag arrived with Sim Search, so a workspace without the + * feature must not be told to emit one: the gate belongs here, at the emission, + * because a client that merely declined to render the tag would leave the raw + * `{...}` JSON sitting in the visible reply. */ const KNOWLEDGE_CITATION_INSTRUCTION = 'Cite each result you use inline, right after the sentence it supports, as {"url":"","title":"","siteName":"","connectorType":"","snippet":"","updatedAt":"","author":""} with every value JSON-escaped; leave out any optional field whose value is null or unknown, and omit the tag for a result whose sourceUrl is null and name the document instead.' @@ -436,13 +443,29 @@ export const knowledgeBaseServerTool: BaseServerTool { + logger.warn('Citation eligibility unavailable; answering without citations', { + workspaceId, + error: getErrorMessage(error), + }) + return false + }), + ]) const results = searchResult.results const knowledgeBase = searchResult.knowledgeBases[0] if (!knowledgeBase) @@ -455,9 +478,11 @@ export const knowledgeBaseServerTool: BaseServerTool { expect(getToolDisplayTitle('browser_insert_text', {})).toBe('Inserting text') }) + it('describes semantic browser controls without exposing element ids', () => { + expect(getToolDisplayTitle('browser_find', { query: 'Submit order' })).toBe( + 'Finding "Submit order"' + ) + expect(getToolDisplayTitle('browser_set_checked', { elementId: 42, checked: false })).toBe( + 'Unchecking control' + ) + expect(getToolDisplayTitle('browser_wait_for', { elementId: 42, state: 'visible' })).toBe( + 'Waiting for element to be visible' + ) + expect(getToolDisplayTitle('browser_zoom', { action: 'reset' })).toBe('Resetting page zoom') + expect(getToolCompletedTitle('Unchecking control')).toBe('Unchecked control') + expect(getToolCompletedTitle('Changing page zoom')).toBe('Changed page zoom') + }) + it('names downloads, docs searches, and generated files', () => { expect(getToolDisplayTitle('download_file', { fileName: 'report.csv' })).toBe( 'Downloading report.csv' diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 32a5a029daf..b0e4a892256 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -620,11 +620,14 @@ const TOOL_TITLES: Record = { // Browser agent tools without an argument-aware title. browser_go_back: 'Going back', browser_go_forward: 'Going forward', + browser_reload: 'Reloading page', browser_switch_tab: 'Switching tab', browser_close_tab: 'Closing tab', browser_list_tabs: 'Listing tabs', browser_list_sessions: 'Checking signed-in sites', + browser_list_downloads: 'Checking downloads', browser_snapshot: 'Scanning page', + browser_find: 'Finding page element', browser_read_text: 'Reading page', browser_screenshot: 'Taking screenshot', browser_click: 'Clicking element', @@ -632,7 +635,9 @@ const TOOL_TITLES: Record = { browser_drag: 'Dragging element', browser_select_option: 'Selecting option', + browser_set_checked: 'Updating control', browser_hover: 'Hovering element', + browser_zoom: 'Changing page zoom', // Subagent trigger tools, when surfaced as a tool call. workflow: 'Workflow Agent', run: 'Run Agent', @@ -1033,7 +1038,24 @@ export function getToolDisplayTitle(name: string, args?: Record } case 'browser_wait_for': { const text = stringArg(args, 'text') - return text ? `Waiting for "${text}"` : 'Waiting for page' + const state = stringArg(args, 'state') + const url = stringArg(args, 'urlContains') + if (text) return `Waiting for "${text}"` + if (state) return `Waiting for element to be ${state}` + return url ? `Waiting for ${displayUrl(url)}` : 'Waiting for page' + } + case 'browser_find': { + const query = stringArg(args, 'query') + return query ? `Finding "${truncateMiddle(query, 32)}"` : 'Finding page element' + } + case 'browser_set_checked': { + return args?.checked === false ? 'Unchecking control' : 'Checking control' + } + case 'browser_zoom': { + const action = stringArg(args, 'action') + if (action === 'in') return 'Zooming in' + if (action === 'out') return 'Zooming out' + return action === 'reset' ? 'Resetting page zoom' : 'Changing page zoom' } case 'generate_image': case 'generate_video': @@ -1284,6 +1306,7 @@ const COMPLETED_VERB_REWRITES: Record = { Cancelling: 'Cancelled', Calling: 'Called', Checking: 'Checked', + Changing: 'Changed', Clicking: 'Clicked', Closing: 'Closed', Combining: 'Combined', @@ -1335,9 +1358,11 @@ const COMPLETED_VERB_REWRITES: Record = { Querying: 'Queried', Reading: 'Read', Redeploying: 'Redeployed', + Reloading: 'Reloaded', Removing: 'Removed', Renaming: 'Renamed', Requesting: 'Requested', + Resetting: 'Reset', Resizing: 'Resized', Restoring: 'Restored', Running: 'Ran', @@ -1358,6 +1383,7 @@ const COMPLETED_VERB_REWRITES: Record = { Toggling: 'Toggled', Trimming: 'Trimmed', Typing: 'Typed', + Unchecking: 'Unchecked', Undeploying: 'Undeployed', Unsharing: 'Unshared', Updating: 'Updated', @@ -1366,6 +1392,7 @@ const COMPLETED_VERB_REWRITES: Record = { Viewing: 'Viewed', Waiting: 'Waited', Writing: 'Wrote', + Zooming: 'Zoomed', } /** diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 308e29c9766..00ae37c6c45 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -245,6 +245,20 @@ describe('entitlement-projected block schemas', () => { }) describe('hosted-key VFS metadata', () => { + it('preserves multi-select dropdown behavior in block schemas', () => { + const block = { + type: 'events', + name: 'Events', + subBlocks: [{ id: 'eventTypes', type: 'dropdown', multiSelect: true }], + tools: { access: [] }, + inputs: {}, + outputs: {}, + } as unknown as BlockConfig + + const schema = JSON.parse(serializeBlockSchema(block)) + expect(schema.subBlocks[0].multiSelect).toBe(true) + }) + it('indexes hosted and conditional-hosted operations for every configured service', () => { const metadata = JSON.parse( serializeApiKeyIntegrations( diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index cf555ba62a1..40d2d5a04cd 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -598,6 +598,7 @@ function serializeSubBlock(sb: SubBlockConfig): Record { if (sb.required === true) result.required = true if (sb.defaultValue !== undefined) result.defaultValue = sb.defaultValue if (sb.mode) result.mode = sb.mode + if (sb.multiSelect) result.multiSelect = true if (sb.canonicalParamId) result.canonicalParamId = sb.canonicalParamId if (sb.condition && typeof sb.condition !== 'function') result.condition = sb.condition // Copied, not aliased: these are the registry's own arrays, shared by every diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts index 3b103314a62..f798187fdff 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts @@ -177,6 +177,7 @@ const JOB_TYPE_TO_TASK_ID: Record = { 'workflow-execution': 'workflow-execution', 'schedule-execution': 'schedule-execution', 'webhook-execution': 'webhook-execution', + 'quickbooks-webhook-ingress': 'quickbooks-webhook-ingress', 'resume-execution': 'resume-execution', 'workflow-group-cell': 'workflow-group-cell', 'cleanup-logs': 'cleanup-logs', diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 793ce0d938a..7945e85e576 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -40,6 +40,7 @@ export type JobType = | 'workflow-execution' | 'schedule-execution' | 'webhook-execution' + | 'quickbooks-webhook-ingress' | 'resume-execution' | 'workflow-group-cell' | 'cleanup-logs' diff --git a/apps/sim/lib/core/config/env-capabilities.test.ts b/apps/sim/lib/core/config/env-capabilities.test.ts index 2be795b62a1..7a0e46b0b44 100644 --- a/apps/sim/lib/core/config/env-capabilities.test.ts +++ b/apps/sim/lib/core/config/env-capabilities.test.ts @@ -1,4 +1,5 @@ import integrationsJson from '@sim/deployment-config/integrations.json' +import { CREDENTIAL_CONFIGURED_OAUTH_SERVICE_IDS } from '@sim/deployment-config/service-account-metadata' import { describe, expect, it, vi } from 'vitest' import { ASYNC_JOBS_CAPABILITY, @@ -746,9 +747,11 @@ describe('env capabilities', () => { it('covers every OAuth integration', () => { const integrations = integrationsJson.integrations as readonly Integration[] + const credentialConfiguredServices = new Set(CREDENTIAL_CONFIGURED_OAUTH_SERVICE_IDS) const uncovered = integrations.flatMap((integration) => { if (integration.authType !== 'oauth' || !integration.oauthServiceId) return [] if (resolveOAuthClientCapabilityId(integration.oauthServiceId)) return [] + if (credentialConfiguredServices.has(integration.oauthServiceId)) return [] return [integration.slug] }) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 8a77dc285cd..5970bf44db1 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -293,7 +293,8 @@ export const env = createEnv({ AZURE_ANTHROPIC_API_KEY: z.string().min(1).optional(), // Azure Anthropic API key AZURE_ANTHROPIC_API_VERSION: z.string().min(1).optional(), // Azure Anthropic API version (e.g. 2023-06-01) KB_OPENAI_MODEL_NAME: z.string().optional(), // Azure deployment name serving the configured KB embedding model (used only when AZURE_OPENAI_* credentials are set). - KB_EMBEDDING_MODEL: z.string().optional(), // Embedding model used for all new knowledge bases. Must be one of the supported model ids; defaults to text-embedding-3-small. + KB_EMBEDDING_MODEL: z.string().optional(), // Embedding model used for all new knowledge bases. Must be one of the supported model ids, or `ollama/` for a model on OLLAMA_URL; defaults to text-embedding-3-small. + EMBEDDING_OUTPUT_DIMS: z.coerce.number().int().positive().optional(), // Vector width new knowledge bases are stored at. One of 384, 768, 1024, 1536, 3072, and the configured KB_EMBEDDING_MODEL must emit it; anything else falls back to 1536. WAND_OPENAI_MODEL_NAME: z.string().optional(), // Wand generation OpenAI model name (works with both regular OpenAI and Azure OpenAI) OCR_AZURE_ENDPOINT: z.string().url().optional(), // Azure Mistral OCR service endpoint OCR_AZURE_MODEL_NAME: z.string().optional(), // Azure Mistral OCR model name for document processing diff --git a/apps/sim/lib/core/security/aws-region-partitions.test.ts b/apps/sim/lib/core/security/aws-region-partitions.test.ts new file mode 100644 index 00000000000..93f58489771 --- /dev/null +++ b/apps/sim/lib/core/security/aws-region-partitions.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ok = [ + 'us-east-1', + 'eu-west-2', + 'us-gov-west-1', + 'cn-northwest-1', + 'us-iso-east-1', + 'us-isob-east-1', + 'eu-isoe-west-1', + 'eusc-de-east-1', + 'us-isof-south-1', + 'us-isof-east-1', + 'ap-southeast-4', + 'me-central-1', +] +const bad = [ + '', + 'us-east', + 'US-EAST-1', + 'us-east-1.evil.com', + 'us-east-1/x', + 'us-isog-east-1', + '../us-east-1', + 'us-east-1:80', +] +describe('validateAwsRegion partitions', () => { + for (const r of ok) it(`accepts ${r}`, () => expect(validateAwsRegion(r).isValid).toBe(true)) + for (const r of bad) + it(`rejects ${JSON.stringify(r)}`, () => expect(validateAwsRegion(r).isValid).toBe(false)) +}) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 21f88bef1e1..9aeb1b6eb19 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -630,8 +630,13 @@ export function validateAwsRegion( } } + /** + * Partition prefixes are matched longest-first: `us-isob` and `us-isof` must precede + * `us-iso`, or the shorter alternative wins and the trailing partition letter fails to + * match the following `-`. + */ const awsRegionPattern = - /^(eu-isoe|eusc-[a-z]{2}|us-isob|us-iso|us-gov|af|ap|ca|cn|eu|il|me|mx|sa|us)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/ + /^(eu-isoe|eusc-[a-z]{2}|us-isob|us-isof|us-iso|us-gov|af|ap|ca|cn|eu|il|me|mx|sa|us)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/ if (!awsRegionPattern.test(value)) { logger.warn('Invalid AWS region format', { diff --git a/apps/sim/lib/core/utils/fetch-deadline.ts b/apps/sim/lib/core/utils/fetch-deadline.ts index cd04b09c4e1..006795a427a 100644 --- a/apps/sim/lib/core/utils/fetch-deadline.ts +++ b/apps/sim/lib/core/utils/fetch-deadline.ts @@ -35,8 +35,9 @@ import { Agent, type Dispatcher } from 'undici' * disagree. * * Bun accepts only the boolean/zero form of `timeout`. Measured on Bun 1.3.14 - * against a server that withholds response headers, so the numbers below are - * the real deadline rather than an inferred one: + * against a server that withholds response headers, with the numeric behavior + * rechecked on Bun 1.4.1, so the numbers below are the real deadline rather + * than an inferred one: * * no option -> THREW 300028ms (TimeoutError) <- the 300s default * timeout: false -> RESOLVED 310031ms <- disarmed @@ -56,10 +57,10 @@ import { Agent, type Dispatcher } from 'undici' * dispatcher armed at 200ms -> THREW 1011ms <- honored * dispatcher with 0/0 vs a 310s server -> RESOLVED 310016ms <- disarmed * - * `bun-types@1.3.14` does not declare `timeout` on `BunFetchRequestInit`, and - * the DOM lib does not declare undici's `dispatcher`, even though each runtime - * honors its respective option — the types lag the runtimes, which is why the - * interface below is declared locally rather than imported. + * `bun-types@1.4.1` declares `timeout` on `BunFetchRequestInit`, but the shared + * DOM lib does not declare that Bun extension or undici's `dispatcher`. The + * interface below therefore stays local so this cross-runtime helper does not + * depend on either runtime's ambient types. */ /** diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index 2fc567d9205..03e68587428 100644 --- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts +++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts @@ -27,7 +27,7 @@ describe('credential-bound webhook deactivation', () => { resetDbChainMock() }) - it('deactivates TikTok and Slack webhook registrations when a credential is removed', async () => { + it('deactivates QuickBooks, TikTok, and Slack webhook registrations when a credential is removed', async () => { await clearCredentialRefs('credential-1', 'workspace-1') expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.webhook) @@ -35,6 +35,7 @@ describe('credential-bound webhook deactivation', () => { expect.objectContaining({ isActive: false, updatedAt: expect.any(Date) }) ) expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'tiktok') + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'quickbooks') expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack_app') expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack') }) diff --git a/apps/sim/lib/credentials/api/route-policies.test.ts b/apps/sim/lib/credentials/api/route-policies.test.ts index 2a00ee16d52..bb4473848e9 100644 --- a/apps/sim/lib/credentials/api/route-policies.test.ts +++ b/apps/sim/lib/credentials/api/route-policies.test.ts @@ -4,6 +4,12 @@ import { describe, expect, it } from 'vitest' import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' +import { + OAuthDisconnectConfigurationError, + OAuthDisconnectLimitError, + OAuthDisconnectPartialFailureError, + OAuthProviderRevocationError, +} from '@/lib/credentials/oauth-accounts' function project(error: unknown) { return internalCredentialErrorPolicy.project(error) @@ -34,6 +40,50 @@ describe('internalCredentialErrorPolicy', () => { expect(response?.headers).toBeUndefined() }) + it('renders an OAuth revocation outage as retryable without exposing the provider response', () => { + const response = project( + new OAuthProviderRevocationError('QuickBooks', new Error('upstream token detail')) + ) + + expect(response?.status).toBe(503) + expect(response?.headers).toEqual({ 'Retry-After': '5' }) + expect(response?.body).toEqual({ + error: 'Unable to revoke QuickBooks access. Please try again.', + }) + }) + + it('renders a disconnect size limit as a caller-fixable 400', () => { + const response = project(new OAuthDisconnectLimitError('Too many linked accounts')) + + expect(response?.status).toBe(400) + expect(response?.headers).toBeUndefined() + expect(response?.body).toEqual({ error: 'Too many linked accounts' }) + }) + + it('renders a local QuickBooks configuration problem as a non-retryable 400', () => { + const response = project( + new OAuthDisconnectConfigurationError('Reconnect the QuickBooks account and try again.') + ) + + expect(response?.status).toBe(400) + expect(response?.headers).toBeUndefined() + expect(response?.body).toEqual({ error: 'Reconnect the QuickBooks account and try again.' }) + }) + + it('keeps a later revocation outage retryable after an earlier account was deleted', () => { + const revocation = new OAuthProviderRevocationError( + 'QuickBooks', + new Error('upstream token detail') + ) + const response = project(new OAuthDisconnectPartialFailureError([], revocation)) + + expect(response?.status).toBe(503) + expect(response?.headers).toEqual({ 'Retry-After': '5' }) + expect(response?.body).toEqual({ + error: 'Unable to revoke QuickBooks access. Please try again.', + }) + }) + it('defers anything that is not a provider failure to the base policy', () => { expect(project(new Error('unrelated'))).toBeNull() }) diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts index a7cbfda62c7..d1af3b8ba68 100644 --- a/apps/sim/lib/credentials/api/route-policies.ts +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -10,6 +10,12 @@ import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { CredentialAccessRequiredError } from '@/lib/credentials/application/authorized-credential-use-case' import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' +import { + OAuthDisconnectConfigurationError, + OAuthDisconnectLimitError, + OAuthDisconnectPartialFailureError, + OAuthProviderRevocationError, +} from '@/lib/credentials/oauth-accounts' export const credentialValidationParseOptions = { validationErrorResponse: (error: Parameters[0]) => @@ -26,9 +32,29 @@ export const credentialValidationParseOptions = { export const internalCredentialErrorPolicy = extendInternalErrorPolicy( internalOrchestrationErrorPolicy, (error) => { + const disconnectError = + error instanceof OAuthDisconnectPartialFailureError ? error.cause : error + if (disconnectError instanceof OAuthDisconnectConfigurationError) { + return internalErrorResponse(400, { error: disconnectError.message }) + } + if (error instanceof OAuthDisconnectLimitError) { + return internalErrorResponse(400, { error: error.message }) + } + const revocationError = + disconnectError instanceof OAuthProviderRevocationError ? disconnectError : null + if (revocationError) { + return internalErrorResponse( + 503, + { error: revocationError.message }, + { 'Retry-After': ADMISSION_RETRY_AFTER_SECONDS.toString() } + ) + } if (!(error instanceof CredentialProviderOperationError)) return null if (!error.providerUnavailable) { - return internalErrorResponse(400, { error: error.message, code: error.providerErrorCode }) + return internalErrorResponse(400, { + error: error.message, + code: error.providerErrorCode, + }) } return internalErrorResponse( 503, diff --git a/apps/sim/lib/credentials/application/capability-gate.test.ts b/apps/sim/lib/credentials/application/capability-gate.test.ts index 247a3ae1260..3e3f3b4ce2c 100644 --- a/apps/sim/lib/credentials/application/capability-gate.test.ts +++ b/apps/sim/lib/credentials/application/capability-gate.test.ts @@ -46,6 +46,9 @@ vi.mock('@/lib/credentials/oauth-accounts', () => ({ OAuthDisconnectPartialFailureError: class OAuthDisconnectPartialFailureError extends Error { credentials: unknown[] = [] }, + OAuthDisconnectLimitError: class OAuthDisconnectLimitError extends Error {}, + OAuthDisconnectConfigurationError: class OAuthDisconnectConfigurationError extends Error {}, + OAuthProviderRevocationError: class OAuthProviderRevocationError extends Error {}, })) import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' diff --git a/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts b/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts new file mode 100644 index 00000000000..13b3761f8a9 --- /dev/null +++ b/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts @@ -0,0 +1,188 @@ +/** + * @vitest-environment node + */ +import { account } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + decryptClientConfig: vi.fn(), + exchangeAuthorizationCode: vi.fn(), + fetchConnectionProfile: vi.fn(), + generateId: vi.fn(), + getActiveDraft: vi.fn(), + loadWorkspace: vi.fn(), + processDraft: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: mocks.generateId })) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) +vi.mock('@/lib/credentials/connect-draft', () => ({ + getActiveConnectDraft: mocks.getActiveDraft, +})) +vi.mock('@/lib/credentials/draft-processor', () => ({ + processCredentialDraft: mocks.processDraft, +})) +vi.mock('@/lib/oauth/quickbooks-client-config', () => ({ + decryptQuickBooksOAuthClientConfig: mocks.decryptClientConfig, +})) +vi.mock('@/lib/oauth/quickbooks', () => ({ + exchangeQuickBooksAuthorizationCode: mocks.exchangeAuthorizationCode, + fetchQuickBooksConnectionProfile: mocks.fetchConnectionProfile, +})) +vi.mock('@/lib/oauth/utils', () => ({ + getCanonicalScopesForProvider: () => ['com.intuit.quickbooks.accounting', 'openid'], +})) + +import { completeQuickBooksConnection } from '@/lib/credentials/application/complete-quickbooks-connection' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} + +describe('completeQuickBooksConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.generateId.mockReturnValue('new-account-id') + mocks.resolvePermission.mockResolvedValue('write') + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.getActiveDraft.mockResolvedValue({ + id: 'draft-1', + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'quickbooks', + credentialId: null, + displayName: 'QuickBooks Sandbox', + description: null, + oauthConfig: 'encrypted-client-config', + createdAt: new Date('2026-09-04T18:00:00.000Z'), + expiresAt: new Date('2026-09-04T18:10:00.000Z'), + }) + mocks.resolveTarget.mockResolvedValue({ providerId: 'quickbooks' }) + mocks.decryptClientConfig.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', + }) + mocks.exchangeAuthorizationCode.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + accessTokenExpiresIn: 3600, + refreshTokenExpiresIn: 8_726_400, + scope: '', + }) + mocks.fetchConnectionProfile.mockResolvedValue({ + accountId: + 'quickbooks:v2:bGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE:sandbox:1234567890:dXNlci0x', + appKey: 'bGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE', + realmId: '1234567890', + subject: 'user-1', + environment: 'sandbox', + name: 'Test User', + email: 'user@example.com', + emailVerified: true, + }) + mocks.processDraft.mockResolvedValue(undefined) + }) + + it('uses only the encrypted draft config and provider-validated company identity', async () => { + queueTableRows(account, []) + + await expect( + completeQuickBooksConnection.execute({ + principal, + input: { + draftId: 'draft-1', + code: 'authorization-code', + realmId: '1234567890', + redirectUri: 'https://sim.test/api/auth/oauth2/callback/quickbooks', + }, + }) + ).resolves.toEqual({ + accountId: 'new-account-id', + environment: 'sandbox', + realmId: '1234567890', + }) + + expect(mocks.exchangeAuthorizationCode).toHaveBeenCalledWith({ + code: 'authorization-code', + redirectUri: 'https://sim.test/api/auth/oauth2/callback/quickbooks', + clientConfig: { + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', + }, + signal: undefined, + }) + expect(mocks.fetchConnectionProfile).toHaveBeenCalledWith('access-token', '1234567890', { + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', + }) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: + 'quickbooks:v2:bGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE:sandbox:1234567890:dXNlci0x', + providerId: 'quickbooks', + oauthConfig: 'encrypted-client-config', + scope: 'com.intuit.quickbooks.accounting openid', + }) + ) + expect(mocks.processDraft).toHaveBeenCalledWith({ + draftId: 'draft-1', + userId: 'user-1', + providerId: 'quickbooks', + accountId: 'new-account-id', + }) + }) + + it('fails before token exchange when the draft does not carry encrypted app credentials', async () => { + mocks.getActiveDraft.mockResolvedValueOnce({ + id: 'draft-1', + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'quickbooks', + credentialId: null, + oauthConfig: null, + }) + + await expect( + completeQuickBooksConnection.execute({ + principal, + input: { + draftId: 'draft-1', + code: 'authorization-code', + realmId: '1234567890', + redirectUri: 'https://sim.test/api/auth/oauth2/callback/quickbooks', + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'QuickBooks OAuth client configuration is missing', + }) + expect(mocks.exchangeAuthorizationCode).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts b/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts new file mode 100644 index 00000000000..f433b85aeb7 --- /dev/null +++ b/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts @@ -0,0 +1,129 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { type ConnectDraft, getActiveConnectDraft } from '@/lib/credentials/connect-draft' +import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { + exchangeQuickBooksAuthorizationCode, + fetchQuickBooksConnectionProfile, +} from '@/lib/oauth/quickbooks' +import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +export interface CompleteQuickBooksConnectionInput { + draftId: string + code: string + realmId: string + redirectUri: string + signal?: AbortSignal +} + +interface CompleteQuickBooksConnectionContext extends ActiveWorkspaceApplicationContext { + draft: ConnectDraft +} + +export const completeQuickBooksConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.completeConnection, + resolveContext: async ({ + principal, + input, + }: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: CompleteQuickBooksConnectionInput + }): Promise => { + const draft = await getActiveConnectDraft(input.draftId, principal.userId) + if (!draft) { + throw new OrchestrationError('not_found', 'QuickBooks connection link is invalid or expired') + } + const workspace = await loadActiveWorkspaceApplicationContext(draft.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return { ...workspace, draft } + }, + authorizationOptions: {}, + execute: async ({ principal, input, context }) => { + if (context.draft.providerId !== 'quickbooks') { + throw new OrchestrationError('validation', 'Credential draft is not for QuickBooks') + } + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: context.draft.credentialId ? undefined : context.draft.providerId, + credentialId: context.draft.credentialId ?? undefined, + }) + if (target.providerId !== 'quickbooks') { + throw new OrchestrationError('conflict', 'OAuth connection provider no longer matches') + } + + const oauthConfig = context.draft.oauthConfig + if (!oauthConfig) { + throw new OrchestrationError('validation', 'QuickBooks OAuth client configuration is missing') + } + const clientConfig = await decryptQuickBooksOAuthClientConfig(oauthConfig) + const tokens = await exchangeQuickBooksAuthorizationCode({ + code: input.code, + redirectUri: input.redirectUri, + clientConfig, + signal: input.signal, + }) + const profile = await fetchQuickBooksConnectionProfile( + tokens.accessToken, + input.realmId, + clientConfig + ) + const now = new Date() + const accessTokenExpiresAt = new Date(now.getTime() + tokens.accessTokenExpiresIn * 1000) + const refreshTokenExpiresAt = new Date(now.getTime() + tokens.refreshTokenExpiresIn * 1000) + const [existing] = await db + .select({ id: account.id }) + .from(account) + .where( + and( + eq(account.userId, principal.userId), + eq(account.providerId, 'quickbooks'), + eq(account.accountId, profile.accountId) + ) + ) + .limit(1) + + const accountId = existing?.id ?? generateId() + const accountValues = { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + idToken: tokens.idToken ?? null, + accessTokenExpiresAt, + refreshTokenExpiresAt, + scope: tokens.scope || getCanonicalScopesForProvider('quickbooks').join(' '), + oauthConfig, + updatedAt: now, + } + if (existing) { + await db.update(account).set(accountValues).where(eq(account.id, existing.id)) + } else { + await db.insert(account).values({ + id: accountId, + accountId: profile.accountId, + providerId: 'quickbooks', + userId: principal.userId, + ...accountValues, + createdAt: now, + }) + } + + await processCredentialDraft({ + draftId: context.draft.id, + userId: principal.userId, + providerId: 'quickbooks', + accountId, + }) + return { accountId, environment: clientConfig.environment, realmId: profile.realmId } + }, +}) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts index 1f095959a97..d9ff3f53105 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.test.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -129,4 +129,47 @@ describe('createCredentialConnection', () => { displayName: 'Existing Gmail', }) }) + + it('requires caller-managed app credentials after resolving a QuickBooks reconnect', async () => { + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'quickbooks' }, + providerId: 'quickbooks', + credentialId: 'credential-1', + displayName: 'Accounting', + }) + + await expect( + createCredentialConnection.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', credentialId: 'credential-1' }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'QuickBooks OAuth client configuration is required', + }) + expect(mocks.createDraft).not.toHaveBeenCalled() + }) + + it('rejects QuickBooks app credentials after resolving another provider', async () => { + await expect( + createCredentialConnection.execute({ + principal: personalPrincipal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + oauthClientConfig: { + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', + }, + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'OAuth client configuration is only supported for QuickBooks', + }) + expect(mocks.createDraft).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts index df4e423add7..e11aae62ebb 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -6,10 +6,12 @@ import { credentialDelegationPolicy } from '@/lib/credentials/application/author import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' import { credentialOperations } from '@/lib/credentials/application/operations' import { createConnectDraft } from '@/lib/credentials/connect-draft' +import type { QuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export type CreateCredentialConnectionInput = { workspaceId: string + oauthClientConfig?: QuickBooksOAuthClientConfig } & ( | { providerId: string; displayName?: string; credentialId?: never } | { @@ -45,6 +47,18 @@ export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ credentialId: input.credentialId, assertedProviderId: 'assertedProviderId' in input ? input.assertedProviderId : undefined, }) + if (target.providerId === 'quickbooks' && !input.oauthClientConfig) { + throw new OrchestrationError( + 'validation', + 'QuickBooks OAuth client configuration is required' + ) + } + if (target.providerId !== 'quickbooks' && input.oauthClientConfig) { + throw new OrchestrationError( + 'validation', + 'OAuth client configuration is only supported for QuickBooks' + ) + } const displayName = input.providerId ? input.displayName : target.displayName const draft = await createConnectDraft({ @@ -53,6 +67,7 @@ export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ providerId: target.providerId, credentialId: target.credentialId, displayName, + oauthClientConfig: input.oauthClientConfig, }) const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) authorizationUrl.searchParams.set('draftId', draft.id) diff --git a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts index 0e6f6f77a7a..edec8f3d8a9 100644 --- a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts +++ b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts @@ -121,7 +121,7 @@ describe('discoverManagedMcpToolsUseCase', () => { expect(mocks.discoverTools).toHaveBeenCalledWith( context.mcpServerId, context.workspaceId, - {}, + { credentialId: context.credentialId, loadProvider: expect.any(Function) }, signal, { requireComplete: true } ) diff --git a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts index 0e8ba546f31..67ac74c7c04 100644 --- a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts +++ b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts @@ -10,7 +10,6 @@ import { saveManagedMcpToolSnapshot, } from '@/lib/credentials/managed-mcp' import { loadManagedMcpAuthProvider } from '@/lib/mcp/application/managed-auth-provider' -import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth' import { mcpService } from '@/lib/mcp/service' export interface DiscoverManagedMcpToolsInput { @@ -35,14 +34,15 @@ export const discoverManagedMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ async execute({ input, context }) { input.signal?.throwIfAborted() const runtime = await loadManagedMcpRuntimeCredential(context.credentialId, context.workspaceId) - const tools = await withMcpOauthRefreshLock(runtime.credentialId, async () => - mcpService.discoverManagedMcpTools( - runtime.mcpServerId, - runtime.workspaceId, - await loadManagedMcpAuthProvider(runtime.credentialId, runtime.workspaceId), - input.signal, - { requireComplete: true } - ) + const tools = await mcpService.discoverManagedMcpTools( + runtime.mcpServerId, + runtime.workspaceId, + { + credentialId: runtime.credentialId, + loadProvider: () => loadManagedMcpAuthProvider(runtime.credentialId, runtime.workspaceId), + }, + input.signal, + { requireComplete: true } ) await saveManagedMcpToolSnapshot( runtime.credentialId, diff --git a/apps/sim/lib/credentials/application/oauth-accounts.test.ts b/apps/sim/lib/credentials/application/oauth-accounts.test.ts index bf77fae5662..6ad587a4112 100644 --- a/apps/sim/lib/credentials/application/oauth-accounts.test.ts +++ b/apps/sim/lib/credentials/application/oauth-accounts.test.ts @@ -2,21 +2,47 @@ * @vitest-environment node */ import { account, credential } from '@sim/db/schema' -import { auditMock, auditMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { + auditMock, + auditMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ deleteCredential: vi.fn(), capture: vi.fn(), + revokeQuickBooksToken: vi.fn(), + decryptClientConfig: vi.fn(), })) vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/credentials/orchestration', () => ({ deleteCredentialRecord: mocks.deleteCredential, })) +vi.mock('@/lib/oauth/quickbooks', () => ({ + revokeQuickBooksToken: mocks.revokeQuickBooksToken, + QuickBooksTokenRevocationError: class QuickBooksTokenRevocationError extends Error { + readonly retryable: boolean + + constructor(readonly status: number) { + super(`QuickBooks token revocation failed with HTTP ${status}`) + this.name = 'QuickBooksTokenRevocationError' + this.retryable = status === 429 || status >= 500 + } + }, +})) +vi.mock('@/lib/oauth/quickbooks-client-config', () => ({ + decryptQuickBooksOAuthClientConfig: mocks.decryptClientConfig, + QuickBooksOAuthClientConfigurationError: class QuickBooksOAuthClientConfigurationError extends Error {}, +})) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) import { disconnectOAuthUseCase } from '@/lib/credentials/application/oauth-accounts' +import { QuickBooksTokenRevocationError } from '@/lib/oauth/quickbooks' +import { QuickBooksOAuthClientConfigurationError } from '@/lib/oauth/quickbooks-client-config' const firstCredential = { id: 'credential-1', @@ -38,6 +64,14 @@ describe('OAuth account application operations', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + mocks.revokeQuickBooksToken.mockResolvedValue(undefined) + mocks.decryptClientConfig.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', + }) + dbChainMockFns.returning.mockResolvedValue([{ id: 'account-1' }]) }) it('audits and captures committed deletions before rethrowing a later failure', async () => { @@ -55,7 +89,11 @@ describe('OAuth account application operations', () => { await expect( disconnectOAuthUseCase.execute({ - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, input: { provider: 'google' }, }) ).rejects.toMatchObject({ @@ -81,4 +119,429 @@ describe('OAuth account application operations', () => { { groups: { workspace: 'workspace-1' } } ) }) + + it('revokes the QuickBooks refresh token before deleting the local account', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: 'access-token', + refreshToken: 'refresh-token', + oauthConfig: 'encrypted-config', + }, + ]) + queueTableRows(credential, []) + + await disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + + expect(mocks.revokeQuickBooksToken).toHaveBeenCalledWith( + 'refresh-token', + expect.objectContaining({ + clientId: 'client-id', + clientSecret: 'client-secret', + }), + expect.any(AbortSignal) + ) + expect(dbChainMockFns.delete).toHaveBeenCalled() + expect(mocks.revokeQuickBooksToken.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.delete.mock.invocationCallOrder[0] + ) + }) + + it('falls back to the QuickBooks access token when no refresh token is stored', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: 'access-token', + refreshToken: null, + oauthConfig: 'encrypted-config', + }, + ]) + queueTableRows(credential, []) + + await disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + + expect(mocks.revokeQuickBooksToken).toHaveBeenCalledWith( + 'access-token', + expect.objectContaining({ + clientId: 'client-id', + clientSecret: 'client-secret', + }), + expect.any(AbortSignal) + ) + }) + + it('keeps QuickBooks credentials locally when Intuit revocation fails', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: 'access-token', + refreshToken: 'refresh-token', + oauthConfig: 'encrypted-config', + }, + ]) + mocks.revokeQuickBooksToken.mockRejectedValueOnce(new Error('Intuit unavailable')) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + ).rejects.toMatchObject({ + name: 'OAuthProviderRevocationError', + message: 'Unable to revoke QuickBooks access. Please try again.', + }) + + expect(mocks.deleteCredential).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('reports missing QuickBooks app configuration as a non-retryable configuration error', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: 'access-token', + refreshToken: 'refresh-token', + oauthConfig: null, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ]) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + ).rejects.toMatchObject({ name: 'OAuthDisconnectConfigurationError' }) + expect(mocks.revokeQuickBooksToken).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('reports malformed decrypted QuickBooks app configuration as a configuration error', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: 'access-token', + refreshToken: 'refresh-token', + oauthConfig: 'invalid-config', + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ]) + mocks.decryptClientConfig.mockRejectedValueOnce( + new QuickBooksOAuthClientConfigurationError('invalid configuration') + ) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + ).rejects.toMatchObject({ name: 'OAuthDisconnectConfigurationError' }) + }) + + it('preserves unexpected QuickBooks decryption infrastructure failures', async () => { + const deploymentError = new Error('encryption key is unavailable') + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: 'access-token', + refreshToken: 'refresh-token', + oauthConfig: 'encrypted-config', + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ]) + mocks.decryptClientConfig.mockRejectedValueOnce(deploymentError) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + ).rejects.toBe(deploymentError) + }) + + it('reports a permanent Intuit revocation rejection as a configuration error', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: 'access-token', + refreshToken: 'refresh-token', + oauthConfig: 'encrypted-config', + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ]) + mocks.revokeQuickBooksToken.mockRejectedValueOnce(new QuickBooksTokenRevocationError(400)) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + ).rejects.toMatchObject({ name: 'OAuthDisconnectConfigurationError' }) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('skips local cleanup when the QuickBooks account changed during revocation', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: 'access-token', + refreshToken: 'refresh-token', + oauthConfig: 'encrypted-config', + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ]) + queueTableRows(credential, [ + { ...firstCredential, providerId: 'quickbooks', accountId: 'account-1' }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + ).resolves.toMatchObject({ success: true, credentials: [] }) + + expect(mocks.deleteCredential).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('claims a tokenless QuickBooks account before deleting local credentials', async () => { + const linkedCredential = { + ...firstCredential, + providerId: 'quickbooks', + accountId: 'account-1', + } + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: null, + refreshToken: null, + oauthConfig: 'encrypted-config', + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ]) + queueTableRows(credential, [linkedCredential]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'account-1' }]) + mocks.deleteCredential.mockResolvedValueOnce(true) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + ).resolves.toMatchObject({ success: true, credentials: [linkedCredential] }) + + expect(mocks.revokeQuickBooksToken).not.toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalledWith(account) + expect(mocks.deleteCredential).toHaveBeenCalledWith({ + credential: linkedCredential, + reason: 'oauth_disconnect', + }) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(account) + }) + + it('skips tokenless QuickBooks cleanup when a reconnect wins the account claim', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: null, + refreshToken: null, + oauthConfig: 'encrypted-config', + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ]) + queueTableRows(credential, [ + { ...firstCredential, providerId: 'quickbooks', accountId: 'account-1' }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + ).resolves.toMatchObject({ success: true, credentials: [] }) + + expect(mocks.revokeQuickBooksToken).not.toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalledWith(account) + expect(mocks.deleteCredential).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('records a successful Intuit revocation so a local-delete retry does not revoke twice', async () => { + const linkedCredential = { + ...firstCredential, + providerId: 'quickbooks', + accountId: 'account-1', + } + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: 'access-token', + refreshToken: 'refresh-token', + oauthConfig: 'encrypted-config', + }, + ]) + queueTableRows(credential, [linkedCredential]) + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: null, + refreshToken: null, + oauthConfig: 'encrypted-config', + }, + ]) + queueTableRows(credential, [linkedCredential]) + mocks.deleteCredential + .mockRejectedValueOnce(new Error('Local delete failed')) + .mockResolvedValueOnce(true) + + const input = { + principal: { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', + }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + } + await expect(disconnectOAuthUseCase.execute(input)).rejects.toThrow('Local delete failed') + await expect(disconnectOAuthUseCase.execute(input)).resolves.toMatchObject({ + success: true, + }) + + expect(mocks.revokeQuickBooksToken).toHaveBeenCalledOnce() + expect(dbChainMockFns.update).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: null, + refreshToken: null, + idToken: null, + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + }) + ) + }) + + it('validates every linked credential before revoking QuickBooks access', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: 'access-token', + refreshToken: 'refresh-token', + oauthConfig: 'encrypted-config', + }, + ]) + queueTableRows(credential, [ + { + ...firstCredential, + type: 'service_account', + providerId: 'quickbooks', + accountId: 'account-1', + }, + ]) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + ).rejects.toThrow('owns a non-OAuth credential') + + expect(mocks.revokeQuickBooksToken).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('classifies oversized disconnects without treating them as provider outages', async () => { + queueTableRows( + account, + Array.from({ length: 101 }, (_, index) => ({ + id: `account-${index}`, + providerId: 'quickbooks', + })) + ) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, + input: { provider: 'quickbooks' }, + }) + ).rejects.toMatchObject({ + name: 'OAuthDisconnectLimitError', + message: 'Too many linked accounts to disconnect in one request', + }) + + expect(mocks.revokeQuickBooksToken).not.toHaveBeenCalled() + }) + + it('removes a tokenless QuickBooks account without calling Intuit', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'quickbooks', + accessToken: null, + refreshToken: null, + }, + ]) + queueTableRows(credential, []) + + await disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'quickbooks', accountId: 'account-1' }, + }) + + expect(mocks.revokeQuickBooksToken).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() + }) + + it('does not revoke tokens for non-QuickBooks providers', async () => { + queueTableRows(account, [ + { + id: 'account-1', + providerId: 'google-email', + accessToken: 'access-token', + refreshToken: 'refresh-token', + }, + ]) + queueTableRows(credential, []) + + await disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'google', accountId: 'account-1' }, + }) + + expect(mocks.revokeQuickBooksToken).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index df3e52834c6..43f773bf7ce 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -171,6 +171,13 @@ export const credentialOperations = { capability: 'integrations.manage', principalKinds: ['session'], }), + completeConnection: defineWorkspaceOperation({ + id: 'credentials.connections.complete', + minimumRole: 'write', + workspaceApiKey: 'deny', + capability: 'integrations.manage', + principalKinds: ['session'], + }), useManagedOAuth: defineWorkspaceOperation({ id: 'credentials.managed_oauth.use', minimumRole: 'read', diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index 73fc59914fc..f76c2c097ef 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -140,6 +140,7 @@ describe('listCredentialProviderCatalog', () => { } } if (serviceId === 'trello') return {} + if (serviceId === 'quickbooks') return {} return null }) }) @@ -156,6 +157,7 @@ describe('listCredentialProviderCatalog', () => { providerFamily: 'salesforce', available: true, supportsReconnect: true, + fields: [], authorizationOptions: [ { providerId: 'salesforce', label: 'Production' }, { providerId: 'salesforce-sandbox', label: 'Sandbox' }, @@ -169,6 +171,7 @@ describe('listCredentialProviderCatalog', () => { providerFamily: 'trello', available: false, supportsReconnect: true, + fields: [], authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], }, { @@ -220,6 +223,70 @@ describe('listCredentialProviderCatalog', () => { ) }) + it('projects QuickBooks app credentials as write-only OAuth setup fields', async () => { + mocks.getAllOAuthServices.mockReturnValue([ + { + serviceId: 'quickbooks', + providerId: 'quickbooks', + name: 'QuickBooks', + description: 'Connect QuickBooks.', + baseProvider: 'quickbooks', + authType: 'oauth', + clientConfiguration: { + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Enter client ID', + secret: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Enter client secret', + secret: true, + }, + { + id: 'environment', + label: 'Environment', + placeholder: 'Select environment', + secret: false, + options: [ + { label: 'Sandbox', value: 'sandbox' }, + { label: 'Production', value: 'production' }, + ], + }, + ], + }, + }, + ]) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: () => true, + isCredentialVisible: () => false, + }) + + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + + expect(catalog[0]).toMatchObject({ + type: 'oauth', + serviceId: 'quickbooks', + available: true, + fields: [ + { id: 'clientId', required: true, secret: false }, + { id: 'clientSecret', required: true, secret: true }, + { + id: 'environment', + required: true, + secret: false, + options: [ + { label: 'Sandbox', value: 'sandbox' }, + { label: 'Production', value: 'production' }, + ], + }, + ], + }) + }) + it('fails fast when a multi-server provider lacks complete labels', async () => { mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { if (serviceId === 'salesforce') { diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index b399663cf4a..371aab2edf9 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -54,6 +54,7 @@ export interface OAuthCredentialProviderCatalogEntry extends CredentialProviderC type: 'oauth' supportsReconnect: boolean authorizationOptions: CredentialProviderAuthorizationOption[] + fields: CredentialProviderField[] } export interface ServiceAccountCredentialProviderCatalogEntry @@ -259,6 +260,16 @@ export async function listCredentialProviderCatalog( available: visibility.isOAuthServiceVisible(service), supportsReconnect: true, authorizationOptions, + fields: (service.clientConfiguration?.fields ?? []).map((field) => ({ + id: field.id, + label: field.label, + placeholder: field.placeholder, + required: true, + secret: field.secret, + multiline: false, + ...(field.options ? { options: [...field.options] } : {}), + ...(field.hint ? { hint: field.hint } : {}), + })), } }) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts index cee499eacfb..bc3051dcf2e 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -4,6 +4,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' import { credentialOperations } from '@/lib/credentials/application/operations' import { createConnectDraft } from '@/lib/credentials/connect-draft' +import type { QuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface SaveCredentialDraftInput { @@ -12,6 +13,7 @@ export interface SaveCredentialDraftInput { displayName: string description?: string credentialId?: string + oauthClientConfig?: QuickBooksOAuthClientConfig } interface SaveCredentialDraftContext { @@ -60,6 +62,7 @@ export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ displayName: input.displayName, description: input.description, credentialId: input.credentialId, + oauthClientConfig: input.oauthClientConfig, }) return { success: true as const, draftId: draft.id } }, diff --git a/apps/sim/lib/credentials/client-state.ts b/apps/sim/lib/credentials/client-state.ts index c3963cc59e9..d95a84c2a4f 100644 --- a/apps/sim/lib/credentials/client-state.ts +++ b/apps/sim/lib/credentials/client-state.ts @@ -92,10 +92,13 @@ export function readOAuthReturnContext(): OAuthReturnContext | null { return parseJson(window.sessionStorage.getItem(OAUTH_RETURN_CONTEXT_KEY)) } +export function clearOAuthReturnContext(): void { + if (typeof window === 'undefined') return + window.sessionStorage.removeItem(OAUTH_RETURN_CONTEXT_KEY) +} + export function consumeOAuthReturnContext(): OAuthReturnContext | null { const ctx = readOAuthReturnContext() - if (ctx) { - window.sessionStorage.removeItem(OAUTH_RETURN_CONTEXT_KEY) - } + if (ctx) clearOAuthReturnContext() return ctx } diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts index 0ce2dccee72..cd4f7b234a7 100644 --- a/apps/sim/lib/credentials/connect-draft.test.ts +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -4,11 +4,15 @@ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGenerateId } = vi.hoisted(() => ({ +const { mockEncryptQuickBooksOAuthClientConfig, mockGenerateId } = vi.hoisted(() => ({ + mockEncryptQuickBooksOAuthClientConfig: vi.fn(), mockGenerateId: vi.fn(), })) vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) +vi.mock('@/lib/oauth/quickbooks-client-config', () => ({ + encryptQuickBooksOAuthClientConfig: mockEncryptQuickBooksOAuthClientConfig, +})) import { createConnectDraft } from '@/lib/credentials/connect-draft' @@ -17,6 +21,7 @@ describe('createConnectDraft', () => { vi.clearAllMocks() resetDbChainMock() mockGenerateId.mockReturnValue('new-draft-id') + mockEncryptQuickBooksOAuthClientConfig.mockResolvedValue('encrypted-client-config') }) it('supersedes an active connection intent with a new exact draft id', async () => { @@ -85,4 +90,63 @@ describe('createConnectDraft', () => { }) ).rejects.toThrow('Failed to create OAuth credential draft') }) + + it('encrypts QuickBooks app credentials before storing the draft', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'new-draft-id', expiresAt }]) + const oauthClientConfig = { + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox' as const, + webhookVerifierToken: 'verifier-token', + } + + await createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'quickbooks', + displayName: 'QuickBooks Sandbox', + oauthClientConfig, + }) + + expect(mockEncryptQuickBooksOAuthClientConfig).toHaveBeenCalledWith(oauthClientConfig) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + oauthConfig: 'encrypted-client-config', + }) + ) + expect(dbChainMockFns.values).not.toHaveBeenCalledWith( + expect.objectContaining({ + oauthConfig: expect.stringContaining('client-secret'), + }) + ) + }) + + it('requires app credentials for QuickBooks and rejects them for other providers', async () => { + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'quickbooks', + displayName: 'QuickBooks', + }) + ).rejects.toThrow( + 'QuickBooks requires an OAuth client ID, client secret, environment, and webhook verifier token' + ) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Gmail', + oauthClientConfig: { + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'production', + webhookVerifierToken: 'verifier-token', + }, + }) + ).rejects.toThrow('OAuth client configuration is not supported for provider google-email') + }) }) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 97f0f6ef744..a261bf3cdfb 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -5,9 +5,14 @@ import { generateId } from '@sim/utils/id' import { and, eq, gt, lt } from 'drizzle-orm' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' +import { + encryptQuickBooksOAuthClientConfig, + type QuickBooksOAuthClientConfig, +} from '@/lib/oauth/quickbooks-client-config' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') +const MAX_CONNECT_DRAFT_CREDENTIAL_NAMES = 10_000 export type ConnectDraft = typeof pendingCredentialDraft.$inferSelect @@ -29,9 +34,22 @@ export async function createConnectDraft(params: { /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string description?: string + oauthClientConfig?: QuickBooksOAuthClientConfig }): Promise { const { userId, workspaceId, providerId, credentialId } = params + if (providerId === 'quickbooks' && !params.oauthClientConfig) { + throw new Error( + 'QuickBooks requires an OAuth client ID, client secret, environment, and webhook verifier token' + ) + } + if (providerId !== 'quickbooks' && params.oauthClientConfig) { + throw new Error(`OAuth client configuration is not supported for provider ${providerId}`) + } + const oauthConfig = params.oauthClientConfig + ? await encryptQuickBooksOAuthClientConfig(params.oauthClientConfig) + : null + let displayName = params.displayName if (!displayName) { // Matches through the canonical predicate so an alternate authorization @@ -54,6 +72,10 @@ export async function createConnectDraft(params: { .select({ displayName: credential.displayName }) .from(credential) .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) + .limit(MAX_CONNECT_DRAFT_CREDENTIAL_NAMES + 1) + if (rows.length > MAX_CONNECT_DRAFT_CREDENTIAL_NAMES) { + throw new Error('Workspace has too many OAuth credentials to generate a default name') + } const takenNames = new Set(rows.map((credentialRow) => credentialRow.displayName.toLowerCase())) displayName = defaultCredentialDisplayName(userName, serviceName, takenNames) @@ -77,6 +99,7 @@ export async function createConnectDraft(params: { displayName, description: params.description?.trim() || null, credentialId: credentialId ?? null, + oauthConfig, expiresAt, createdAt: now, }) @@ -96,6 +119,7 @@ export async function createConnectDraft(params: { displayName, description: params.description?.trim() || null, credentialId: credentialId ?? null, + oauthConfig, expiresAt, createdAt: now, }, diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 3fca53bcd10..ab7c7e74ce8 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -165,9 +165,9 @@ export async function clearCredentialRefs( /** * Deactivates app-level trigger webhooks bound to this credential so inbound * events stop routing once the account is disconnected. Native Slack and - * TikTok rows reference it via `providerConfig.credentialId`; custom-bot Slack - * rows use `routingKey` = the bot credential id. Neither is a foreign key, so - * neither is covered by CASCADE. + * QuickBooks and TikTok rows reference it via `providerConfig.credentialId`; + * custom-bot Slack rows use `routingKey` = the bot credential id. None is a + * foreign key, so none is covered by CASCADE. */ async function deactivateCredentialBoundWebhooks(credentialId: string): Promise { await db @@ -185,6 +185,10 @@ async function deactivateCredentialBoundWebhooks(credentialId: string): Promise< eq(schema.webhook.provider, 'tiktok'), sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}` ), + and( + eq(schema.webhook.provider, 'quickbooks'), + sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}` + ), and(eq(schema.webhook.provider, 'slack'), eq(schema.webhook.routingKey, credentialId)) ) ) diff --git a/apps/sim/lib/credentials/oauth-accounts.ts b/apps/sim/lib/credentials/oauth-accounts.ts index 705d926d5bc..bc60a9d79fd 100644 --- a/apps/sim/lib/credentials/oauth-accounts.ts +++ b/apps/sim/lib/credentials/oauth-accounts.ts @@ -8,9 +8,17 @@ import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' import { deleteCredentialRecord } from '@/lib/credentials/orchestration' import type { OAuthProvider } from '@/lib/oauth' import { parseProvider } from '@/lib/oauth' +import { QuickBooksTokenRevocationError, revokeQuickBooksToken } from '@/lib/oauth/quickbooks' +import { + decryptQuickBooksOAuthClientConfig, + QuickBooksOAuthClientConfigurationError, +} from '@/lib/oauth/quickbooks-client-config' import { providerIdsForService } from '@/lib/oauth/utils' const logger = createLogger('CredentialOAuthAccounts') +const MAX_DISCONNECT_ACCOUNTS = 100 +const MAX_DISCONNECT_CREDENTIALS = 1000 +const QUICKBOOKS_DISCONNECT_TIMEOUT_MS = 30_000 interface GoogleIdToken { email?: string @@ -35,7 +43,10 @@ export async function listOAuthConnectionsForUser(userId: string): Promise(accountRow.idToken) displayName = decoded.email || decoded.name || '' } catch (error) { - logger.warn('Failed to decode OAuth account ID token', { accountId: accountRow.id, error }) + logger.warn('Failed to decode OAuth account ID token', { + accountId: accountRow.id, + error, + }) } } if (!displayName && baseProvider === 'github') { @@ -111,6 +122,32 @@ export class OAuthDisconnectPartialFailureError extends Error { } } +export class OAuthProviderRevocationError extends Error { + constructor( + readonly providerId: string, + cause: unknown + ) { + super(`Unable to revoke ${providerId} access. Please try again.`, { + cause: toError(cause), + }) + this.name = 'OAuthProviderRevocationError' + } +} + +export class OAuthDisconnectConfigurationError extends Error { + constructor(message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause: toError(cause) }) + this.name = 'OAuthDisconnectConfigurationError' + } +} + +export class OAuthDisconnectLimitError extends Error { + constructor(message: string) { + super(message) + this.name = 'OAuthDisconnectLimitError' + } +} + export async function disconnectOAuthAccounts(params: DisconnectOAuthAccountsParams) { const accountFilter = params.accountId ? and(eq(account.userId, params.userId), eq(account.id, params.accountId)) @@ -123,7 +160,21 @@ export async function disconnectOAuthAccounts(params: DisconnectOAuthAccountsPar like(account.providerId, `${params.provider}-%`) ) ) - const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) + const targetAccounts = await db + .select({ + id: account.id, + providerId: account.providerId, + accessToken: account.accessToken, + refreshToken: account.refreshToken, + oauthConfig: account.oauthConfig, + updatedAt: account.updatedAt, + }) + .from(account) + .where(accountFilter) + .limit(MAX_DISCONNECT_ACCOUNTS + 1) + if (targetAccounts.length > MAX_DISCONNECT_ACCOUNTS) { + throw new OAuthDisconnectLimitError('Too many linked accounts to disconnect in one request') + } const targetAccountIds = targetAccounts.map((row) => row.id) if (targetAccountIds.length === 0) return { credentials: [] } @@ -131,19 +182,100 @@ export async function disconnectOAuthAccounts(params: DisconnectOAuthAccountsPar .select() .from(credential) .where(inArray(credential.accountId, targetAccountIds)) + .limit(MAX_DISCONNECT_CREDENTIALS + 1) + if (credentialRows.length > MAX_DISCONNECT_CREDENTIALS) { + throw new OAuthDisconnectLimitError('Too many linked credentials to disconnect in one request') + } + for (const credentialRow of credentialRows) { + if (credentialRow.type !== 'oauth') { + throw new Error(`OAuth account ${credentialRow.accountId} owns a non-OAuth credential`) + } + } + + const credentialsByAccount = new Map() + for (const credentialRow of credentialRows) { + if (!credentialRow.accountId) continue + const rows = credentialsByAccount.get(credentialRow.accountId) ?? [] + rows.push(credentialRow) + credentialsByAccount.set(credentialRow.accountId, rows) + } + + const quickBooksDisconnectSignal = AbortSignal.timeout(QUICKBOOKS_DISCONNECT_TIMEOUT_MS) const deletedCredentials: typeof credentialRows = [] try { - for (const credentialRow of credentialRows) { - if (credentialRow.type !== 'oauth') { - throw new Error(`OAuth account ${credentialRow.accountId} owns a non-OAuth credential`) + for (const targetAccount of targetAccounts) { + let expectedAccountVersion: Date | undefined + if (targetAccount.providerId === 'quickbooks') { + const token = targetAccount.refreshToken?.trim() || targetAccount.accessToken?.trim() + if (token) { + if (!targetAccount.oauthConfig) { + throw new OAuthDisconnectConfigurationError( + 'QuickBooks OAuth client configuration is missing. Reconnect the account and try again.' + ) + } + let clientConfig + try { + clientConfig = await decryptQuickBooksOAuthClientConfig(targetAccount.oauthConfig) + } catch (error) { + if (!(error instanceof QuickBooksOAuthClientConfigurationError)) throw error + throw new OAuthDisconnectConfigurationError( + 'QuickBooks OAuth client configuration is invalid. Reconnect the account and try again.', + error + ) + } + try { + await revokeQuickBooksToken(token, clientConfig, quickBooksDisconnectSignal) + } catch (error) { + if (error instanceof QuickBooksTokenRevocationError && !error.retryable) { + throw new OAuthDisconnectConfigurationError( + 'Intuit rejected the QuickBooks revocation request. Reconnect the account and try again.', + error + ) + } + throw new OAuthProviderRevocationError('QuickBooks', error) + } + } + + const cleanupVersion = new Date() + const claimedAccounts = await db + .update(account) + .set({ + accessToken: null, + refreshToken: null, + idToken: null, + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + updatedAt: cleanupVersion, + }) + .where( + and( + eq(account.id, targetAccount.id), + eq(account.userId, params.userId), + eq(account.updatedAt, targetAccount.updatedAt) + ) + ) + .returning({ id: account.id }) + if (claimedAccounts.length === 0) continue + expectedAccountVersion = cleanupVersion } - const deleted = await deleteCredentialRecord({ - credential: credentialRow, - reason: 'oauth_disconnect', - }) - if (deleted) deletedCredentials.push(credentialRow) + + for (const credentialRow of credentialsByAccount.get(targetAccount.id) ?? []) { + const deleted = await deleteCredentialRecord({ + credential: credentialRow, + reason: 'oauth_disconnect', + }) + if (deleted) deletedCredentials.push(credentialRow) + } + await db + .delete(account) + .where( + and( + eq(account.id, targetAccount.id), + eq(account.userId, params.userId), + expectedAccountVersion ? eq(account.updatedAt, expectedAccountVersion) : undefined + ) + ) } - await db.delete(account).where(inArray(account.id, targetAccountIds)) } catch (error) { if (deletedCredentials.length === 0) throw error throw new OAuthDisconnectPartialFailureError(deletedCredentials, error) diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 4f31f79c83b..49d7a88c32f 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -75,25 +75,32 @@ export function prefersInPlaceNavigation(): boolean { * reads availability synchronously while the shell only answers over async * IPC. An unread value uses the shell default (enabled). * - * The cache is authoritative at call time, which is what tool execution and - * capability reporting need. React trees that read it in a memo settle on the - * next mount — flipping a switch happens on a settings route, so the chat view - * has unmounted by then anyway. + * Synchronous callers read the cache at call time. React consumers subscribe + * to the same snapshot so asynchronous initialization and settings changes + * update mounted UI without keeping a second copy of the preferences. */ let devicePreferences: DesktopPreferences | null = null let devicePreferencesLoad: Promise | null = null +const devicePreferencesListeners = new Set<() => void>() function loadDevicePreferences(): Promise { devicePreferencesLoad ??= getDesktopBridge() ?.settings.getPreferences() - .then((preferences) => { - devicePreferences = preferences - }) + .then(setDesktopPreferencesSnapshot) .catch(() => {}) ?? Promise.resolve() return devicePreferencesLoad } +/** Subscribes React consumers to the shared desktop-preference snapshot. */ +export function subscribeDesktopPreferences(listener: () => void): () => void { + devicePreferencesListeners.add(listener) + void loadDevicePreferences() + return () => { + devicePreferencesListeners.delete(listener) + } +} + function isSurfaceSwitchedOn(key: 'browserEnabled' | 'terminalEnabled'): boolean { void loadDevicePreferences() return devicePreferences?.[key] !== false @@ -106,6 +113,7 @@ function isSurfaceSwitchedOn(key: 'browserEnabled' | 'terminalEnabled'): boolean export function setDesktopPreferencesSnapshot(preferences: DesktopPreferences): void { devicePreferences = preferences devicePreferencesLoad = Promise.resolve() + for (const listener of devicePreferencesListeners) listener() } /** True when the agent browser is installed and switched on for this device. */ diff --git a/apps/sim/lib/desktop/preferences-loading.test.ts b/apps/sim/lib/desktop/preferences-loading.test.ts new file mode 100644 index 00000000000..0d1259f3c7b --- /dev/null +++ b/apps/sim/lib/desktop/preferences-loading.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import type { DesktopPreferences } from '@sim/desktop-bridge' +import { afterEach, expect, it, vi } from 'vitest' +import { + isBrowserAgentEnabled, + isTerminalEnabled, + setDesktopPreferencesSnapshot, + subscribeDesktopPreferences, +} from '@/lib/desktop' + +afterEach(() => vi.unstubAllGlobals()) + +it('publishes asynchronous startup preferences and later settings changes to subscribers', async () => { + const preferences: DesktopPreferences = { + notificationsEnabled: true, + notificationSounds: true, + notificationsOnlyWhenUnfocused: true, + launchAtLogin: false, + autoDownloadUpdates: true, + browserEnabled: false, + terminalEnabled: true, + } + const pending = Promise.withResolvers() + const getPreferences = vi.fn(() => pending.promise) + vi.stubGlobal('window', { simDesktop: { settings: { getPreferences } } }) + const listener = vi.fn() + const unsubscribe = subscribeDesktopPreferences(listener) + const otherListener = vi.fn() + const unsubscribeOther = subscribeDesktopPreferences(otherListener) + + try { + expect(getPreferences).toHaveBeenCalledOnce() + expect(isBrowserAgentEnabled()).toBe(true) + expect(isTerminalEnabled()).toBe(true) + expect(listener).not.toHaveBeenCalled() + + pending.resolve(preferences) + await pending.promise + expect(listener).toHaveBeenCalledOnce() + expect(otherListener).toHaveBeenCalledOnce() + expect(isBrowserAgentEnabled()).toBe(false) + expect(isTerminalEnabled()).toBe(true) + + unsubscribeOther() + const next = { ...preferences, browserEnabled: true } + setDesktopPreferencesSnapshot(next) + expect(listener).toHaveBeenCalledTimes(2) + expect(otherListener).toHaveBeenCalledOnce() + expect(isBrowserAgentEnabled()).toBe(true) + expect(getPreferences).toHaveBeenCalledOnce() + } finally { + unsubscribe() + unsubscribeOther() + } +}) diff --git a/apps/sim/lib/embeddings/catalog.test.ts b/apps/sim/lib/embeddings/catalog.test.ts index 8f9e594aa1f..d4525371551 100644 --- a/apps/sim/lib/embeddings/catalog.test.ts +++ b/apps/sim/lib/embeddings/catalog.test.ts @@ -4,11 +4,13 @@ import { describe, expect, it } from 'vitest' import { EMBEDDING_MODELS, + findEmbeddingModelInfo, getEmbeddingModelInfo, getKbEligibleModels, + getKbEmbeddingDimensions, getModelsForProvider, hasApproximateTokenCount, - KB_EMBEDDING_DIMENSIONS, + KB_EMBEDDING_STORAGE_DIMENSIONS, resolveDimensions, } from '@/lib/embeddings/catalog' import { EMBEDDING_MODEL_PRICING } from '@/providers/models' @@ -58,16 +60,23 @@ describe('embedding catalog', () => { } }) - it('only marks a model KB-eligible when it can emit the fixed KB vector width', () => { + it('only marks a model KB-eligible when it can emit a storable vector width', () => { for (const modelId of getKbEligibleModels()) { - const info = EMBEDDING_MODELS[modelId] - const canEmit = - info.nativeDimensions === KB_EMBEDDING_DIMENSIONS || - info.supportedDimensions?.includes(KB_EMBEDDING_DIMENSIONS) - expect(canEmit, `${modelId} cannot emit ${KB_EMBEDDING_DIMENSIONS} dimensions`).toBe(true) + const widths = getKbEmbeddingDimensions(EMBEDDING_MODELS[modelId]) + expect( + widths, + `${modelId} emits none of ${KB_EMBEDDING_STORAGE_DIMENSIONS.join(', ')}` + ).not.toHaveLength(0) } }) + it('resolves an ollama-prefixed model to every storable width', () => { + const info = getEmbeddingModelInfo('ollama/nomic-embed-text') + expect(info.provider).toBe('ollama') + expect(getKbEmbeddingDimensions(info)).toEqual([...KB_EMBEDDING_STORAGE_DIMENSIONS]) + expect(findEmbeddingModelInfo('ollama/')).toBeUndefined() + }) + it('keeps the KB-eligible set to the three models knowledge bases already index with', () => { // Widening this set changes which models KB_EMBEDDING_MODEL accepts, so it // is a deliberate decision rather than a side effect of adding a provider. diff --git a/apps/sim/lib/embeddings/catalog.ts b/apps/sim/lib/embeddings/catalog.ts index 69f5f7cb11c..1bd4c9ad3aa 100644 --- a/apps/sim/lib/embeddings/catalog.ts +++ b/apps/sim/lib/embeddings/catalog.ts @@ -1,6 +1,7 @@ import type { EmbeddingCatalogProvider, EmbeddingTaskType, + KeyedEmbeddingProvider, TokenizerProviderId, } from '@/lib/embeddings/types' import type { BYOKProviderId } from '@/tools/types' @@ -14,11 +15,38 @@ import type { BYOKProviderId } from '@/tools/types' export const DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small' /** - * Dimensionality every knowledge-base vector is stored at. The pgvector column - * is fixed at this width, so any model used for KB indexing must be able to - * emit vectors of exactly this size. + * Widths the `embedding` table has a pgvector column for, largest first. + * + * A knowledge base pins one of these at creation and every chunk in it is + * stored in the matching column, so a model used for KB indexing must emit + * vectors of exactly the width its base was created with. The set covers the + * sizes the popular embedding models actually emit: 384 (all-minilm and the + * small Sentence Transformers), 768 (nomic-embed-text, embeddinggemma, most + * open-weight models), 1024 (mxbai-embed-large, bge-m3, qwen3-embedding, + * Voyage), 1536 (OpenAI's small model), and 3072 (OpenAI's large model and + * gemini-embedding-001). + */ +export const KB_EMBEDDING_STORAGE_DIMENSIONS = [3072, 1536, 1024, 768, 384] as const + +export type KbEmbeddingDimensions = (typeof KB_EMBEDDING_STORAGE_DIMENSIONS)[number] + +/** + * Widest width a knowledge base can be created at. Anything sized for "the + * largest response a base could produce" has to use this rather than the + * default, because the per-request item ceiling falls as the width grows. */ -export const KB_EMBEDDING_DIMENSIONS = 1536 as const +export const MAX_KB_EMBEDDING_DIMENSIONS: KbEmbeddingDimensions = KB_EMBEDDING_STORAGE_DIMENSIONS[0] + +/** + * Width a knowledge base is created at when the deployment names no other one. + * Matches the `embedding.embedding` column every base predating multi-width + * storage was written into, so an unconfigured deployment keeps its behavior. + */ +export const DEFAULT_KB_EMBEDDING_DIMENSIONS = 1536 as const + +export function isKbEmbeddingDimensions(value: number): value is KbEmbeddingDimensions { + return (KB_EMBEDDING_STORAGE_DIMENSIONS as readonly number[]).includes(value) +} /** * OpenAI caps a single `/v1/embeddings` call at 300,000 tokens summed across all @@ -60,8 +88,9 @@ export interface EmbeddingModelInfo { */ maxTokensPerRequest?: number /** - * Selectable for knowledge-base indexing. Requires the model to emit exactly - * KB_EMBEDDING_DIMENSIONS. + * Selectable for knowledge-base indexing. Requires the model to emit at least + * one width in {@link KB_EMBEDDING_STORAGE_DIMENSIONS}; a model that can is + * still opted out here when it is superseded and only kept for placed blocks. */ kbEligible: boolean } @@ -152,8 +181,13 @@ export const EMBEDDING_MODELS: Record = { }, } -/** Providers a user can pick, in the order the block offers them. */ -export const EMBEDDING_CATALOG_PROVIDERS: readonly EmbeddingCatalogProvider[] = [ +/** + * Providers a user can pick, in the order the block offers them. Ollama is + * absent because its models are whatever the operator has pulled onto their own + * server, which no static catalog can enumerate; it is reachable through the + * `ollama/` model prefix that {@link findEmbeddingModelInfo} resolves. + */ +export const EMBEDDING_CATALOG_PROVIDERS: readonly KeyedEmbeddingProvider[] = [ 'openai', 'gemini', 'cohere', @@ -164,7 +198,7 @@ export const EMBEDDING_CATALOG_PROVIDERS: readonly EmbeddingCatalogProvider[] = * Model each provider falls back to when the caller names none. Single source * for the block's pre-selected value, the per-provider tools, and the route. */ -export const DEFAULT_MODEL_BY_PROVIDER: Record = { +export const DEFAULT_MODEL_BY_PROVIDER: Record = { openai: DEFAULT_EMBEDDING_MODEL, gemini: 'gemini-embedding-001', cohere: 'embed-v4.0', @@ -175,15 +209,82 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record * BYOK provider id for a workspace-owned key. Differs from the embedding * provider id for Gemini, whose keys are stored under the shared Google entry. */ -export const BYOK_PROVIDER_IDS: Record = { +export const BYOK_PROVIDER_IDS: Record = { openai: 'openai', gemini: 'google', cohere: 'cohere', mistral: 'mistral', } +/** + * Marks a model id as living on the deployment's own Ollama server. The + * remainder is passed to Ollama verbatim, so tags survive + * (`ollama/mxbai-embed-large:335m`), matching how the OpenRouter transport + * already namespaces ids it does not own. + */ +export const OLLAMA_EMBEDDING_MODEL_PREFIX = 'ollama/' + +/** + * Per-input token ceiling assumed for an Ollama model. + * + * Sim cannot know a local model's context length — it varies from 512 + * (mxbai-embed-large) to 8192 (nomic-embed-text, bge-m3) — so inputs are held + * to the common upper figure and the adapter asks Ollama to truncate anything + * the loaded model cannot fit. Ollama truncating is a silent shortening, but + * the alternative is an outright rejection of input every other provider in the + * catalog accepts. + */ +const OLLAMA_MAX_INPUT_TOKENS = 8192 + +export function isOllamaEmbeddingModel(model: string): boolean { + return model.startsWith(OLLAMA_EMBEDDING_MODEL_PREFIX) +} + +/** Model name as Ollama expects it on the wire, with the routing prefix removed. */ +export function ollamaEmbeddingModelName(model: string): string { + return model.slice(OLLAMA_EMBEDDING_MODEL_PREFIX.length) +} + +/** + * Routing id for a model on the deployment's Ollama. Tolerant of a value that + * already carries the prefix, so an operator who writes + * `KB_EMBEDDING_MODEL=ollama/nomic-embed-text` and a block that stores the bare + * name Ollama itself lists both resolve to the same id. + */ +export function toOllamaEmbeddingModelId(model: string): string { + const name = (isOllamaEmbeddingModel(model) ? ollamaEmbeddingModelName(model) : model).trim() + if (!name) throw new Error('Ollama embedding model name is required') + return `${OLLAMA_EMBEDDING_MODEL_PREFIX}${name}` +} + +/** + * Metadata for a model served by the deployment's own Ollama instance. + * + * Ollama publishes no catalog Sim can read at module load, so the entry is + * synthesized: every storage width is offered, and `nativeDimensions` is the + * platform default only so a caller that requests no width still names one. + * The model's real width is whatever it emits, and the client's response + * validation is what enforces the match — a base created at 768 whose model + * returns 1024 fails with both numbers rather than writing a wrong-width vector. + */ +function buildOllamaEmbeddingModelInfo(model: string): EmbeddingModelInfo | undefined { + const name = ollamaEmbeddingModelName(model) + if (!name) return undefined + return { + provider: 'ollama', + label: name, + /** No entry in EMBEDDING_MODEL_PRICING: local inference costs Sim nothing. */ + pricingId: model, + tokenizerProvider: 'ollama', + nativeDimensions: DEFAULT_KB_EMBEDDING_DIMENSIONS, + supportedDimensions: KB_EMBEDDING_STORAGE_DIMENSIONS, + maxInputTokens: OLLAMA_MAX_INPUT_TOKENS, + kbEligible: true, + } +} + export function getEmbeddingModelInfo(model: string): EmbeddingModelInfo { - const info = EMBEDDING_MODELS[model] + const info = findEmbeddingModelInfo(model) if (!info) { throw new Error(`Unsupported embedding model: ${model}`) } @@ -191,10 +292,16 @@ export function getEmbeddingModelInfo(model: string): EmbeddingModelInfo { } export function findEmbeddingModelInfo(model: string): EmbeddingModelInfo | undefined { - return EMBEDDING_MODELS[model] + if (isOllamaEmbeddingModel(model)) return buildOllamaEmbeddingModelInfo(model) + /** + * Own-property lookup, not indexing: the record's prototype is + * `Object.prototype`, so `EMBEDDING_MODELS['toString']` would otherwise hand + * back an inherited function that every downstream field read then crashes on. + */ + return Object.hasOwn(EMBEDDING_MODELS, model) ? EMBEDDING_MODELS[model] : undefined } -export function getModelsForProvider(provider: EmbeddingCatalogProvider): string[] { +export function getModelsForProvider(provider: KeyedEmbeddingProvider): string[] { return Object.keys(EMBEDDING_MODELS).filter((id) => EMBEDDING_MODELS[id].provider === provider) } @@ -203,6 +310,18 @@ export function getKbEligibleModels(): string[] { return Object.keys(EMBEDDING_MODELS).filter((id) => EMBEDDING_MODELS[id].kbEligible) } +/** + * Storage widths a model can be indexed at, largest first — the intersection of + * what it emits with what the `embedding` table has a column for. Empty when the + * model emits no width knowledge bases can store, which is what makes it + * unusable for indexing regardless of its {@link EmbeddingModelInfo.kbEligible} + * flag. + */ +export function getKbEmbeddingDimensions(info: EmbeddingModelInfo): KbEmbeddingDimensions[] { + const emitted = info.supportedDimensions ?? [info.nativeDimensions] + return KB_EMBEDDING_STORAGE_DIMENSIONS.filter((width) => emitted.includes(width)) +} + /** * True when a model's tokens cannot be counted exactly. * @@ -223,6 +342,19 @@ export function hasApproximateTokenCount(info: EmbeddingModelInfo): boolean { */ export function resolveDimensions(info: EmbeddingModelInfo, requested?: number): number { if (requested === undefined) return info.nativeDimensions + /** + * A local model's width is whatever the operator pulled, and Ollama publishes + * no catalog Sim can enumerate at module load, so any positive width is taken + * at face value here. It is not unchecked: the caller reads the real width off + * `/api/show` (or, for a knowledge base, off the row it stores into), and the + * client's response validation rejects a model that returns anything else. + */ + if (info.provider === 'ollama') { + if (!Number.isInteger(requested) || requested <= 0) { + throw new Error(`${info.label} cannot produce ${requested}-dimensional output`) + } + return requested + } if (!info.supportedDimensions?.includes(requested)) { throw new Error( `${info.label} does not support ${requested}-dimensional output. Supported: ${ diff --git a/apps/sim/lib/embeddings/client.test.ts b/apps/sim/lib/embeddings/client.test.ts index 054a83c8568..71f67c3d219 100644 --- a/apps/sim/lib/embeddings/client.test.ts +++ b/apps/sim/lib/embeddings/client.test.ts @@ -102,6 +102,7 @@ beforeEach(() => { OPENAI_API_KEY_2: undefined, OPENAI_API_KEY_3: undefined, OPENROUTER_API_KEY: undefined, + OLLAMA_URL: undefined, }) }) @@ -1383,3 +1384,85 @@ describe('knowledge embedding transport fallback', () => { expect(isBYOKEmbeddingCredentialRejection(error)).toBe(false) }) }) + +describe('ollama embeddings', () => { + function ollamaBody(vectors: number[][], dimensions: number, promptEvalCount = 3) { + return { + embeddings: vectors.map((vector) => sizedVector(vector, dimensions)), + prompt_eval_count: promptEvalCount, + } + } + + it('embeds against the configured server with no credential and bills nothing', async () => { + setEnv({ OLLAMA_URL: 'http://ollama.internal:11434/' }) + fetchMock.mockResolvedValue(jsonResponse(ollamaBody([[1, 2, 3]], 768))) + + const result = await embed(['hello'], { + model: 'ollama/nomic-embed-text', + dimensions: 768, + projectInputs: null, + }) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('http://ollama.internal:11434/api/embed') + expect((init as RequestInit).headers).not.toHaveProperty('Authorization') + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + model: 'nomic-embed-text', + input: ['hello'], + truncate: true, + }) + expect(result.dimensions).toBe(768) + expect(result.modelName).toBe('nomic-embed-text') + expect(result.isBYOK).toBe(true) + expect(result.billableTokens).toBe(0) + expect(result.totalTokens).toBe(3) + }) + + /** + * The width is the operator's to get right, so the failure has to name both + * numbers rather than storing a vector the knowledge base cannot query. + */ + it('rejects a model that returns a different width than the base stores', async () => { + setEnv({ OLLAMA_URL: 'http://ollama.internal:11434' }) + fetchMock.mockResolvedValue(jsonResponse(ollamaBody([[1, 2, 3]], 1024))) + + await expect( + embed(['hello'], { model: 'ollama/mxbai-embed-large', dimensions: 768, projectInputs: null }) + ).rejects.toThrow('has 1024 unexpected dimensions; expected 768') + }) + + /** + * A self-hosted deployment runs alongside its own Ollama, so the loopback + * default is a working configuration that needs no env var — the same rule + * the chat provider and the block's model selector apply. Requiring the + * variable here would make embedding stricter than the list that offers the + * models. + */ + it('serves a self-hosted deployment from the loopback default', async () => { + fetchMock.mockResolvedValue(jsonResponse(ollamaBody([[1, 2, 3]], 768))) + + const result = await embed(['hello'], { + model: 'ollama/nomic-embed-text', + dimensions: 768, + projectInputs: null, + }) + + expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:11434/api/embed') + expect(result.dimensions).toBe(768) + }) + + it('ignores a caller-supplied key rather than sending one Ollama cannot use', async () => { + setEnv({ OLLAMA_URL: 'http://ollama.internal:11434' }) + fetchMock.mockResolvedValue(jsonResponse(ollamaBody([[1, 2, 3]], 384))) + + await embed(['hello'], { + model: 'ollama/all-minilm', + dimensions: 384, + apiKey: 'sk-not-applicable', + projectInputs: null, + }) + + const [, init] = fetchMock.mock.calls[0] + expect((init as RequestInit).headers).toEqual({ 'Content-Type': 'application/json' }) + }) +}) diff --git a/apps/sim/lib/embeddings/client.ts b/apps/sim/lib/embeddings/client.ts index c9e0a623345..e9ab2be165d 100644 --- a/apps/sim/lib/embeddings/client.ts +++ b/apps/sim/lib/embeddings/client.ts @@ -15,14 +15,17 @@ import { readResponseJsonWithLimit, readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' +import { getOllamaUrl } from '@/lib/core/utils/urls' import { DEFAULT_EMBEDDING_MODEL, type EmbeddingModelInfo, getEmbeddingModelInfo, hasApproximateTokenCount, + ollamaEmbeddingModelName, resolveDimensions, } from '@/lib/embeddings/catalog' import { resolveProviderKey } from '@/lib/embeddings/keys' +import { isOllamaServerConfigured } from '@/lib/embeddings/ollama-model-catalog.server' import { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models' import { getAdapterFactory } from '@/lib/embeddings/providers' import { @@ -359,6 +362,39 @@ async function resolveProvider(model: string, options: EmbedOptions): Promise { @@ -993,6 +1044,17 @@ export async function embedKnowledgeForDeployment( isBYOK: false, } }, + /** + * Gemini and Ollama are declared on the capability because they serve + * knowledge embeddings, but never through this chain: it is built only for + * OpenAI models (the guard above returns for everything else), and the + * capability's own family gating marks them inactive here for the same + * reason. `wireFallback` throws if a provider it resolved as ready returns + * null, so this stays a loud failure rather than a silent wrong provider if + * either assumption ever stops holding. + */ + gemini: () => null, + ollama: () => null, } satisfies FallbackFactories const fallback = wireFallback({ diff --git a/apps/sim/lib/embeddings/index.ts b/apps/sim/lib/embeddings/index.ts index 9bd367954fa..ddc7babbd12 100644 --- a/apps/sim/lib/embeddings/index.ts +++ b/apps/sim/lib/embeddings/index.ts @@ -8,6 +8,7 @@ export { DEFAULT_MODEL_BY_PROVIDER, findEmbeddingModelInfo, resolveDimensions, + toOllamaEmbeddingModelId, } from '@/lib/embeddings/catalog' export { BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE, diff --git a/apps/sim/lib/embeddings/keys.ts b/apps/sim/lib/embeddings/keys.ts index 98f591484ee..0121363afa1 100644 --- a/apps/sim/lib/embeddings/keys.ts +++ b/apps/sim/lib/embeddings/keys.ts @@ -3,7 +3,7 @@ import { getBYOKKey } from '@/lib/api-key/byok' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { env } from '@/lib/core/config/env' import { BYOK_PROVIDER_IDS } from '@/lib/embeddings/catalog' -import type { EmbeddingCatalogProvider } from '@/lib/embeddings/types' +import type { KeyedEmbeddingProvider } from '@/lib/embeddings/types' import type { BYOKProviderId } from '@/tools/types' const logger = createLogger('EmbeddingKeys') @@ -28,7 +28,7 @@ interface ProviderKeyConfig { * Resolution order per provider is BYOK -> singular env key -> rotating pool. * `env` is read lazily through a getter so tests that stub `env` still work. */ -const PROVIDER_KEY_CONFIG: Record ProviderKeyConfig> = { +const PROVIDER_KEY_CONFIG: Record ProviderKeyConfig> = { openai: () => ({ byokProviderId: BYOK_PROVIDER_IDS.openai, envKey: env.OPENAI_API_KEY, @@ -57,7 +57,7 @@ const PROVIDER_KEY_CONFIG: Record ProviderKeyCon } export async function resolveProviderKey( - provider: EmbeddingCatalogProvider, + provider: KeyedEmbeddingProvider, workspaceId?: string | null ): Promise { const config = PROVIDER_KEY_CONFIG[provider]() diff --git a/apps/sim/lib/embeddings/knowledge-embedding-family.test.ts b/apps/sim/lib/embeddings/knowledge-embedding-family.test.ts new file mode 100644 index 00000000000..6dbfc7f259c --- /dev/null +++ b/apps/sim/lib/embeddings/knowledge-embedding-family.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + inspectCapability, + KNOWLEDGE_EMBEDDINGS_CAPABILITY, + knowledgeEmbeddingFamily, +} from '@/lib/core/config/env-capabilities' +import { + DEFAULT_EMBEDDING_MODEL, + getEmbeddingModelInfo, + getKbEligibleModels, + getKbEmbeddingDimensions, + KB_EMBEDDING_STORAGE_DIMENSIONS, +} from '@/lib/embeddings/catalog' +import { isKbEmbeddingModel } from '@/lib/knowledge/embedding-models' + +/** + * `knowledgeEmbeddingFamily` decides which credential the setup CLI and the + * capability status treat as serving knowledge embeddings. It lives in + * `@sim/deployment-config`, which cannot import the app's model catalog, so it + * classifies model ids by shape instead. These pin the two together: a catalog + * model whose family the classifier gets wrong would make a correctly + * configured deployment report as unconfigured, and an incorrectly configured + * one report as ready. + */ +describe('knowledgeEmbeddingFamily', () => { + it('agrees with the catalog for every model a knowledge base can be created with', () => { + for (const model of getKbEligibleModels()) { + expect(knowledgeEmbeddingFamily({ KB_EMBEDDING_MODEL: model }), model).toBe( + getEmbeddingModelInfo(model).provider + ) + } + }) + + /** + * The capability also duplicates each model's storable widths, to reject a + * width the selected model cannot emit. A width the catalog allows but the + * capability rejects blocks a working deployment; the reverse passes a + * configuration the runtime silently overrides. + */ + it('accepts exactly the widths the catalog says each model can be indexed at', () => { + for (const model of getKbEligibleModels()) { + const widths = getKbEmbeddingDimensions(getEmbeddingModelInfo(model)) + for (const width of KB_EMBEDDING_STORAGE_DIMENSIONS) { + const issues = inspectCapability(KNOWLEDGE_EMBEDDINGS_CAPABILITY, { + KB_EMBEDDING_MODEL: model, + EMBEDDING_OUTPUT_DIMS: String(width), + OPENAI_API_KEY: 'k', + GEMINI_API_KEY: 'k', + }).providers.flatMap((provider) => provider.invalidFields) + expect(issues.includes('EMBEDDING_OUTPUT_DIMS'), `${model} @ ${width}`).toBe( + !widths.includes(width) + ) + } + } + }) + + it('classifies any model on the deployment’s own Ollama by its routing prefix', () => { + for (const model of ['ollama/nomic-embed-text', 'ollama/mxbai-embed-large:335m']) { + expect(knowledgeEmbeddingFamily({ KB_EMBEDDING_MODEL: model }), model).toBe('ollama') + } + }) + + /** + * The classifier decides which credential the CLI reports as serving knowledge + * embeddings; the runtime decides which one actually gets used. An id the + * runtime rejects falls back to the default model, so the classifier has to + * call it that family too — otherwise a deployment holding only the credential + * it names passes its status check and fails every embedding call. + */ + it('agrees with the runtime on ids the runtime does not accept', () => { + const defaultFamily = getEmbeddingModelInfo(DEFAULT_EMBEDDING_MODEL).provider + const rejected = [ + '', + ' ', + 'not-a-model', + 'gemini-embedding-999', + 'Gemini-Embedding-001', + 'gemini', + 'OLLAMA/nomic-embed-text', + 'ollama/', + 'toString', + 'constructor', + ] + for (const model of rejected) { + expect(isKbEmbeddingModel(model), `${model} must not be a KB model`).toBe(false) + expect(knowledgeEmbeddingFamily({ KB_EMBEDDING_MODEL: model }), model).toBe(defaultFamily) + } + expect(knowledgeEmbeddingFamily({})).toBe(defaultFamily) + }) +}) diff --git a/apps/sim/lib/embeddings/ollama-model-catalog.server.test.ts b/apps/sim/lib/embeddings/ollama-model-catalog.server.test.ts new file mode 100644 index 00000000000..d563ff76a8c --- /dev/null +++ b/apps/sim/lib/embeddings/ollama-model-catalog.server.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + fetchOllamaEmbeddingModelCatalog, + getOllamaEmbeddingModelMetadata, + OllamaEmbeddingModelNotFoundError, + OllamaEmbeddingWidthUnknownError, + OllamaUnreachableError, +} from '@/lib/embeddings/ollama-model-catalog.server' + +const fetchMock = vi.fn() + +/** Mirrors an Ollama server: `/api/tags` lists everything, `/api/show` classifies it. */ +function serve( + models: Record }> +) { + fetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (url.endsWith('/api/tags')) { + return Promise.resolve( + Response.json({ models: Object.keys(models).map((name) => ({ name })) }) + ) + } + const { model } = JSON.parse(String(init?.body)) as { model: string } + /** Ollama resolves a bare name to its `:latest` tag server-side. */ + const detail = models[model] ?? models[`${model}:latest`] + if (!detail) return Promise.resolve(new Response('not found', { status: 404 })) + return Promise.resolve( + Response.json({ + ...(detail.capabilities ? { capabilities: detail.capabilities } : {}), + ...(detail.modelInfo ? { model_info: detail.modelInfo } : {}), + }) + ) + }) +} + +describe('Ollama embedding model catalog', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + setEnv({ OLLAMA_URL: 'http://ollama.internal:11434' }) + }) + + afterEach(() => { + resetEnvMock() + }) + + afterAll(() => { + vi.unstubAllGlobals() + }) + + it('keeps embedding models with the width their architecture publishes', async () => { + serve({ + 'nomic-embed-text:latest': { + capabilities: ['embedding'], + modelInfo: { 'nomic-bert.embedding_length': 768, 'nomic-bert.block_count': 12 }, + }, + }) + + await expect(fetchOllamaEmbeddingModelCatalog()).resolves.toEqual([ + { id: 'nomic-embed-text:latest', dimensions: 768 }, + ]) + }) + + /** `/api/tags` lists chat and embedding models together and distinguishes neither. */ + it('drops a chat model, which the tags listing does not separate out', async () => { + serve({ + 'smollm2:135m': { + capabilities: ['completion'], + modelInfo: { 'llama.embedding_length': 576 }, + }, + 'all-minilm:latest': { + capabilities: ['embedding'], + modelInfo: { 'bert.embedding_length': 384 }, + }, + }) + + await expect(fetchOllamaEmbeddingModelCatalog()).resolves.toEqual([ + { id: 'all-minilm:latest', dimensions: 384 }, + ]) + }) + + it('keeps a model from a server too old to report capabilities', async () => { + serve({ 'legacy-embed': { modelInfo: { 'bert.embedding_length': 512 } } }) + + await expect(fetchOllamaEmbeddingModelCatalog()).resolves.toEqual([ + { id: 'legacy-embed', dimensions: 512 }, + ]) + }) + + it('keeps the rest of the catalog when one model cannot be inspected', async () => { + serve({ + 'all-minilm:latest': { + capabilities: ['embedding'], + modelInfo: { 'bert.embedding_length': 384 }, + }, + }) + const served = fetchMock.getMockImplementation() + /** `broken` has to fail its own `/api/show`, or the skip path never runs. */ + fetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (String(url).endsWith('/api/tags')) { + return Promise.resolve( + Response.json({ models: [{ name: 'broken' }, { name: 'all-minilm:latest' }] }) + ) + } + const { model } = JSON.parse(String(init?.body)) as { model: string } + if (model === 'broken') return Promise.reject(new Error('inspection failed')) + return served?.(url, init) + }) + + await expect(fetchOllamaEmbeddingModelCatalog()).resolves.toEqual([ + { id: 'all-minilm:latest', dimensions: 384 }, + ]) + }) + + it('offers nothing rather than throwing when no Ollama answers', async () => { + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')) + + await expect(fetchOllamaEmbeddingModelCatalog()).resolves.toEqual([]) + }) + + /** Ollama resolves a bare name to its `:latest` tag itself, so Sim need not. */ + it('accepts a bare name and a routing-prefixed one alike', async () => { + serve({ + 'all-minilm:latest': { + capabilities: ['embedding'], + modelInfo: { 'bert.embedding_length': 384 }, + }, + }) + + await expect(getOllamaEmbeddingModelMetadata('all-minilm')).resolves.toEqual({ + id: 'all-minilm', + dimensions: 384, + }) + await expect(getOllamaEmbeddingModelMetadata('ollama/all-minilm')).resolves.toEqual({ + id: 'all-minilm', + dimensions: 384, + }) + }) + + it('rejects a model that is not installed', async () => { + serve({}) + + await expect(getOllamaEmbeddingModelMetadata('missing')).rejects.toBeInstanceOf( + OllamaEmbeddingModelNotFoundError + ) + }) + + /** + * Resolution asks about one model rather than listing the server, so an + * unrelated model's slow or failing `/api/show` cannot delay or break it. + */ + it('asks only about the selected model, never the whole catalog', async () => { + serve({ + 'all-minilm:latest': { + capabilities: ['embedding'], + modelInfo: { 'bert.embedding_length': 384 }, + }, + }) + + await getOllamaEmbeddingModelMetadata('all-minilm') + + const paths = fetchMock.mock.calls.map(([url]) => String(url)) + expect(paths.every((url) => url.endsWith('/api/show'))).toBe(true) + expect(paths).toHaveLength(1) + }) + + it('reports a server that stops answering as an outage, not a missing model', async () => { + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')) + + await expect(getOllamaEmbeddingModelMetadata('all-minilm')).rejects.toBeInstanceOf( + OllamaUnreachableError + ) + }) + + it('rejects a chat model asked for by name', async () => { + serve({ 'smollm2:135m': { capabilities: ['completion'] } }) + + await expect(getOllamaEmbeddingModelMetadata('smollm2:135m')).rejects.toBeInstanceOf( + OllamaEmbeddingModelNotFoundError + ) + }) + + /** + * The width is what the client validates the response against, so a model + * whose width cannot be read is refused rather than embedded unchecked. + */ + it('refuses a model whose width Ollama does not report', async () => { + serve({ 'widthless:latest': { capabilities: ['embedding'] } }) + + await expect(getOllamaEmbeddingModelMetadata('widthless')).rejects.toBeInstanceOf( + OllamaEmbeddingWidthUnknownError + ) + }) +}) diff --git a/apps/sim/lib/embeddings/ollama-model-catalog.server.ts b/apps/sim/lib/embeddings/ollama-model-catalog.server.ts new file mode 100644 index 00000000000..75008e1f4be --- /dev/null +++ b/apps/sim/lib/embeddings/ollama-model-catalog.server.ts @@ -0,0 +1,220 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + ollamaShowUpstreamResponseSchema, + ollamaUpstreamResponseSchema, +} from '@/lib/api/contracts/providers' +import { isHosted } from '@/lib/core/config/env-flags' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { getOllamaUrl, isOllamaUrlConfigured } from '@/lib/core/utils/urls' +import { isOllamaEmbeddingModel, ollamaEmbeddingModelName } from '@/lib/embeddings/catalog' + +const logger = createLogger('OllamaEmbeddingCatalog') + +const MAX_OLLAMA_CATALOG_BYTES = 4 * 1024 * 1024 + +/** `/api/show` is one request per installed model, so the fan-out is bounded. */ +const OLLAMA_SHOW_CONCURRENCY = 6 + +/** Suffix every architecture uses for the width it emits (`bert.embedding_length`). */ +const EMBEDDING_LENGTH_SUFFIX = '.embedding_length' + +export interface OllamaEmbeddingModel { + /** Name as Ollama lists it, tag included (`nomic-embed-text:latest`). */ + id: string + /** Width the model emits, when its architecture publishes one. */ + dimensions?: number +} + +/** + * Whether this deployment has an Ollama server to talk to. + * + * Self-hosted deployments run alongside their own Ollama, so the loopback + * default `getOllamaUrl` falls back to is a working configuration that needs no + * env var — the same rule the chat provider's model route applies. Hosted Sim + * runs none, so there the address must be stated or there is nothing to reach. + * + * Exported because the selector, this module's width lookup, and the embedding + * client all have to answer it identically: a selector that lists models the + * client then refuses to embed with is worse than either behaviour alone. + */ +export function isOllamaServerConfigured(): boolean { + return !isHosted || isOllamaUrlConfigured() +} + +export class OllamaEmbeddingModelNotFoundError extends Error { + constructor(model: string) { + super( + `Unsupported Ollama embedding model: ${model}. Pull it on the configured Ollama server first.` + ) + this.name = 'OllamaEmbeddingModelNotFoundError' + } +} + +/** The configured server could not be reached at all — an outage, not a bad model id. */ +export class OllamaUnreachableError extends Error { + constructor(cause: string) { + super(`The configured Ollama server could not be reached: ${cause}`) + this.name = 'OllamaUnreachableError' + } +} + +export class OllamaEmbeddingWidthUnknownError extends Error { + constructor(model: string) { + super( + `Ollama did not report an embedding width for ${model}, so Sim cannot verify what it returns. Upgrade Ollama or choose another model.` + ) + this.name = 'OllamaEmbeddingWidthUnknownError' + } +} + +async function fetchOllamaJson(path: string, init: RequestInit, signal?: AbortSignal) { + const response = await fetch(`${getOllamaUrl().replace(/\/+$/, '')}${path}`, { + headers: { 'Content-Type': 'application/json' }, + signal, + ...init, + }) + if (!response.ok) { + /** Release the connection: nothing reads the body of a rejected probe. */ + await response.body?.cancel().catch(() => {}) + throw new Error(`Ollama ${path} failed: ${response.status} ${response.statusText}`) + } + return readResponseJsonWithLimit(response, { + maxBytes: MAX_OLLAMA_CATALOG_BYTES, + label: `Ollama ${path} response`, + signal, + }) +} + +function readEmbeddingLength(modelInfo: Record | undefined): number | undefined { + if (!modelInfo) return undefined + for (const [key, value] of Object.entries(modelInfo)) { + if (!key.endsWith(EMBEDDING_LENGTH_SUFFIX)) continue + if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value + } + return undefined +} + +/** + * Embedding-capable models installed on the deployment's Ollama, with the width + * each one emits. + * + * `/api/tags` lists chat and embedding models together and distinguishes + * neither, so every entry is resolved through `/api/show`, whose `capabilities` + * is the only thing that says which is which. A model that reports no + * capabilities at all (Ollama older than 0.5) is kept rather than dropped: + * hiding a usable model on an older server is worse than offering one the user + * has to recognise as a chat model. + * + * Returns an empty list rather than throwing when no Ollama is configured or + * reachable, mirroring `/api/providers/ollama/models` — an absent Ollama is a + * normal deployment shape, not a failure of this lookup. + */ +export async function fetchOllamaEmbeddingModelCatalog( + signal?: AbortSignal +): Promise { + return (await loadOllamaEmbeddingModelCatalog(signal)).models +} + +/** + * The catalog plus why it is empty, so a caller resolving one specific model can + * tell "not installed" apart from "no server answered". The selector only needs + * the list; the tool needs the distinction to pick a status code. + */ +async function loadOllamaEmbeddingModelCatalog( + signal?: AbortSignal +): Promise<{ models: OllamaEmbeddingModel[]; unreachable?: string }> { + /** Nothing to dial: an unconfigured hosted deployment has no server to reach. */ + if (!isOllamaServerConfigured()) return { models: [] } + + let names: string[] + try { + const tags = ollamaUpstreamResponseSchema.parse( + await fetchOllamaJson('/api/tags', { method: 'GET' }, signal) + ) + names = tags.models.map((model) => model.name) + } catch (error) { + signal?.throwIfAborted() + const cause = getErrorMessage(error, 'Unknown error') + logger.info('Ollama is not reachable; offering no embedding models', { error: cause }) + return { models: [], unreachable: cause } + } + + const resolved = await mapWithConcurrency(names, OLLAMA_SHOW_CONCURRENCY, async (name) => { + try { + const detail = ollamaShowUpstreamResponseSchema.parse( + await fetchOllamaJson( + '/api/show', + { method: 'POST', body: JSON.stringify({ model: name }) }, + signal + ) + ) + if (detail.capabilities && !detail.capabilities.includes('embedding')) return null + const dimensions = readEmbeddingLength(detail.model_info) + return { id: name, ...(dimensions !== undefined ? { dimensions } : {}) } + } catch (error) { + signal?.throwIfAborted() + /** One unreadable model must not hide the rest of the server's catalog. */ + logger.info('Skipping an Ollama model that could not be inspected', { + model: name, + error: getErrorMessage(error, 'Unknown error'), + }) + return null + } + }) + + return { models: resolved.filter((model): model is OllamaEmbeddingModel => model !== null) } +} + +/** + * Resolves one selected model, the way the OpenRouter tool resolves a model + * against OpenRouter's catalog — but by asking `/api/show` for that model alone. + * + * Deliberately not a catalog scan: this runs on every Ollama embedding request, + * and listing the server would make one unrelated model's slow or failing + * `/api/show` delay or break an embedding that never involved it. Asking + * directly also keeps the failures separable, which a scan cannot do — an + * absent model comes back 404 and is the caller's to fix, while anything else + * is the server's problem and must not be reported as a bad request. + * + * The width is required rather than optional because it is what the client + * validates the response against. Without it a model that quietly returns a + * different size than the caller expects would be indistinguishable from a good + * one — the exact failure the knowledge-base path names both numbers for. + */ +export async function getOllamaEmbeddingModelMetadata( + model: string, + signal?: AbortSignal +): Promise> { + const name = isOllamaEmbeddingModel(model) ? ollamaEmbeddingModelName(model) : model + if (!name) throw new OllamaEmbeddingModelNotFoundError(model) + /** The same rule the selector and the embedding client apply. */ + if (!isOllamaServerConfigured()) { + throw new OllamaUnreachableError('OLLAMA_URL is not configured') + } + + let detail: { capabilities?: string[]; model_info?: Record } + try { + detail = ollamaShowUpstreamResponseSchema.parse( + await fetchOllamaJson( + '/api/show', + { method: 'POST', body: JSON.stringify({ model: name }) }, + signal + ) + ) + } catch (error) { + signal?.throwIfAborted() + const cause = getErrorMessage(error, 'Unknown error') + /** Ollama answers 404 for a model it does not have; anything else is its problem. */ + if (cause.includes('404')) throw new OllamaEmbeddingModelNotFoundError(name) + throw new OllamaUnreachableError(cause) + } + + if (detail.capabilities && !detail.capabilities.includes('embedding')) { + throw new OllamaEmbeddingModelNotFoundError(name) + } + const dimensions = readEmbeddingLength(detail.model_info) + if (dimensions === undefined) throw new OllamaEmbeddingWidthUnknownError(name) + return { id: name, dimensions } +} diff --git a/apps/sim/lib/embeddings/providers/index.ts b/apps/sim/lib/embeddings/providers/index.ts index 17a46b1f6f8..46314ab7d81 100644 --- a/apps/sim/lib/embeddings/providers/index.ts +++ b/apps/sim/lib/embeddings/providers/index.ts @@ -2,18 +2,26 @@ import { createAzureOpenAIAdapter } from '@/lib/embeddings/providers/azure-opena import { createCohereAdapter } from '@/lib/embeddings/providers/cohere' import { createGeminiAdapter } from '@/lib/embeddings/providers/gemini' import { createMistralAdapter } from '@/lib/embeddings/providers/mistral' +import { createOllamaAdapter } from '@/lib/embeddings/providers/ollama' import { createOpenAIAdapter } from '@/lib/embeddings/providers/openai' import { createOpenRouterAdapter } from '@/lib/embeddings/providers/openrouter' import type { AzureEmbeddingAdapterContext, EmbeddingAdapterFactory, EmbeddingProviderKind, + OllamaEmbeddingAdapterContext, } from '@/lib/embeddings/types' -/** Azure's entry keeps its own context type so its routing fields stay required. */ +/** + * Azure and Ollama keep their own context types so their routing fields stay + * required and, for Ollama, so no caller can hand it a credential it has no way + * to send. + */ type AdapterFactoryFor = K extends 'azure-openai' ? EmbeddingAdapterFactory - : EmbeddingAdapterFactory + : K extends 'ollama' + ? EmbeddingAdapterFactory + : EmbeddingAdapterFactory const ADAPTER_FACTORIES: { [K in EmbeddingProviderKind]: AdapterFactoryFor } = { openai: createOpenAIAdapter, @@ -22,6 +30,7 @@ const ADAPTER_FACTORIES: { [K in EmbeddingProviderKind]: AdapterFactoryFor } gemini: createGeminiAdapter, cohere: createCohereAdapter, mistral: createMistralAdapter, + ollama: createOllamaAdapter, } export function getAdapterFactory( @@ -35,6 +44,7 @@ export { createCohereAdapter, createGeminiAdapter, createMistralAdapter, + createOllamaAdapter, createOpenAIAdapter, createOpenRouterAdapter, } diff --git a/apps/sim/lib/embeddings/providers/ollama.ts b/apps/sim/lib/embeddings/providers/ollama.ts new file mode 100644 index 00000000000..5aedaa082bc --- /dev/null +++ b/apps/sim/lib/embeddings/providers/ollama.ts @@ -0,0 +1,52 @@ +import type { EmbeddingAdapterFactory, OllamaEmbeddingAdapterContext } from '@/lib/embeddings/types' + +/** + * Ollama documents no cap on `/api/embed`, but it embeds a batch on one local + * model serially, so a large batch is a long single request against the + * client's 60s timeout. Capping items keeps a slow local model's failures cheap + * to retry — the same reason the shared batch token target sits below every + * provider's documented maximum. + */ +const OLLAMA_MAX_ITEMS_PER_REQUEST = 64 + +interface OllamaEmbeddingResponse { + embeddings: number[][] + /** Ollama reports the tokens it actually embedded, after any truncation. */ + prompt_eval_count?: number +} + +/** + * Ollama `/api/embed` on the deployment's own server. Takes no credential: + * Ollama exposes no authentication, so the server URL is the whole trust + * boundary. + * + * `dimensions` is deliberately not forwarded. Only recent Ollama builds accept + * it, and only for Matryoshka-capable models; an older server ignores unknown + * fields rather than rejecting them, so sending it would silently produce + * native-width vectors passing as reduced ones. The requested width is enforced + * by the client's response validation instead, which names both the width asked + * for and the width returned. + */ +export const createOllamaAdapter: EmbeddingAdapterFactory = ({ + modelName, + baseUrl, +}) => ({ + maxItemsPerRequest: OLLAMA_MAX_ITEMS_PER_REQUEST, + buildRequest: ({ inputs }) => ({ + apiUrl: `${baseUrl}/api/embed`, + headers: { 'Content-Type': 'application/json' }, + body: { + model: modelName, + input: inputs, + /** + * Sim cannot know a local model's context length, so it holds inputs to a + * common upper figure and lets Ollama shorten anything the loaded model + * still cannot fit. Without this, a model with a small context rejects + * the whole batch. + */ + truncate: true, + }, + parse: (json) => (json as OllamaEmbeddingResponse).embeddings, + parseTokens: (json) => (json as OllamaEmbeddingResponse).prompt_eval_count, + }), +}) diff --git a/apps/sim/lib/embeddings/providers/providers.test.ts b/apps/sim/lib/embeddings/providers/providers.test.ts index e2382560bb7..2713bc9fc22 100644 --- a/apps/sim/lib/embeddings/providers/providers.test.ts +++ b/apps/sim/lib/embeddings/providers/providers.test.ts @@ -7,6 +7,7 @@ import { createCohereAdapter, createGeminiAdapter, createMistralAdapter, + createOllamaAdapter, createOpenAIAdapter, createOpenRouterAdapter, } from '@/lib/embeddings/providers' @@ -223,3 +224,43 @@ describe('Mistral adapter', () => { expect(request.parseTokens?.(json)).toBe(4) }) }) + +describe('Ollama adapter', () => { + const adapter = createOllamaAdapter({ + modelName: 'nomic-embed-text', + baseUrl: 'http://ollama.internal:11434', + nativeDimensions: 1536, + }) + + it('posts the batch to the configured server with no credential header', () => { + const request = adapter.buildRequest({ inputs: INPUTS, taskType: 'document' }) + expect(request.apiUrl).toBe('http://ollama.internal:11434/api/embed') + expect(request.headers).toEqual({ 'Content-Type': 'application/json' }) + expect(request.body).toEqual({ + model: 'nomic-embed-text', + input: INPUTS, + truncate: true, + }) + }) + + it('never forwards dimensions, which older servers ignore rather than reject', () => { + const request = adapter.buildRequest({ inputs: INPUTS, taskType: 'document', dimensions: 768 }) + expect(request.body).not.toHaveProperty('dimensions') + }) + + it('parses vectors and the tokens Ollama reports embedding', () => { + const request = adapter.buildRequest({ inputs: INPUTS, taskType: 'document' }) + const json = { + embeddings: [ + [1, 2], + [3, 4], + ], + prompt_eval_count: 9, + } + expect(request.parse(json)).toEqual([ + [1, 2], + [3, 4], + ]) + expect(request.parseTokens?.(json)).toBe(9) + }) +}) diff --git a/apps/sim/lib/embeddings/types.ts b/apps/sim/lib/embeddings/types.ts index f7b6b647110..3efd6c20b74 100644 --- a/apps/sim/lib/embeddings/types.ts +++ b/apps/sim/lib/embeddings/types.ts @@ -11,6 +11,7 @@ export type EmbeddingProviderKind = | 'gemini' | 'cohere' | 'mistral' + | 'ollama' /** * Providers a catalog model can belong to. Azure OpenAI and OpenRouter are @@ -18,8 +19,15 @@ export type EmbeddingProviderKind = */ export type EmbeddingCatalogProvider = Exclude +/** + * Catalog providers reached with an API key. Ollama is a self-hosted server + * addressed by URL and takes no credential, so every key-resolution table is + * keyed by this narrower type rather than carrying a meaningless Ollama entry. + */ +export type KeyedEmbeddingProvider = Exclude + /** Provider id for `estimateTokenCount` so token counts match the embedding provider's tokenization. */ -export type TokenizerProviderId = 'openai' | 'google' | 'cohere' | 'mistral' +export type TokenizerProviderId = 'openai' | 'google' | 'cohere' | 'mistral' | 'ollama' /** * What the embedding will be used for. Providers that support task-conditioned @@ -55,14 +63,29 @@ export interface EmbeddingProviderAdapter { maxItemsPerRequest?: number } -export interface EmbeddingAdapterContext { +/** What every adapter needs regardless of how its provider is reached. */ +export interface EmbeddingAdapterIdentity { /** Model name as the provider expects it on the wire (an Azure deployment name for Azure). */ modelName: string - apiKey: string /** Model's un-reduced dimensionality, so adapters can detect a Matryoshka reduction. */ nativeDimensions: number } +export interface EmbeddingAdapterContext extends EmbeddingAdapterIdentity { + apiKey: string +} + +/** + * Ollama is addressed by server URL and authenticates with nothing, so it needs + * routing fields no other provider takes and holds no credential at all. + * Declared as its own context rather than as an optional `baseUrl` on the shared + * one, so the adapter cannot be constructed without a server to talk to. + */ +export interface OllamaEmbeddingAdapterContext extends EmbeddingAdapterIdentity { + /** Origin of the Ollama server, without a trailing slash. */ + baseUrl: string +} + /** * Azure selects the model by deployment name in the URL, so it needs routing * fields no other provider takes. Declared as its own context rather than as @@ -74,8 +97,9 @@ export interface AzureEmbeddingAdapterContext extends EmbeddingAdapterContext { apiVersion: string } -export type EmbeddingAdapterFactory = - (context: Ctx) => EmbeddingProviderAdapter +export type EmbeddingAdapterFactory< + Ctx extends EmbeddingAdapterIdentity = EmbeddingAdapterContext, +> = (context: Ctx) => EmbeddingProviderAdapter export interface EmbedOptions { /** Cancels provider requests, retry waits, and remaining batches. */ diff --git a/apps/sim/lib/integrations/availability.server.test.ts b/apps/sim/lib/integrations/availability.server.test.ts index 4230532dc94..e15fd2eab51 100644 --- a/apps/sim/lib/integrations/availability.server.test.ts +++ b/apps/sim/lib/integrations/availability.server.test.ts @@ -21,7 +21,10 @@ import { isIntegrationDeploymentAvailable, isIntegrationDeploymentAvailableForVisibility, } from '@/lib/integrations/availability.server' -import { SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID } from '@/lib/integrations/service-account-metadata' +import { + CREDENTIAL_CONFIGURED_OAUTH_SERVICE_IDS, + SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID, +} from '@/lib/integrations/service-account-metadata' import type { Integration } from '@/lib/integrations/types' import { getServiceConfigByServiceId } from '@/lib/oauth/utils' @@ -81,6 +84,17 @@ describe('integration availability', () => { }) }) + it('keeps credential-configured OAuth integrations independent of deployment secrets', () => { + expect(availabilityFor('quickbooks')).toMatchObject({ + state: 'ready', + oauthAvailable: true, + serviceAccountAvailable: false, + missingFields: [], + }) + expect(availabilityFor('quickbooks').setupCommand).toBeUndefined() + expect(resolveOAuthClientCapabilityId('quickbooks')).toBeNull() + }) + it('keeps custom bots available when the Slack OAuth client is partial', () => { expect(availabilityFor('slack_v2', { SLACK_CLIENT_ID: 'client' })).toMatchObject({ state: 'limited', @@ -176,12 +190,16 @@ describe('integration availability', () => { ), ] const expectedServiceAccountIds: Record = {} + const expectedCredentialConfiguredServiceIds: string[] = [] for (const oauthServiceId of oauthServiceIds) { const canonical = getServiceConfigByServiceId(oauthServiceId) if (!canonical) throw new Error(`Missing canonical OAuth service ${oauthServiceId}`) const projected = SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[oauthServiceId] expect(projected?.providerId, oauthServiceId).toBe(canonical.serviceAccountProviderId) + if (canonical.clientConfiguration) { + expectedCredentialConfiguredServiceIds.push(oauthServiceId) + } if (canonical.serviceAccountProviderId) { expectedServiceAccountIds[oauthServiceId] = canonical.serviceAccountProviderId } @@ -194,6 +212,9 @@ describe('integration availability', () => { ) ) ).toEqual(expectedServiceAccountIds) + expect([...CREDENTIAL_CONFIGURED_OAUTH_SERVICE_IDS].sort()).toEqual( + expectedCredentialConfiguredServiceIds.sort() + ) expect(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID.slack.deploymentRequirement).toBeUndefined() expect(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID.trello.deploymentRequirement).toBe( 'oauth-client' diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 34b99c0e128..a9318bcd494 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -44,6 +44,7 @@ import { ClickUpIcon, CloudFormationIcon, CloudflareIcon, + CloudTrailIcon, CloudWatchIcon, CodePipelineIcon, ConfluenceIcon, @@ -192,6 +193,7 @@ import { PulseIcon, QdrantIcon, QuartrIcon, + QuickBooksIcon, QuiverIcon, RabbitmqIcon, RailwayIcon, @@ -232,6 +234,7 @@ import { SportmonksIcon, SQSIcon, SquareIcon, + SSMIcon, SshIcon, STSIcon, STTIcon, @@ -314,6 +317,7 @@ export const blockTypeToIconMap: Record = { clickup: ClickUpIcon, cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, + cloudtrail: CloudTrailIcon, cloudwatch: CloudWatchIcon, codepipeline: CodePipelineIcon, confluence: ConfluenceIcon, @@ -481,6 +485,7 @@ export const blockTypeToIconMap: Record = { pulse_v2: PulseIcon, qdrant: QdrantIcon, quartr: QuartrIcon, + quickbooks: QuickBooksIcon, quiver: QuiverIcon, rabbitmq: RabbitmqIcon, railway: RailwayIcon, @@ -525,6 +530,7 @@ export const blockTypeToIconMap: Record = { sqs: SQSIcon, square: SquareIcon, ssh: SshIcon, + ssm: SSMIcon, stagehand: StagehandIcon, stripe: StripeIcon, sts: STSIcon, diff --git a/apps/sim/lib/internal/cloudtrail/client.ts b/apps/sim/lib/internal/cloudtrail/client.ts new file mode 100644 index 00000000000..3d5b13d7bd9 --- /dev/null +++ b/apps/sim/lib/internal/cloudtrail/client.ts @@ -0,0 +1,36 @@ +import { CloudTrailClient } from '@aws-sdk/client-cloudtrail' + +export interface CloudTrailConnectionConfig { + region: string + accessKeyId: string + secretAccessKey: string +} + +/** + * Attempts allowed for `LookupEvents`, which AWS throttles at two requests per + * second per account per Region. Paired with adaptive retry mode so the SDK's + * client-side rate limiter absorbs `ThrottlingException` with exponential + * backoff and jitter instead of failing the tool run. + */ +const THROTTLE_SENSITIVE_MAX_ATTEMPTS = 6 + +export interface CreateCloudTrailClientOptions { + /** Use AWS adaptive retry mode with a raised attempt ceiling. */ + throttleSensitive?: boolean +} + +export function createCloudTrailClient( + config: CloudTrailConnectionConfig, + options: CreateCloudTrailClientOptions = {} +): CloudTrailClient { + return new CloudTrailClient({ + region: config.region, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + ...(options.throttleSensitive + ? { retryMode: 'adaptive', maxAttempts: THROTTLE_SENSITIVE_MAX_ATTEMPTS } + : {}), + }) +} diff --git a/apps/sim/lib/internal/cloudtrail/execute-tool.test.ts b/apps/sim/lib/internal/cloudtrail/execute-tool.test.ts new file mode 100644 index 00000000000..ffbb518277a --- /dev/null +++ b/apps/sim/lib/internal/cloudtrail/execute-tool.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockOperations = vi.hoisted(() => ({ + executeCloudtrailCancelQuery: vi.fn(), + executeCloudtrailDescribeQuery: vi.fn(), + executeCloudtrailDescribeTrails: vi.fn(), + executeCloudtrailGetEventDataStore: vi.fn(), + executeCloudtrailGetEventSelectors: vi.fn(), + executeCloudtrailGetInsightSelectors: vi.fn(), + executeCloudtrailGetQueryResults: vi.fn(), + executeCloudtrailGetTrail: vi.fn(), + executeCloudtrailGetTrailStatus: vi.fn(), + executeCloudtrailListEventDataStores: vi.fn(), + executeCloudtrailListTags: vi.fn(), + executeCloudtrailListTrails: vi.fn(), + executeCloudtrailLookupEvents: vi.fn(), + executeCloudtrailStartQuery: vi.fn(), +})) + +vi.mock('@/lib/internal/cloudtrail/operations', () => mockOperations) + +import { executeCloudtrailTool } from '@/lib/internal/cloudtrail/execute-tool' +import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' + +const CONNECTION = { + region: 'us-east-1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', +} + +const TRAIL_ARN = 'arn:aws:cloudtrail:us-east-1:123456789012:trail/my-trail' +const EVENT_DATA_STORE_ARN = + 'arn:aws:cloudtrail:us-east-1:123456789012:eventdatastore/11111111-2222-3333-4444-555555555555' +const QUERY_ID = '11111111-2222-3333-4444-555555555555' + +function createRequest( + overrides: Partial = {} +): InternalToolOperationCall { + return { + toolId: 'cloudtrail_list_trails', + input: CONNECTION, + headers: new Headers({ 'content-type': 'application/json' }), + context: { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + metadata: {}, + }, + requestId: 'request-1', + ...overrides, + } +} + +const TOOL_CASES = [ + [ + 'cloudtrail_cancel_query', + { ...CONNECTION, queryId: QUERY_ID }, + mockOperations.executeCloudtrailCancelQuery, + ], + [ + 'cloudtrail_describe_query', + { ...CONNECTION, queryId: QUERY_ID }, + mockOperations.executeCloudtrailDescribeQuery, + ], + ['cloudtrail_describe_trails', CONNECTION, mockOperations.executeCloudtrailDescribeTrails], + [ + 'cloudtrail_get_event_data_store', + { ...CONNECTION, eventDataStore: EVENT_DATA_STORE_ARN }, + mockOperations.executeCloudtrailGetEventDataStore, + ], + [ + 'cloudtrail_get_event_selectors', + { ...CONNECTION, trailName: 'my-trail' }, + mockOperations.executeCloudtrailGetEventSelectors, + ], + [ + 'cloudtrail_get_insight_selectors', + { ...CONNECTION, trailName: 'my-trail' }, + mockOperations.executeCloudtrailGetInsightSelectors, + ], + [ + 'cloudtrail_get_query_results', + { ...CONNECTION, queryId: QUERY_ID }, + mockOperations.executeCloudtrailGetQueryResults, + ], + [ + 'cloudtrail_get_trail', + { ...CONNECTION, name: TRAIL_ARN }, + mockOperations.executeCloudtrailGetTrail, + ], + [ + 'cloudtrail_get_trail_status', + { ...CONNECTION, name: 'my-trail' }, + mockOperations.executeCloudtrailGetTrailStatus, + ], + [ + 'cloudtrail_list_event_data_stores', + CONNECTION, + mockOperations.executeCloudtrailListEventDataStores, + ], + [ + 'cloudtrail_list_tags', + { ...CONNECTION, resourceIdList: [TRAIL_ARN] }, + mockOperations.executeCloudtrailListTags, + ], + ['cloudtrail_list_trails', CONNECTION, mockOperations.executeCloudtrailListTrails], + ['cloudtrail_lookup_events', CONNECTION, mockOperations.executeCloudtrailLookupEvents], + [ + 'cloudtrail_start_query', + { ...CONNECTION, queryStatement: 'SELECT eventID FROM eds LIMIT 1' }, + mockOperations.executeCloudtrailStartQuery, + ], +] as const + +describe('executeCloudtrailTool', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each(TOOL_CASES)('routes %s to its operation', async (toolId, input, operation) => { + operation.mockResolvedValue({ success: true, output: {} }) + + const response = await executeCloudtrailTool(createRequest({ toolId, input })) + + expect(response.status).toBe(200) + expect(operation).toHaveBeenCalledTimes(1) + }) + + it('rejects an unsupported tool id', async () => { + const response = await executeCloudtrailTool(createRequest({ toolId: 'cloudtrail_nope' })) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: 'Unsupported CloudTrail tool: cloudtrail_nope', + }) + }) + + it('rejects input that fails contract validation before calling the operation', async () => { + const response = await executeCloudtrailTool( + createRequest({ + toolId: 'cloudtrail_get_query_results', + input: { ...CONNECTION, queryId: 'not-a-query-id' }, + }) + ) + + expect(response.status).toBe(400) + expect(mockOperations.executeCloudtrailGetQueryResults).not.toHaveBeenCalled() + }) + + it('rejects a lookup that names an attribute key without a value', async () => { + const response = await executeCloudtrailTool( + createRequest({ + toolId: 'cloudtrail_lookup_events', + input: { ...CONNECTION, attributeKey: 'Username' }, + }) + ) + + expect(response.status).toBe(400) + expect(mockOperations.executeCloudtrailLookupEvents).not.toHaveBeenCalled() + }) + + it('rejects a region outside the documented AWS partitions', async () => { + const response = await executeCloudtrailTool( + createRequest({ toolId: 'cloudtrail_list_trails', input: { ...CONNECTION, region: 'nope' } }) + ) + + expect(response.status).toBe(400) + expect(mockOperations.executeCloudtrailListTrails).not.toHaveBeenCalled() + }) + + it('accepts GovCloud and China partition regions', async () => { + mockOperations.executeCloudtrailListTrails.mockResolvedValue({ success: true, output: {} }) + + for (const region of ['us-gov-west-1', 'cn-north-1']) { + const response = await executeCloudtrailTool( + createRequest({ toolId: 'cloudtrail_list_trails', input: { ...CONNECTION, region } }) + ) + expect(response.status).toBe(200) + } + + expect(mockOperations.executeCloudtrailListTrails).toHaveBeenCalledTimes(2) + }) + + it('surfaces an operation failure as a 500 with its message', async () => { + mockOperations.executeCloudtrailLookupEvents.mockRejectedValue(new Error('Rate exceeded')) + + const response = await executeCloudtrailTool( + createRequest({ toolId: 'cloudtrail_lookup_events' }) + ) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Rate exceeded' }) + }) +}) diff --git a/apps/sim/lib/internal/cloudtrail/execute-tool.ts b/apps/sim/lib/internal/cloudtrail/execute-tool.ts new file mode 100644 index 00000000000..b450f3a84ad --- /dev/null +++ b/apps/sim/lib/internal/cloudtrail/execute-tool.ts @@ -0,0 +1,179 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { AnyApiRouteContract, ContractBody } from '@/lib/api/contracts' +import { awsCloudtrailCancelQueryContract } from '@/lib/api/contracts/tools/aws/cloudtrail-cancel-query' +import { awsCloudtrailDescribeQueryContract } from '@/lib/api/contracts/tools/aws/cloudtrail-describe-query' +import { awsCloudtrailDescribeTrailsContract } from '@/lib/api/contracts/tools/aws/cloudtrail-describe-trails' +import { awsCloudtrailGetEventDataStoreContract } from '@/lib/api/contracts/tools/aws/cloudtrail-get-event-data-store' +import { awsCloudtrailGetEventSelectorsContract } from '@/lib/api/contracts/tools/aws/cloudtrail-get-event-selectors' +import { awsCloudtrailGetInsightSelectorsContract } from '@/lib/api/contracts/tools/aws/cloudtrail-get-insight-selectors' +import { awsCloudtrailGetQueryResultsContract } from '@/lib/api/contracts/tools/aws/cloudtrail-get-query-results' +import { awsCloudtrailGetTrailContract } from '@/lib/api/contracts/tools/aws/cloudtrail-get-trail' +import { awsCloudtrailGetTrailStatusContract } from '@/lib/api/contracts/tools/aws/cloudtrail-get-trail-status' +import { awsCloudtrailListEventDataStoresContract } from '@/lib/api/contracts/tools/aws/cloudtrail-list-event-data-stores' +import { awsCloudtrailListTagsContract } from '@/lib/api/contracts/tools/aws/cloudtrail-list-tags' +import { awsCloudtrailListTrailsContract } from '@/lib/api/contracts/tools/aws/cloudtrail-list-trails' +import { awsCloudtrailLookupEventsContract } from '@/lib/api/contracts/tools/aws/cloudtrail-lookup-events' +import { awsCloudtrailStartQueryContract } from '@/lib/api/contracts/tools/aws/cloudtrail-start-query' +import { + executeCloudtrailCancelQuery, + executeCloudtrailDescribeQuery, + executeCloudtrailDescribeTrails, + executeCloudtrailGetEventDataStore, + executeCloudtrailGetEventSelectors, + executeCloudtrailGetInsightSelectors, + executeCloudtrailGetQueryResults, + executeCloudtrailGetTrail, + executeCloudtrailGetTrailStatus, + executeCloudtrailListEventDataStores, + executeCloudtrailListTags, + executeCloudtrailListTrails, + executeCloudtrailLookupEvents, + executeCloudtrailStartQuery, +} from '@/lib/internal/cloudtrail/operations' +import { parseInternalToolInput } from '@/lib/internal/tool-operations/parse-input' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +async function executeOperation( + contract: C, + input: unknown, + execute: (input: ContractBody, signal?: AbortSignal) => Promise, + fallbackError: string, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const parsed = parseInternalToolInput(contract, input) + if (!parsed.success) return parsed.response + + try { + const result = await execute(parsed.data, signal) + signal?.throwIfAborted() + return Response.json(result) + } catch (error) { + signal?.throwIfAborted() + return Response.json({ error: getErrorMessage(error, fallbackError) }, { status: 500 }) + } +} + +export const executeCloudtrailTool: InternalToolOperationHandler = async ({ + toolId, + input, + signal, +}) => { + signal?.throwIfAborted() + switch (toolId) { + case 'cloudtrail_cancel_query': + return executeOperation( + awsCloudtrailCancelQueryContract, + input, + executeCloudtrailCancelQuery, + 'Failed to cancel CloudTrail Lake query', + signal + ) + case 'cloudtrail_describe_query': + return executeOperation( + awsCloudtrailDescribeQueryContract, + input, + executeCloudtrailDescribeQuery, + 'Failed to describe CloudTrail Lake query', + signal + ) + case 'cloudtrail_describe_trails': + return executeOperation( + awsCloudtrailDescribeTrailsContract, + input, + executeCloudtrailDescribeTrails, + 'Failed to describe CloudTrail trails', + signal + ) + case 'cloudtrail_get_event_data_store': + return executeOperation( + awsCloudtrailGetEventDataStoreContract, + input, + executeCloudtrailGetEventDataStore, + 'Failed to get CloudTrail event data store', + signal + ) + case 'cloudtrail_get_event_selectors': + return executeOperation( + awsCloudtrailGetEventSelectorsContract, + input, + executeCloudtrailGetEventSelectors, + 'Failed to get CloudTrail event selectors', + signal + ) + case 'cloudtrail_get_insight_selectors': + return executeOperation( + awsCloudtrailGetInsightSelectorsContract, + input, + executeCloudtrailGetInsightSelectors, + 'Failed to get CloudTrail Insights selectors', + signal + ) + case 'cloudtrail_get_query_results': + return executeOperation( + awsCloudtrailGetQueryResultsContract, + input, + executeCloudtrailGetQueryResults, + 'Failed to get CloudTrail Lake query results', + signal + ) + case 'cloudtrail_get_trail': + return executeOperation( + awsCloudtrailGetTrailContract, + input, + executeCloudtrailGetTrail, + 'Failed to get CloudTrail trail', + signal + ) + case 'cloudtrail_get_trail_status': + return executeOperation( + awsCloudtrailGetTrailStatusContract, + input, + executeCloudtrailGetTrailStatus, + 'Failed to get CloudTrail trail status', + signal + ) + case 'cloudtrail_list_event_data_stores': + return executeOperation( + awsCloudtrailListEventDataStoresContract, + input, + executeCloudtrailListEventDataStores, + 'Failed to list CloudTrail event data stores', + signal + ) + case 'cloudtrail_list_tags': + return executeOperation( + awsCloudtrailListTagsContract, + input, + executeCloudtrailListTags, + 'Failed to list CloudTrail resource tags', + signal + ) + case 'cloudtrail_list_trails': + return executeOperation( + awsCloudtrailListTrailsContract, + input, + executeCloudtrailListTrails, + 'Failed to list CloudTrail trails', + signal + ) + case 'cloudtrail_lookup_events': + return executeOperation( + awsCloudtrailLookupEventsContract, + input, + executeCloudtrailLookupEvents, + 'Failed to look up CloudTrail events', + signal + ) + case 'cloudtrail_start_query': + return executeOperation( + awsCloudtrailStartQueryContract, + input, + executeCloudtrailStartQuery, + 'Failed to start CloudTrail Lake query', + signal + ) + default: + return Response.json({ error: `Unsupported CloudTrail tool: ${toolId}` }, { status: 500 }) + } +} diff --git a/apps/sim/lib/internal/cloudtrail/operations.test.ts b/apps/sim/lib/internal/cloudtrail/operations.test.ts new file mode 100644 index 00000000000..62b4f012a2a --- /dev/null +++ b/apps/sim/lib/internal/cloudtrail/operations.test.ts @@ -0,0 +1,167 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createCloudTrailClient: vi.fn(), + destroy: vi.fn(), + send: vi.fn(), +})) + +vi.mock('@/lib/internal/cloudtrail/client', () => ({ + createCloudTrailClient: mocks.createCloudTrailClient, +})) + +import { + executeCloudtrailCancelQuery, + executeCloudtrailDescribeTrails, + executeCloudtrailGetQueryResults, + executeCloudtrailListTrails, + executeCloudtrailLookupEvents, +} from '@/lib/internal/cloudtrail/operations' + +const CONNECTION = { + region: 'eu-west-2', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', +} + +describe('CloudTrail operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createCloudTrailClient.mockReturnValue({ send: mocks.send, destroy: mocks.destroy }) + }) + + it('parses CloudTrailEvent into a structured record and forwards cancellation', async () => { + const controller = new AbortController() + mocks.send.mockResolvedValue({ + Events: [ + { + EventId: 'event-1', + EventName: 'ConsoleLogin', + ReadOnly: 'false', + AccessKeyId: 'AKIAEXAMPLE', + EventTime: new Date('2026-09-01T12:00:00.000Z'), + EventSource: 'signin.amazonaws.com', + Username: 'alice', + Resources: [{ ResourceType: 'AWS::S3::Bucket', ResourceName: 'my-bucket' }], + CloudTrailEvent: JSON.stringify({ + sourceIPAddress: '203.0.113.10', + userIdentity: { type: 'IAMUser', arn: 'arn:aws:iam::123456789012:user/alice' }, + }), + }, + ], + NextToken: 'next-page', + }) + + const result = await executeCloudtrailLookupEvents( + { ...CONNECTION, attributeKey: 'Username', attributeValue: 'alice', maxResults: 10 }, + controller.signal + ) + + const [command, options] = mocks.send.mock.calls[0] + expect(command.input).toEqual({ + LookupAttributes: [{ AttributeKey: 'Username', AttributeValue: 'alice' }], + MaxResults: 10, + }) + expect(options).toEqual({ abortSignal: controller.signal }) + + expect(result.output.events[0].cloudTrailEvent).toEqual({ + sourceIPAddress: '203.0.113.10', + userIdentity: { type: 'IAMUser', arn: 'arn:aws:iam::123456789012:user/alice' }, + }) + expect(result.output.events[0].cloudTrailEventRaw).toBeNull() + expect(result.output.events[0].eventTime).toBe('2026-09-01T12:00:00.000Z') + expect(result.output.nextToken).toBe('next-page') + expect(mocks.destroy).toHaveBeenCalledTimes(1) + }) + + it('preserves an unparseable CloudTrailEvent as the raw string rather than dropping it', async () => { + mocks.send.mockResolvedValue({ Events: [{ EventId: 'event-1', CloudTrailEvent: 'not json' }] }) + + const result = await executeCloudtrailLookupEvents(CONNECTION) + + expect(result.output.events[0].cloudTrailEvent).toBeNull() + expect(result.output.events[0].cloudTrailEventRaw).toBe('not json') + }) + + it('uses adaptive retry only for the throttle-limited lookup operation', async () => { + mocks.send.mockResolvedValue({ Trails: [] }) + await executeCloudtrailListTrails(CONNECTION) + expect(mocks.createCloudTrailClient).toHaveBeenLastCalledWith( + expect.objectContaining(CONNECTION), + undefined + ) + + mocks.send.mockResolvedValue({ Events: [] }) + await executeCloudtrailLookupEvents(CONNECTION) + expect(mocks.createCloudTrailClient).toHaveBeenLastCalledWith( + expect.objectContaining(CONNECTION), + { throttleSensitive: true } + ) + }) + + it('threads the caller region through without overriding it', async () => { + mocks.send.mockResolvedValue({ Trails: [] }) + + await executeCloudtrailListTrails({ ...CONNECTION, region: 'us-gov-west-1' }) + + expect(mocks.createCloudTrailClient).toHaveBeenCalledWith( + expect.objectContaining({ region: 'us-gov-west-1' }), + undefined + ) + }) + + it('omits includeShadowTrails when unset so the AWS default applies', async () => { + mocks.send.mockResolvedValue({ trailList: [] }) + + await executeCloudtrailDescribeTrails(CONNECTION) + + expect(mocks.send.mock.calls[0][0].input).toEqual({}) + }) + + it('flattens Lake result rows into one object per row', async () => { + mocks.send.mockResolvedValue({ + QueryStatus: 'FINISHED', + QueryResultRows: [ + [{ eventName: 'ConsoleLogin' }, { eventCount: '12' }], + [{ eventName: 'AssumeRole' }, { eventCount: '4' }], + ], + QueryStatistics: { ResultsCount: 2, TotalResultsCount: 2, BytesScanned: 1024 }, + NextToken: 'next-page', + }) + + const result = await executeCloudtrailGetQueryResults({ + ...CONNECTION, + queryId: '11111111-2222-3333-4444-555555555555', + maxQueryResults: 2, + }) + + expect(result.output.rows).toEqual([ + { eventName: 'ConsoleLogin', eventCount: '12' }, + { eventName: 'AssumeRole', eventCount: '4' }, + ]) + expect(result.output.totalResultsCount).toBe(2) + expect(result.output.nextToken).toBe('next-page') + }) + + it('reports the status AWS returned for a cancellation without inventing a terminal state', async () => { + mocks.send.mockResolvedValue({ QueryId: 'query-1' }) + + const result = await executeCloudtrailCancelQuery({ + ...CONNECTION, + queryId: '11111111-2222-3333-4444-555555555555', + }) + + expect(result.output.queryStatus).toBeNull() + expect(result.output.queryId).toBe('query-1') + }) + + it('destroys the client when the AWS call throws', async () => { + mocks.send.mockRejectedValue(new Error('ThrottlingException')) + + await expect(executeCloudtrailLookupEvents(CONNECTION)).rejects.toThrow('ThrottlingException') + expect(mocks.destroy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/internal/cloudtrail/operations.ts b/apps/sim/lib/internal/cloudtrail/operations.ts new file mode 100644 index 00000000000..ad169ea7566 --- /dev/null +++ b/apps/sim/lib/internal/cloudtrail/operations.ts @@ -0,0 +1,529 @@ +import { + type AdvancedEventSelector, + CancelQueryCommand, + type CloudTrailClient, + DescribeQueryCommand, + DescribeTrailsCommand, + GetEventDataStoreCommand, + GetEventSelectorsCommand, + GetInsightSelectorsCommand, + GetQueryResultsCommand, + GetTrailCommand, + GetTrailStatusCommand, + ListEventDataStoresCommand, + ListTagsCommand, + ListTrailsCommand, + LookupEventsCommand, + StartQueryCommand, + type Trail, +} from '@aws-sdk/client-cloudtrail' +import { createLogger } from '@sim/logger' +import type { AwsCloudtrailCancelQueryBody } from '@/lib/api/contracts/tools/aws/cloudtrail-cancel-query' +import type { AwsCloudtrailDescribeQueryBody } from '@/lib/api/contracts/tools/aws/cloudtrail-describe-query' +import type { AwsCloudtrailDescribeTrailsBody } from '@/lib/api/contracts/tools/aws/cloudtrail-describe-trails' +import type { AwsCloudtrailGetEventDataStoreBody } from '@/lib/api/contracts/tools/aws/cloudtrail-get-event-data-store' +import type { AwsCloudtrailGetEventSelectorsBody } from '@/lib/api/contracts/tools/aws/cloudtrail-get-event-selectors' +import type { AwsCloudtrailGetInsightSelectorsBody } from '@/lib/api/contracts/tools/aws/cloudtrail-get-insight-selectors' +import type { AwsCloudtrailGetQueryResultsBody } from '@/lib/api/contracts/tools/aws/cloudtrail-get-query-results' +import type { AwsCloudtrailGetTrailBody } from '@/lib/api/contracts/tools/aws/cloudtrail-get-trail' +import type { AwsCloudtrailGetTrailStatusBody } from '@/lib/api/contracts/tools/aws/cloudtrail-get-trail-status' +import type { AwsCloudtrailListEventDataStoresBody } from '@/lib/api/contracts/tools/aws/cloudtrail-list-event-data-stores' +import type { AwsCloudtrailListTagsBody } from '@/lib/api/contracts/tools/aws/cloudtrail-list-tags' +import type { AwsCloudtrailListTrailsBody } from '@/lib/api/contracts/tools/aws/cloudtrail-list-trails' +import type { AwsCloudtrailLookupEventsBody } from '@/lib/api/contracts/tools/aws/cloudtrail-lookup-events' +import type { AwsCloudtrailStartQueryBody } from '@/lib/api/contracts/tools/aws/cloudtrail-start-query' +import { + type CloudTrailConnectionConfig, + type CreateCloudTrailClientOptions, + createCloudTrailClient, +} from '@/lib/internal/cloudtrail/client' + +const logger = createLogger('CloudTrailOperations') + +async function withCloudTrailClient( + input: CloudTrailConnectionConfig, + execute: (client: CloudTrailClient) => Promise, + options?: CreateCloudTrailClientOptions +): Promise { + const client = createCloudTrailClient(input, options) + try { + return await execute(client) + } finally { + client.destroy() + } +} + +function mapAdvancedEventSelectors(selectors: AdvancedEventSelector[] | undefined) { + return (selectors ?? []).map((selector) => ({ + name: selector.Name ?? null, + fieldSelectors: (selector.FieldSelectors ?? []).map((field) => ({ + field: field.Field ?? '', + equals: field.Equals ?? [], + startsWith: field.StartsWith ?? [], + endsWith: field.EndsWith ?? [], + notEquals: field.NotEquals ?? [], + notStartsWith: field.NotStartsWith ?? [], + notEndsWith: field.NotEndsWith ?? [], + })), + })) +} + +function mapTrail(trail: Trail | undefined) { + return { + name: trail?.Name ?? '', + s3BucketName: trail?.S3BucketName ?? null, + s3KeyPrefix: trail?.S3KeyPrefix ?? null, + snsTopicName: trail?.SnsTopicName ?? null, + snsTopicArn: trail?.SnsTopicARN ?? null, + includeGlobalServiceEvents: trail?.IncludeGlobalServiceEvents ?? null, + isMultiRegionTrail: trail?.IsMultiRegionTrail ?? null, + homeRegion: trail?.HomeRegion ?? null, + trailArn: trail?.TrailARN ?? null, + logFileValidationEnabled: trail?.LogFileValidationEnabled ?? null, + cloudWatchLogsLogGroupArn: trail?.CloudWatchLogsLogGroupArn ?? null, + cloudWatchLogsRoleArn: trail?.CloudWatchLogsRoleArn ?? null, + kmsKeyId: trail?.KmsKeyId ?? null, + hasCustomEventSelectors: trail?.HasCustomEventSelectors ?? null, + hasInsightSelectors: trail?.HasInsightSelectors ?? null, + isOrganizationTrail: trail?.IsOrganizationTrail ?? null, + } +} + +/** + * `LookupEvents` returns the full event record as a JSON-encoded string in + * `CloudTrailEvent`. Downstream agents want the structured record (userIdentity, + * sourceIPAddress, requestParameters, errorCode), so it is parsed into + * `cloudTrailEvent`. If parsing ever fails the original string is preserved in + * `cloudTrailEventRaw` so no data is lost. + */ +function parseCloudTrailEvent(raw: string | undefined): { + cloudTrailEvent: Record | null + cloudTrailEventRaw: string | null +} { + if (!raw) return { cloudTrailEvent: null, cloudTrailEventRaw: null } + try { + const parsed: unknown = JSON.parse(raw) + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { cloudTrailEvent: parsed as Record, cloudTrailEventRaw: null } + } + } catch { + logger.warn('Failed to parse CloudTrailEvent payload; returning the raw string') + return { cloudTrailEvent: null, cloudTrailEventRaw: raw } + } + return { cloudTrailEvent: null, cloudTrailEventRaw: raw } +} + +export async function executeCloudtrailLookupEvents( + input: AwsCloudtrailLookupEventsBody, + signal?: AbortSignal +) { + return withCloudTrailClient( + input, + async (client) => { + const response = await client.send( + new LookupEventsCommand({ + ...(input.attributeKey && input.attributeValue + ? { + LookupAttributes: [ + { AttributeKey: input.attributeKey, AttributeValue: input.attributeValue }, + ], + } + : {}), + ...(input.startTime ? { StartTime: new Date(input.startTime) } : {}), + ...(input.endTime ? { EndTime: new Date(input.endTime) } : {}), + ...(input.eventCategory ? { EventCategory: input.eventCategory } : {}), + ...(input.maxResults !== undefined ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + return { + success: true, + output: { + events: (response.Events ?? []).map((event) => ({ + eventId: event.EventId ?? null, + eventName: event.EventName ?? null, + readOnly: event.ReadOnly ?? null, + accessKeyId: event.AccessKeyId ?? null, + eventTime: event.EventTime?.toISOString() ?? null, + eventSource: event.EventSource ?? null, + username: event.Username ?? null, + resources: (event.Resources ?? []).map((resource) => ({ + resourceType: resource.ResourceType ?? null, + resourceName: resource.ResourceName ?? null, + })), + ...parseCloudTrailEvent(event.CloudTrailEvent), + })), + nextToken: response.NextToken ?? null, + }, + } + }, + { throttleSensitive: true } + ) +} + +export async function executeCloudtrailDescribeTrails( + input: AwsCloudtrailDescribeTrailsBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new DescribeTrailsCommand({ + ...(input.trailNameList ? { trailNameList: input.trailNameList } : {}), + ...(input.includeShadowTrails !== undefined + ? { includeShadowTrails: input.includeShadowTrails } + : {}), + }), + { abortSignal: signal } + ) + return { + success: true, + output: { trails: (response.trailList ?? []).map(mapTrail) }, + } + }) +} + +export async function executeCloudtrailGetTrail( + input: AwsCloudtrailGetTrailBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send(new GetTrailCommand({ Name: input.name }), { + abortSignal: signal, + }) + if (!response.Trail) throw new Error('No trail data returned') + return { success: true, output: mapTrail(response.Trail) } + }) +} + +export async function executeCloudtrailGetTrailStatus( + input: AwsCloudtrailGetTrailStatusBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send(new GetTrailStatusCommand({ Name: input.name }), { + abortSignal: signal, + }) + return { + success: true, + output: { + isLogging: response.IsLogging ?? null, + latestDeliveryError: response.LatestDeliveryError ?? null, + latestDeliveryTime: response.LatestDeliveryTime?.toISOString() ?? null, + latestNotificationError: response.LatestNotificationError ?? null, + latestNotificationTime: response.LatestNotificationTime?.toISOString() ?? null, + latestCloudWatchLogsDeliveryError: response.LatestCloudWatchLogsDeliveryError ?? null, + latestCloudWatchLogsDeliveryTime: + response.LatestCloudWatchLogsDeliveryTime?.toISOString() ?? null, + latestDigestDeliveryError: response.LatestDigestDeliveryError ?? null, + latestDigestDeliveryTime: response.LatestDigestDeliveryTime?.toISOString() ?? null, + startLoggingTime: response.StartLoggingTime?.toISOString() ?? null, + stopLoggingTime: response.StopLoggingTime?.toISOString() ?? null, + }, + } + }) +} + +export async function executeCloudtrailListTrails( + input: AwsCloudtrailListTrailsBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new ListTrailsCommand({ ...(input.nextToken ? { NextToken: input.nextToken } : {}) }), + { abortSignal: signal } + ) + return { + success: true, + output: { + trails: (response.Trails ?? []).map((trail) => ({ + trailArn: trail.TrailARN ?? null, + name: trail.Name ?? null, + homeRegion: trail.HomeRegion ?? null, + })), + nextToken: response.NextToken ?? null, + }, + } + }) +} + +export async function executeCloudtrailGetEventSelectors( + input: AwsCloudtrailGetEventSelectorsBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new GetEventSelectorsCommand({ TrailName: input.trailName }), + { abortSignal: signal } + ) + return { + success: true, + output: { + trailArn: response.TrailARN ?? null, + eventSelectors: (response.EventSelectors ?? []).map((selector) => ({ + readWriteType: selector.ReadWriteType ?? null, + includeManagementEvents: selector.IncludeManagementEvents ?? null, + dataResources: (selector.DataResources ?? []).map((resource) => ({ + type: resource.Type ?? null, + values: resource.Values ?? [], + })), + excludeManagementEventSources: selector.ExcludeManagementEventSources ?? [], + })), + advancedEventSelectors: mapAdvancedEventSelectors(response.AdvancedEventSelectors), + }, + } + }) +} + +export async function executeCloudtrailGetInsightSelectors( + input: AwsCloudtrailGetInsightSelectorsBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new GetInsightSelectorsCommand({ + ...(input.trailName ? { TrailName: input.trailName } : {}), + ...(input.eventDataStore ? { EventDataStore: input.eventDataStore } : {}), + }), + { abortSignal: signal } + ) + return { + success: true, + output: { + trailArn: response.TrailARN ?? null, + eventDataStoreArn: response.EventDataStoreArn ?? null, + insightsDestination: response.InsightsDestination ?? null, + insightSelectors: (response.InsightSelectors ?? []).map((selector) => ({ + insightType: selector.InsightType ?? null, + eventCategories: selector.EventCategories ?? [], + })), + }, + } + }) +} + +export async function executeCloudtrailStartQuery( + input: AwsCloudtrailStartQueryBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new StartQueryCommand({ + ...(input.queryStatement ? { QueryStatement: input.queryStatement } : {}), + ...(input.queryAlias ? { QueryAlias: input.queryAlias } : {}), + ...(input.queryParameters ? { QueryParameters: input.queryParameters } : {}), + ...(input.deliveryS3Uri ? { DeliveryS3Uri: input.deliveryS3Uri } : {}), + ...(input.eventDataStoreOwnerAccountId + ? { EventDataStoreOwnerAccountId: input.eventDataStoreOwnerAccountId } + : {}), + }), + { abortSignal: signal } + ) + if (!response.QueryId) throw new Error('No query ID returned') + return { + success: true, + output: { + queryId: response.QueryId, + eventDataStoreOwnerAccountId: response.EventDataStoreOwnerAccountId ?? null, + }, + } + }) +} + +export async function executeCloudtrailDescribeQuery( + input: AwsCloudtrailDescribeQueryBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new DescribeQueryCommand({ + ...(input.queryId ? { QueryId: input.queryId } : {}), + ...(input.queryAlias ? { QueryAlias: input.queryAlias } : {}), + ...(input.refreshId ? { RefreshId: input.refreshId } : {}), + ...(input.eventDataStoreOwnerAccountId + ? { EventDataStoreOwnerAccountId: input.eventDataStoreOwnerAccountId } + : {}), + }), + { abortSignal: signal } + ) + return { + success: true, + output: { + queryId: response.QueryId ?? null, + queryString: response.QueryString ?? null, + queryStatus: response.QueryStatus ?? null, + errorMessage: response.ErrorMessage ?? null, + deliveryS3Uri: response.DeliveryS3Uri ?? null, + deliveryStatus: response.DeliveryStatus ?? null, + prompt: response.Prompt ?? null, + eventDataStoreOwnerAccountId: response.EventDataStoreOwnerAccountId ?? null, + eventsMatched: response.QueryStatistics?.EventsMatched ?? null, + eventsScanned: response.QueryStatistics?.EventsScanned ?? null, + bytesScanned: response.QueryStatistics?.BytesScanned ?? null, + executionTimeInMillis: response.QueryStatistics?.ExecutionTimeInMillis ?? null, + creationTime: response.QueryStatistics?.CreationTime?.toISOString() ?? null, + }, + } + }) +} + +export async function executeCloudtrailGetQueryResults( + input: AwsCloudtrailGetQueryResultsBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new GetQueryResultsCommand({ + QueryId: input.queryId, + ...(input.maxQueryResults !== undefined ? { MaxQueryResults: input.maxQueryResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + ...(input.eventDataStoreOwnerAccountId + ? { EventDataStoreOwnerAccountId: input.eventDataStoreOwnerAccountId } + : {}), + }), + { abortSignal: signal } + ) + const rows = (response.QueryResultRows ?? []).map((row) => { + const record: Record = {} + for (const cell of row) { + for (const [key, value] of Object.entries(cell)) { + record[key] = value ?? '' + } + } + return record + }) + return { + success: true, + output: { + queryStatus: response.QueryStatus ?? null, + rows, + resultsCount: response.QueryStatistics?.ResultsCount ?? null, + totalResultsCount: response.QueryStatistics?.TotalResultsCount ?? null, + bytesScanned: response.QueryStatistics?.BytesScanned ?? null, + errorMessage: response.ErrorMessage ?? null, + nextToken: response.NextToken ?? null, + }, + } + }) +} + +export async function executeCloudtrailCancelQuery( + input: AwsCloudtrailCancelQueryBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new CancelQueryCommand({ + QueryId: input.queryId, + ...(input.eventDataStoreOwnerAccountId + ? { EventDataStoreOwnerAccountId: input.eventDataStoreOwnerAccountId } + : {}), + }), + { abortSignal: signal } + ) + return { + success: true, + output: { + queryId: response.QueryId ?? input.queryId, + queryStatus: response.QueryStatus ?? null, + eventDataStoreOwnerAccountId: response.EventDataStoreOwnerAccountId ?? null, + }, + } + }) +} + +export async function executeCloudtrailListEventDataStores( + input: AwsCloudtrailListEventDataStoresBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new ListEventDataStoresCommand({ + ...(input.maxResults !== undefined ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + return { + success: true, + output: { + eventDataStores: (response.EventDataStores ?? []).map((store) => ({ + eventDataStoreArn: store.EventDataStoreArn ?? null, + name: store.Name ?? null, + status: store.Status ?? null, + advancedEventSelectors: mapAdvancedEventSelectors(store.AdvancedEventSelectors), + multiRegionEnabled: store.MultiRegionEnabled ?? null, + organizationEnabled: store.OrganizationEnabled ?? null, + retentionPeriod: store.RetentionPeriod ?? null, + terminationProtectionEnabled: store.TerminationProtectionEnabled ?? null, + createdTimestamp: store.CreatedTimestamp?.toISOString() ?? null, + updatedTimestamp: store.UpdatedTimestamp?.toISOString() ?? null, + })), + nextToken: response.NextToken ?? null, + }, + } + }) +} + +export async function executeCloudtrailGetEventDataStore( + input: AwsCloudtrailGetEventDataStoreBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new GetEventDataStoreCommand({ EventDataStore: input.eventDataStore }), + { abortSignal: signal } + ) + return { + success: true, + output: { + eventDataStoreArn: response.EventDataStoreArn ?? null, + name: response.Name ?? null, + status: response.Status ?? null, + advancedEventSelectors: mapAdvancedEventSelectors(response.AdvancedEventSelectors), + multiRegionEnabled: response.MultiRegionEnabled ?? null, + organizationEnabled: response.OrganizationEnabled ?? null, + retentionPeriod: response.RetentionPeriod ?? null, + terminationProtectionEnabled: response.TerminationProtectionEnabled ?? null, + createdTimestamp: response.CreatedTimestamp?.toISOString() ?? null, + updatedTimestamp: response.UpdatedTimestamp?.toISOString() ?? null, + kmsKeyId: response.KmsKeyId ?? null, + billingMode: response.BillingMode ?? null, + federationStatus: response.FederationStatus ?? null, + federationRoleArn: response.FederationRoleArn ?? null, + partitionKeys: (response.PartitionKeys ?? []).map((key) => ({ + name: key.Name ?? '', + type: key.Type ?? '', + })), + }, + } + }) +} + +export async function executeCloudtrailListTags( + input: AwsCloudtrailListTagsBody, + signal?: AbortSignal +) { + return withCloudTrailClient(input, async (client) => { + const response = await client.send( + new ListTagsCommand({ + ResourceIdList: input.resourceIdList, + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + return { + success: true, + output: { + resourceTags: (response.ResourceTagList ?? []).map((resourceTag) => ({ + resourceId: resourceTag.ResourceId ?? null, + tags: (resourceTag.TagsList ?? []).map((tag) => ({ + key: tag.Key ?? '', + value: tag.Value ?? null, + })), + })), + nextToken: response.NextToken ?? null, + }, + } + }) +} diff --git a/apps/sim/lib/internal/embeddings/execute-tool.test.ts b/apps/sim/lib/internal/embeddings/execute-tool.test.ts index 66524f6d630..63d5e09e4ff 100644 --- a/apps/sim/lib/internal/embeddings/execute-tool.test.ts +++ b/apps/sim/lib/internal/embeddings/execute-tool.test.ts @@ -9,7 +9,11 @@ vi.mock('@/lib/internal/embeddings/operations', () => ({ executeEmbedding: mockExecuteEmbedding, })) -import { executeEmbeddingsTool } from '@/lib/internal/embeddings/execute-tool' +import { + EMBEDDINGS_TOOL_PROVIDERS, + executeEmbeddingsTool, +} from '@/lib/internal/embeddings/execute-tool' +import { getRegisteredInternalToolOperationIds } from '@/lib/internal/tool-operations/registry.server' function request(input: unknown, overrides: Record = {}) { return { @@ -75,4 +79,35 @@ describe('executeEmbeddingsTool', () => { expect(response.status).toBe(400) expect(mockExecuteEmbedding).not.toHaveBeenCalled() }) + + /** + * Registering a tool id with the operation registry is only half the wiring: + * without an entry here the handler rejects its own tool as unsupported, and + * nothing but a call would say so. + */ + it('maps every embeddings tool the operation registry routes to this handler', () => { + const registered = getRegisteredInternalToolOperationIds().filter((id) => + Object.hasOwn(EMBEDDINGS_TOOL_PROVIDERS, id) + ) + const routedHere = getRegisteredInternalToolOperationIds().filter( + (id) => id.startsWith('embeddings_') || id === 'openai_embeddings' + ) + + expect(registered.sort()).toEqual(routedHere.sort()) + }) + + it('runs Ollama without a credential the schema does not declare', async () => { + const response = await executeEmbeddingsTool( + request( + { provider: 'ollama', model: 'nomic-embed-text', input: 'hello' }, + { toolId: 'embeddings_ollama' } + ) + ) + + expect(response.status).toBe(200) + expect(mockExecuteEmbedding).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'ollama', model: 'nomic-embed-text' }), + expect.any(Object) + ) + }) }) diff --git a/apps/sim/lib/internal/embeddings/execute-tool.ts b/apps/sim/lib/internal/embeddings/execute-tool.ts index ca400dbf556..f0cbcf05b9e 100644 --- a/apps/sim/lib/internal/embeddings/execute-tool.ts +++ b/apps/sim/lib/internal/embeddings/execute-tool.ts @@ -4,6 +4,11 @@ import { executeEmbedding } from '@/lib/internal/embeddings/operations' import { type EmbeddingProvider, embeddingsInputSchema } from '@/lib/internal/embeddings/schema' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' +/** + * Every tool id the embeddings family registers must appear here, or its + * handler rejects the call as an unsupported tool. `execute-tool.test.ts` pins + * this map to the registry's family list so the two cannot drift. + */ const PROVIDERS_BY_TOOL_ID: Record = { openai_embeddings: 'openai', embeddings_openai: 'openai', @@ -11,8 +16,13 @@ const PROVIDERS_BY_TOOL_ID: Record = { embeddings_gemini: 'gemini', embeddings_cohere: 'cohere', embeddings_mistral: 'mistral', + embeddings_ollama: 'ollama', } +/** @internal Exported so the registry's family list can be pinned against it. */ +export const EMBEDDINGS_TOOL_PROVIDERS: Readonly> = + PROVIDERS_BY_TOOL_ID + export const executeEmbeddingsTool: InternalToolOperationHandler = async (request) => { request.signal?.throwIfAborted() if (!request.context.userId) { diff --git a/apps/sim/lib/internal/embeddings/operations.ts b/apps/sim/lib/internal/embeddings/operations.ts index 8154b1da469..1d462cc6ef3 100644 --- a/apps/sim/lib/internal/embeddings/operations.ts +++ b/apps/sim/lib/internal/embeddings/operations.ts @@ -8,7 +8,13 @@ import { embedOpenRouter, findEmbeddingModelInfo, resolveDimensions, + toOllamaEmbeddingModelId, } from '@/lib/embeddings' +import { + getOllamaEmbeddingModelMetadata, + OllamaEmbeddingModelNotFoundError, + OllamaEmbeddingWidthUnknownError, +} from '@/lib/embeddings/ollama-model-catalog.server' import { getOpenRouterEmbeddingModelMetadata, type OpenRouterEmbeddingModelMetadata, @@ -48,7 +54,9 @@ export async function executeEmbedding( context: EmbeddingOperationContext ): Promise { context.signal?.throwIfAborted() - const { provider, apiKey, model, taskType, dimensions } = input + const { provider, model, taskType, dimensions } = input + /** Ollama takes no credential, so its input variant declares no `apiKey` at all. */ + const apiKey = provider === 'ollama' ? undefined : input.apiKey const texts = normalizeEmbeddingInput(input.input) if (texts.length === 0) return failureResponse('input must contain at least one text', 400) if (texts.length > MAX_EMBEDDING_INPUTS) { @@ -70,7 +78,38 @@ export async function executeEmbedding( let resolvedModel: string let openRouterModelMetadata: OpenRouterEmbeddingModelMetadata | undefined - if (provider === 'openrouter') { + /** + * Width Ollama reports for the selected model. Resolved from the server rather + * than requested by the caller: a local model's width is a property of what + * the operator pulled, and it is what the client validates the response + * against. + */ + let ollamaDimensions: number | undefined + if (provider === 'ollama') { + try { + resolvedModel = toOllamaEmbeddingModelId(model) + } catch (error) { + return failureResponse(getErrorMessage(error, 'Invalid Ollama embedding model'), 400) + } + try { + ollamaDimensions = (await getOllamaEmbeddingModelMetadata(resolvedModel, context.signal)) + .dimensions + } catch (error) { + context.signal?.throwIfAborted() + /** + * A model the caller can fix (not installed, or one whose width Ollama + * will not report) is a 400; anything else — an unreachable server above + * all — is an upstream failure and must not read as a bad request. + */ + const userError = + error instanceof OllamaEmbeddingModelNotFoundError || + error instanceof OllamaEmbeddingWidthUnknownError + return failureResponse( + getErrorMessage(error, 'Failed to load Ollama embedding model metadata'), + userError ? 400 : 502 + ) + } + } else if (provider === 'openrouter') { try { resolvedModel = normalizeOpenRouterEmbeddingModelId( model || DEFAULT_OPENROUTER_EMBEDDING_MODEL @@ -110,13 +149,14 @@ export async function executeEmbedding( ) } try { - resolveDimensions(info, dimensions) + resolveDimensions(info, ollamaDimensions ?? dimensions) } catch (error) { return failureResponse(getErrorMessage(error, 'Invalid dimensions'), 400) } } - logger.info(`Embedding ${texts.length} input(s) with ${provider}/${resolvedModel}`) + /** `resolvedModel` already carries a routing prefix for the providers that use one. */ + logger.info(`Embedding ${texts.length} input(s)`, { provider, model: resolvedModel }) try { let result: EmbedResult if (provider === 'openrouter') { @@ -126,7 +166,7 @@ export async function executeEmbedding( result = await embedOpenRouter(texts, { model: resolvedModel, dimensions, - apiKey, + apiKey: input.apiKey, maxInputTokens: openRouterModelMetadata.maxInputTokens, projectInputs: null, signal: context.signal, @@ -135,7 +175,12 @@ export async function executeEmbedding( result = await embed(texts, { model: resolvedModel, taskType, - dimensions, + /** + * Ollama's width comes from the server, never from the caller: the + * adapter cannot ask for a reduction, so a requested size could only + * ever be an assertion, and the server already knows the answer. + */ + dimensions: ollamaDimensions ?? dimensions, apiKey, projectInputs: null, signal: context.signal, diff --git a/apps/sim/lib/internal/embeddings/schema.ts b/apps/sim/lib/internal/embeddings/schema.ts index ab37064c629..e5bbe7f6a8c 100644 --- a/apps/sim/lib/internal/embeddings/schema.ts +++ b/apps/sim/lib/internal/embeddings/schema.ts @@ -9,6 +9,7 @@ export const embeddingProviders = [ 'gemini', 'cohere', 'mistral', + 'ollama', ] as const satisfies readonly EmbeddingToolProvider[] export const embeddingTaskTypes = [ @@ -65,6 +66,18 @@ export const embeddingsInputSchema = z.discriminatedUnion('provider', [ provider: z.literal('openrouter'), apiKey: z.string({ error: 'apiKey is required' }).min(1, 'apiKey cannot be empty'), }), + /** + * Ollama is reached at `OLLAMA_URL` and authenticates with nothing, so this + * variant carries no `apiKey` at all rather than an optional one — there is no + * credential a caller could meaningfully supply. `model` is required because + * the server's catalog is whatever the operator pulled, so there is no default + * Sim could name. + */ + z.object({ + ...commonShape, + provider: z.literal('ollama'), + model: z.string().min(1, 'model cannot be empty'), + }), ]) export type EmbeddingsInput = z.output diff --git a/apps/sim/lib/internal/iam/client.test.ts b/apps/sim/lib/internal/iam/client.test.ts new file mode 100644 index 00000000000..bb8151131ed --- /dev/null +++ b/apps/sim/lib/internal/iam/client.test.ts @@ -0,0 +1,204 @@ +/** + * @vitest-environment node + */ +import type { IAMClient, SimulatePrincipalPolicyCommandOutput } from '@aws-sdk/client-iam' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listPolicies, simulatePrincipalPolicy } from '@/lib/internal/iam/client' + +const mockSend = vi.fn() + +/** A stub standing in for the AWS SDK client; only `send` is exercised. */ +function createStubClient(): IAMClient { + // double-cast-allowed: test stub implements only the single SDK method under test + return { send: mockSend } as unknown as IAMClient +} + +/** + * The literal ARN template AWS echoes back on the top-level result. The braces are escaped + * so the linter does not read AWS's placeholders as JavaScript interpolation. + */ +const AWS_ARN_TEMPLATE = `arn:$\{Partition}:s3:::$\{BucketName}/$\{KeyName}` + +/** + * A realistic SimulatePrincipalPolicy payload for ONE action across TWO buckets: + * allowed on bucket-a, explicitly denied on bucket-b. + * + * AWS returns a single EvaluationResult per action regardless of resource count. Its + * EvalDecision is the aggregate, most-restrictive decision (explicitDeny here, because + * bucket-b denies), its EvalResourceName is an ARN template rather than either bucket, + * and its MissingContextValues is empty because concrete ResourceArns were supplied — + * per-resource missing context moves into ResourceSpecificResults. + */ +const MULTI_RESOURCE_RESPONSE = { + EvaluationResults: [ + { + EvalActionName: 's3:GetObject', + EvalResourceName: AWS_ARN_TEMPLATE, + EvalDecision: 'explicitDeny', + MatchedStatements: [{ SourcePolicyId: 'DenyBucketB', SourcePolicyType: 'IAM Policy' }], + MissingContextValues: [], + PermissionsBoundaryDecisionDetail: { AllowedByPermissionsBoundary: true }, + ResourceSpecificResults: [ + { + EvalResourceName: 'arn:aws:s3:::bucket-a/*', + EvalResourceDecision: 'allowed', + MatchedStatements: [{ SourcePolicyId: 'AllowReadA', SourcePolicyType: 'IAM Policy' }], + MissingContextValues: [], + PermissionsBoundaryDecisionDetail: { AllowedByPermissionsBoundary: true }, + }, + { + EvalResourceName: 'arn:aws:s3:::bucket-b/*', + EvalResourceDecision: 'explicitDeny', + MatchedStatements: [{ SourcePolicyId: 'DenyBucketB', SourcePolicyType: 'IAM Policy' }], + MissingContextValues: ['aws:SourceIp'], + PermissionsBoundaryDecisionDetail: { AllowedByPermissionsBoundary: false }, + }, + ], + }, + ], + IsTruncated: false, +} satisfies Partial + +describe('simulatePrincipalPolicy response mapping', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('preserves the per-resource decision for every simulated ARN', async () => { + mockSend.mockResolvedValue(MULTI_RESOURCE_RESPONSE) + + const result = await simulatePrincipalPolicy(createStubClient(), { + policySourceArn: 'arn:aws:iam::123456789012:user/alice', + actionNames: 's3:GetObject', + resourceArns: 'arn:aws:s3:::bucket-a/*, arn:aws:s3:::bucket-b/*', + }) + + expect(result.evaluationResults).toHaveLength(1) + const [evaluation] = result.evaluationResults + + expect(evaluation.resourceSpecificResults).toEqual([ + { + evalResourceName: 'arn:aws:s3:::bucket-a/*', + evalResourceDecision: 'allowed', + matchedStatements: [{ sourcePolicyId: 'AllowReadA', sourcePolicyType: 'IAM Policy' }], + missingContextValues: [], + permissionsBoundaryAllowed: true, + }, + { + evalResourceName: 'arn:aws:s3:::bucket-b/*', + evalResourceDecision: 'explicitDeny', + matchedStatements: [{ sourcePolicyId: 'DenyBucketB', sourcePolicyType: 'IAM Policy' }], + missingContextValues: ['aws:SourceIp'], + permissionsBoundaryAllowed: false, + }, + ]) + }) + + it('does not let the aggregate decision stand in for the allowed resource', async () => { + mockSend.mockResolvedValue(MULTI_RESOURCE_RESPONSE) + + const result = await simulatePrincipalPolicy(createStubClient(), { + policySourceArn: 'arn:aws:iam::123456789012:user/alice', + actionNames: 's3:GetObject', + resourceArns: 'arn:aws:s3:::bucket-a/*,arn:aws:s3:::bucket-b/*', + }) + + const [evaluation] = result.evaluationResults + expect(evaluation.evalDecision).toBe('explicitDeny') + + const decisionByResource = new Map( + evaluation.resourceSpecificResults.map((r) => [r.evalResourceName, r.evalResourceDecision]) + ) + expect(decisionByResource.get('arn:aws:s3:::bucket-a/*')).toBe('allowed') + expect(decisionByResource.get('arn:aws:s3:::bucket-b/*')).toBe('explicitDeny') + }) + + it('keeps missing context values that AWS moved into the per-resource results', async () => { + mockSend.mockResolvedValue(MULTI_RESOURCE_RESPONSE) + + const result = await simulatePrincipalPolicy(createStubClient(), { + policySourceArn: 'arn:aws:iam::123456789012:user/alice', + actionNames: 's3:GetObject', + resourceArns: 'arn:aws:s3:::bucket-a/*,arn:aws:s3:::bucket-b/*', + }) + + const [evaluation] = result.evaluationResults + expect(evaluation.missingContextValues).toEqual([]) + expect(evaluation.resourceSpecificResults.flatMap((r) => r.missingContextValues)).toEqual([ + 'aws:SourceIp', + ]) + }) + + it('sends both resource ARNs and the supplied condition context keys to AWS', async () => { + mockSend.mockResolvedValue(MULTI_RESOURCE_RESPONSE) + + await simulatePrincipalPolicy(createStubClient(), { + policySourceArn: 'arn:aws:iam::123456789012:user/alice', + actionNames: 's3:GetObject, s3:PutObject', + resourceArns: 'arn:aws:s3:::bucket-a/*,arn:aws:s3:::bucket-b/*', + contextEntries: [ + { + contextKeyName: 'aws:SourceIp', + contextKeyValues: ['203.0.113.10'], + contextKeyType: 'ip', + }, + ], + }) + + expect(mockSend).toHaveBeenCalledOnce() + expect(mockSend.mock.calls[0][0].input).toMatchObject({ + PolicySourceArn: 'arn:aws:iam::123456789012:user/alice', + ActionNames: ['s3:GetObject', 's3:PutObject'], + ResourceArns: ['arn:aws:s3:::bucket-a/*', 'arn:aws:s3:::bucket-b/*'], + ContextEntries: [ + { + ContextKeyName: 'aws:SourceIp', + ContextKeyValues: ['203.0.113.10'], + ContextKeyType: 'ip', + }, + ], + }) + }) + + it('defaults to simulating against * when no resource ARNs are supplied', async () => { + mockSend.mockResolvedValue({ EvaluationResults: [], IsTruncated: false }) + + await simulatePrincipalPolicy(createStubClient(), { + policySourceArn: 'arn:aws:iam::123456789012:user/alice', + actionNames: 'iam:ListUsers', + }) + + expect(mockSend.mock.calls[0][0].input.ResourceArns).toEqual(['*']) + expect(mockSend.mock.calls[0][0].input.ContextEntries).toBeUndefined() + }) +}) + +describe('listPolicies response mapping', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('omits the description AWS never returns from ListPolicies', async () => { + mockSend.mockResolvedValue({ + Policies: [ + { + PolicyName: 'ReadOnlyAccess', + PolicyId: 'ANPAI7XKCFMBPM3QQRRVQ', + Arn: 'arn:aws:iam::aws:policy/ReadOnlyAccess', + Path: '/', + AttachmentCount: 3, + IsAttachable: true, + DefaultVersionId: 'v1', + PermissionsBoundaryUsageCount: 0, + }, + ], + IsTruncated: false, + }) + + const result = await listPolicies(createStubClient(), 'AWS') + + expect(result.policies).toHaveLength(1) + expect(result.policies[0]).not.toHaveProperty('description') + expect(mockSend.mock.calls[0][0].input.Scope).toBe('AWS') + }) +}) diff --git a/apps/sim/lib/internal/iam/client.ts b/apps/sim/lib/internal/iam/client.ts index bb654ca1784..76a2a610692 100644 --- a/apps/sim/lib/internal/iam/client.ts +++ b/apps/sim/lib/internal/iam/client.ts @@ -1,9 +1,14 @@ import type { + AccessKeyMetadata, AttachedPolicy, + ContextEntry, Group, Policy, PolicyScopeType, + ResourceSpecificResult, Role, + Statement, + StatusType, User, } from '@aws-sdk/client-iam' import { @@ -18,9 +23,11 @@ import { DeleteUserCommand, DetachRolePolicyCommand, DetachUserPolicyCommand, + GetPolicyCommand, GetRoleCommand, GetUserCommand, IAMClient, + ListAccessKeysCommand, ListAttachedRolePoliciesCommand, ListAttachedUserPoliciesCommand, ListGroupsCommand, @@ -29,8 +36,9 @@ import { ListUsersCommand, RemoveUserFromGroupCommand, SimulatePrincipalPolicyCommand, + UpdateAccessKeyCommand, } from '@aws-sdk/client-iam' -import type { IAMConnectionConfig } from '@/tools/iam/types' +import type { IAMConnectionConfig, IAMSimulateContextEntry } from '@/tools/iam/types' export function createIAMClient(config: IAMConnectionConfig): IAMClient { return new IAMClient({ @@ -266,7 +274,7 @@ export async function detachRolePolicy( export async function listPolicies( client: IAMClient, - scope?: string | null, + scope?: PolicyScopeType | null, onlyAttached?: boolean | null, pathPrefix?: string | null, maxItems?: number | null, @@ -274,7 +282,7 @@ export async function listPolicies( signal?: AbortSignal ) { const command = new ListPoliciesCommand({ - ...(scope ? { Scope: scope as PolicyScopeType } : {}), + ...(scope ? { Scope: scope } : {}), ...(onlyAttached != null ? { OnlyAttached: onlyAttached } : {}), ...(pathPrefix ? { PathPrefix: pathPrefix } : {}), ...(maxItems ? { MaxItems: maxItems } : {}), @@ -291,7 +299,6 @@ export async function listPolicies( isAttachable: policy.IsAttachable ?? false, createDate: policy.CreateDate?.toISOString() ?? null, updateDate: policy.UpdateDate?.toISOString() ?? null, - description: policy.Description ?? null, defaultVersionId: policy.DefaultVersionId ?? null, permissionsBoundaryUsageCount: policy.PermissionsBoundaryUsageCount ?? 0, })) @@ -452,32 +459,69 @@ export async function listAttachedUserPolicies( } } +function mapMatchedStatements(statements: Statement[] | undefined) { + return (statements ?? []).map((s) => ({ + sourcePolicyId: s.SourcePolicyId ?? '', + sourcePolicyType: s.SourcePolicyType ?? '', + })) +} + +/** + * Projects the per-resource half of a simulation result. AWS reports one + * `EvaluationResult` per action no matter how many resource ARNs were supplied, so this + * is the only place a caller can learn what was decided for an individual ARN. When + * concrete `ResourceArns` are supplied, missing context values are reported here rather + * than on the aggregate result. + */ +function mapResourceSpecificResults(results: ResourceSpecificResult[] | undefined) { + return (results ?? []).map((r) => ({ + evalResourceName: r.EvalResourceName ?? '', + evalResourceDecision: r.EvalResourceDecision ?? '', + matchedStatements: mapMatchedStatements(r.MatchedStatements), + missingContextValues: (r.MissingContextValues ?? []).map((v) => String(v)), + permissionsBoundaryAllowed: + r.PermissionsBoundaryDecisionDetail?.AllowedByPermissionsBoundary ?? null, + })) +} + +export interface SimulatePrincipalPolicyOptions { + policySourceArn: string + actionNames: string + resourceArns?: string | null + contextEntries?: IAMSimulateContextEntry[] | null + maxResults?: number | null + marker?: string | null +} + export async function simulatePrincipalPolicy( client: IAMClient, - policySourceArn: string, - actionNames: string, - resourceArns?: string | null, - maxResults?: number | null, - marker?: string | null, + options: SimulatePrincipalPolicyOptions, signal?: AbortSignal ) { - const actions = actionNames + const actions = options.actionNames .split(',') .map((a) => a.trim()) .filter(Boolean) - const resources = resourceArns - ? resourceArns + const resources = options.resourceArns + ? options.resourceArns .split(',') .map((r) => r.trim()) .filter(Boolean) : ['*'] + const contextEntries: ContextEntry[] = (options.contextEntries ?? []).map((entry) => ({ + ContextKeyName: entry.contextKeyName, + ContextKeyValues: entry.contextKeyValues, + ContextKeyType: entry.contextKeyType, + })) + const command = new SimulatePrincipalPolicyCommand({ - PolicySourceArn: policySourceArn, + PolicySourceArn: options.policySourceArn, ActionNames: actions, ResourceArns: resources, - ...(maxResults ? { MaxItems: maxResults } : {}), - ...(marker ? { Marker: marker } : {}), + ...(contextEntries.length > 0 ? { ContextEntries: contextEntries } : {}), + ...(options.maxResults ? { MaxItems: options.maxResults } : {}), + ...(options.marker ? { Marker: options.marker } : {}), }) const response = await client.send(command, { abortSignal: signal }) @@ -485,11 +529,11 @@ export async function simulatePrincipalPolicy( evalActionName: r.EvalActionName ?? '', evalResourceName: r.EvalResourceName ?? '', evalDecision: r.EvalDecision ?? '', - matchedStatements: (r.MatchedStatements ?? []).map((s) => ({ - sourcePolicyId: s.SourcePolicyId ?? '', - sourcePolicyType: s.SourcePolicyType ?? '', - })), + matchedStatements: mapMatchedStatements(r.MatchedStatements), missingContextValues: (r.MissingContextValues ?? []).map((v) => String(v)), + permissionsBoundaryAllowed: + r.PermissionsBoundaryDecisionDetail?.AllowedByPermissionsBoundary ?? null, + resourceSpecificResults: mapResourceSpecificResults(r.ResourceSpecificResults), })) return { @@ -499,3 +543,68 @@ export async function simulatePrincipalPolicy( count: evaluationResults.length, } } + +export async function getPolicy(client: IAMClient, policyArn: string, signal?: AbortSignal) { + const command = new GetPolicyCommand({ PolicyArn: policyArn }) + const response = await client.send(command, { abortSignal: signal }) + const policy = response.Policy + + return { + policyName: policy?.PolicyName ?? '', + policyId: policy?.PolicyId ?? '', + arn: policy?.Arn ?? '', + path: policy?.Path ?? '', + attachmentCount: policy?.AttachmentCount ?? 0, + isAttachable: policy?.IsAttachable ?? false, + createDate: policy?.CreateDate?.toISOString() ?? null, + updateDate: policy?.UpdateDate?.toISOString() ?? null, + description: policy?.Description ?? null, + defaultVersionId: policy?.DefaultVersionId ?? null, + permissionsBoundaryUsageCount: policy?.PermissionsBoundaryUsageCount ?? 0, + tags: policy?.Tags?.map((t) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [], + } +} + +export async function listAccessKeys( + client: IAMClient, + userName?: string | null, + maxItems?: number | null, + marker?: string | null, + signal?: AbortSignal +) { + const command = new ListAccessKeysCommand({ + ...(userName ? { UserName: userName } : {}), + ...(maxItems ? { MaxItems: maxItems } : {}), + ...(marker ? { Marker: marker } : {}), + }) + + const response = await client.send(command, { abortSignal: signal }) + const accessKeys = (response.AccessKeyMetadata ?? []).map((key: AccessKeyMetadata) => ({ + accessKeyId: key.AccessKeyId ?? '', + userName: key.UserName ?? '', + status: key.Status ?? '', + createDate: key.CreateDate?.toISOString() ?? null, + })) + + return { + accessKeys, + isTruncated: response.IsTruncated ?? false, + marker: response.Marker ?? null, + count: accessKeys.length, + } +} + +export async function updateAccessKey( + client: IAMClient, + accessKeyIdToUpdate: string, + status: StatusType, + userName?: string | null, + signal?: AbortSignal +) { + const command = new UpdateAccessKeyCommand({ + AccessKeyId: accessKeyIdToUpdate, + Status: status, + ...(userName ? { UserName: userName } : {}), + }) + await client.send(command, { abortSignal: signal }) +} diff --git a/apps/sim/lib/internal/iam/execute-tool.test.ts b/apps/sim/lib/internal/iam/execute-tool.test.ts index e8ddad046db..028505e240c 100644 --- a/apps/sim/lib/internal/iam/execute-tool.test.ts +++ b/apps/sim/lib/internal/iam/execute-tool.test.ts @@ -15,8 +15,10 @@ const mockOperations = vi.hoisted(() => ({ executeIamDeleteUser: vi.fn(), executeIamDetachRolePolicy: vi.fn(), executeIamDetachUserPolicy: vi.fn(), + executeIamGetPolicy: vi.fn(), executeIamGetRole: vi.fn(), executeIamGetUser: vi.fn(), + executeIamListAccessKeys: vi.fn(), executeIamListAttachedRolePolicies: vi.fn(), executeIamListAttachedUserPolicies: vi.fn(), executeIamListGroups: vi.fn(), @@ -25,6 +27,7 @@ const mockOperations = vi.hoisted(() => ({ executeIamListUsers: vi.fn(), executeIamRemoveUserFromGroup: vi.fn(), executeIamSimulatePrincipalPolicy: vi.fn(), + executeIamUpdateAccessKey: vi.fn(), })) vi.mock('@/lib/internal/iam/operations', () => mockOperations) @@ -93,9 +96,29 @@ const TOOL_CASES = [ }, { toolId: 'iam_delete_access_key', - input: { ...CONNECTION, accessKeyIdToDelete: 'AKIADELETE' }, + input: { ...CONNECTION, accessKeyIdToDelete: 'AKIAIOSFODNN7EXAMPLE' }, operation: mockOperations.executeIamDeleteAccessKey, }, + { + toolId: 'iam_get_policy', + input: { ...CONNECTION, policyArn: 'arn:aws:iam::aws:policy/ReadOnlyAccess' }, + operation: mockOperations.executeIamGetPolicy, + }, + { + toolId: 'iam_list_access_keys', + input: { ...CONNECTION, userName: 'test-user' }, + operation: mockOperations.executeIamListAccessKeys, + }, + { + toolId: 'iam_update_access_key', + input: { + ...CONNECTION, + accessKeyIdToUpdate: 'AKIAIOSFODNN7EXAMPLE', + status: 'Inactive', + userName: 'test-user', + }, + operation: mockOperations.executeIamUpdateAccessKey, + }, { toolId: 'iam_delete_role', input: { ...CONNECTION, roleName: 'test-role' }, diff --git a/apps/sim/lib/internal/iam/execute-tool.ts b/apps/sim/lib/internal/iam/execute-tool.ts index e9074744ac2..58f266ff3a0 100644 --- a/apps/sim/lib/internal/iam/execute-tool.ts +++ b/apps/sim/lib/internal/iam/execute-tool.ts @@ -9,8 +9,10 @@ import { awsIamDeleteRoleContract } from '@/lib/api/contracts/tools/aws/iam-dele import { awsIamDeleteUserContract } from '@/lib/api/contracts/tools/aws/iam-delete-user' import { awsIamDetachRolePolicyContract } from '@/lib/api/contracts/tools/aws/iam-detach-role-policy' import { awsIamDetachUserPolicyContract } from '@/lib/api/contracts/tools/aws/iam-detach-user-policy' +import { awsIamGetPolicyContract } from '@/lib/api/contracts/tools/aws/iam-get-policy' import { awsIamGetRoleContract } from '@/lib/api/contracts/tools/aws/iam-get-role' import { awsIamGetUserContract } from '@/lib/api/contracts/tools/aws/iam-get-user' +import { awsIamListAccessKeysContract } from '@/lib/api/contracts/tools/aws/iam-list-access-keys' import { awsIamListAttachedRolePoliciesContract } from '@/lib/api/contracts/tools/aws/iam-list-attached-role-policies' import { awsIamListAttachedUserPoliciesContract } from '@/lib/api/contracts/tools/aws/iam-list-attached-user-policies' import { awsIamListGroupsContract } from '@/lib/api/contracts/tools/aws/iam-list-groups' @@ -19,6 +21,7 @@ import { awsIamListRolesContract } from '@/lib/api/contracts/tools/aws/iam-list- import { awsIamListUsersContract } from '@/lib/api/contracts/tools/aws/iam-list-users' import { awsIamRemoveUserFromGroupContract } from '@/lib/api/contracts/tools/aws/iam-remove-user-from-group' import { awsIamSimulatePrincipalPolicyContract } from '@/lib/api/contracts/tools/aws/iam-simulate-principal-policy' +import { awsIamUpdateAccessKeyContract } from '@/lib/api/contracts/tools/aws/iam-update-access-key' import { executeIamAddUserToGroup, executeIamAttachRolePolicy, @@ -31,8 +34,10 @@ import { executeIamDeleteUser, executeIamDetachRolePolicy, executeIamDetachUserPolicy, + executeIamGetPolicy, executeIamGetRole, executeIamGetUser, + executeIamListAccessKeys, executeIamListAttachedRolePolicies, executeIamListAttachedUserPolicies, executeIamListGroups, @@ -41,6 +46,7 @@ import { executeIamListUsers, executeIamRemoveUserFromGroup, executeIamSimulatePrincipalPolicy, + executeIamUpdateAccessKey, } from '@/lib/internal/iam/operations' import { executeInternalJsonToolOperation } from '@/lib/internal/tool-operations/execute-json-operation' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' @@ -137,6 +143,14 @@ export const executeIamTool: InternalToolOperationHandler = async ({ toolId, inp 'Failed to detach user policy', signal ) + case 'iam_get_policy': + return executeInternalJsonToolOperation( + awsIamGetPolicyContract, + input, + executeIamGetPolicy, + 'Failed to get IAM policy', + signal + ) case 'iam_get_role': return executeInternalJsonToolOperation( awsIamGetRoleContract, @@ -153,6 +167,14 @@ export const executeIamTool: InternalToolOperationHandler = async ({ toolId, inp 'Failed to get IAM user', signal ) + case 'iam_list_access_keys': + return executeInternalJsonToolOperation( + awsIamListAccessKeysContract, + input, + executeIamListAccessKeys, + 'Failed to list access keys', + signal + ) case 'iam_list_attached_role_policies': return executeInternalJsonToolOperation( awsIamListAttachedRolePoliciesContract, @@ -217,6 +239,14 @@ export const executeIamTool: InternalToolOperationHandler = async ({ toolId, inp 'Failed to simulate principal policy', signal ) + case 'iam_update_access_key': + return executeInternalJsonToolOperation( + awsIamUpdateAccessKeyContract, + input, + executeIamUpdateAccessKey, + 'Failed to update access key', + signal + ) default: return Response.json({ error: `Unsupported IAM tool: ${toolId}` }, { status: 500 }) } diff --git a/apps/sim/lib/internal/iam/operations.test.ts b/apps/sim/lib/internal/iam/operations.test.ts index c7b46afcbd0..31a414df49a 100644 --- a/apps/sim/lib/internal/iam/operations.test.ts +++ b/apps/sim/lib/internal/iam/operations.test.ts @@ -22,8 +22,10 @@ vi.mock('@/lib/internal/iam/client', () => ({ deleteUser: vi.fn(), detachRolePolicy: vi.fn(), detachUserPolicy: vi.fn(), + getPolicy: vi.fn(), getRole: vi.fn(), getUser: vi.fn(), + listAccessKeys: vi.fn(), listAttachedRolePolicies: vi.fn(), listAttachedUserPolicies: vi.fn(), listGroups: vi.fn(), @@ -32,6 +34,7 @@ vi.mock('@/lib/internal/iam/client', () => ({ listUsers: mockListUsers, removeUserFromGroup: vi.fn(), simulatePrincipalPolicy: vi.fn(), + updateAccessKey: vi.fn(), })) import { executeIamListUsers } from '@/lib/internal/iam/operations' diff --git a/apps/sim/lib/internal/iam/operations.ts b/apps/sim/lib/internal/iam/operations.ts index 1780c0d4c56..ae136afd44f 100644 --- a/apps/sim/lib/internal/iam/operations.ts +++ b/apps/sim/lib/internal/iam/operations.ts @@ -10,8 +10,10 @@ import type { AwsIamDeleteRoleBody } from '@/lib/api/contracts/tools/aws/iam-del import type { AwsIamDeleteUserBody } from '@/lib/api/contracts/tools/aws/iam-delete-user' import type { AwsIamDetachRolePolicyBody } from '@/lib/api/contracts/tools/aws/iam-detach-role-policy' import type { AwsIamDetachUserPolicyBody } from '@/lib/api/contracts/tools/aws/iam-detach-user-policy' +import type { AwsIamGetPolicyBody } from '@/lib/api/contracts/tools/aws/iam-get-policy' import type { AwsIamGetRoleBody } from '@/lib/api/contracts/tools/aws/iam-get-role' import type { AwsIamGetUserBody } from '@/lib/api/contracts/tools/aws/iam-get-user' +import type { AwsIamListAccessKeysBody } from '@/lib/api/contracts/tools/aws/iam-list-access-keys' import type { AwsIamListAttachedRolePoliciesBody } from '@/lib/api/contracts/tools/aws/iam-list-attached-role-policies' import type { AwsIamListAttachedUserPoliciesBody } from '@/lib/api/contracts/tools/aws/iam-list-attached-user-policies' import type { AwsIamListGroupsBody } from '@/lib/api/contracts/tools/aws/iam-list-groups' @@ -20,6 +22,7 @@ import type { AwsIamListRolesBody } from '@/lib/api/contracts/tools/aws/iam-list import type { AwsIamListUsersBody } from '@/lib/api/contracts/tools/aws/iam-list-users' import type { AwsIamRemoveUserFromGroupBody } from '@/lib/api/contracts/tools/aws/iam-remove-user-from-group' import type { AwsIamSimulatePrincipalPolicyBody } from '@/lib/api/contracts/tools/aws/iam-simulate-principal-policy' +import type { AwsIamUpdateAccessKeyBody } from '@/lib/api/contracts/tools/aws/iam-update-access-key' import { addUserToGroup, attachRolePolicy, @@ -33,8 +36,10 @@ import { deleteUser, detachRolePolicy, detachUserPolicy, + getPolicy, getRole, getUser, + listAccessKeys, listAttachedRolePolicies, listAttachedUserPolicies, listGroups, @@ -43,6 +48,7 @@ import { listUsers, removeUserFromGroup, simulatePrincipalPolicy, + updateAccessKey, } from '@/lib/internal/iam/client' import type { IAMConnectionConfig } from '@/tools/iam/types' @@ -322,13 +328,47 @@ export async function executeIamSimulatePrincipalPolicy( (client) => simulatePrincipalPolicy( client, - input.policySourceArn, - input.actionNames, - input.resourceArns, - input.maxResults, - input.marker, + { + policySourceArn: input.policySourceArn, + actionNames: input.actionNames, + resourceArns: input.resourceArns, + contextEntries: input.contextEntries, + maxResults: input.maxResults, + marker: input.marker, + }, signal ), signal ) } + +export async function executeIamGetPolicy(input: AwsIamGetPolicyBody, signal?: AbortSignal) { + return withIamClient(input, (client) => getPolicy(client, input.policyArn, signal), signal) +} + +export async function executeIamListAccessKeys( + input: AwsIamListAccessKeysBody, + signal?: AbortSignal +) { + return withIamClient( + input, + (client) => listAccessKeys(client, input.userName, input.maxItems, input.marker, signal), + signal + ) +} + +export async function executeIamUpdateAccessKey( + input: AwsIamUpdateAccessKeyBody, + signal?: AbortSignal +) { + return withIamClient( + input, + async (client) => { + await updateAccessKey(client, input.accessKeyIdToUpdate, input.status, input.userName, signal) + return { + message: `Access key "${input.accessKeyIdToUpdate}" set to ${input.status}`, + } + }, + signal + ) +} diff --git a/apps/sim/lib/internal/identity-center/client.ts b/apps/sim/lib/internal/identity-center/client.ts index 539a2381d94..02c5cbeef68 100644 --- a/apps/sim/lib/internal/identity-center/client.ts +++ b/apps/sim/lib/internal/identity-center/client.ts @@ -4,6 +4,7 @@ import { GetGroupIdCommand, GetUserIdCommand, IdentitystoreClient, + ListGroupMembershipsCommand, ListGroupsCommand, } from '@aws-sdk/client-identitystore' import { @@ -18,6 +19,7 @@ import { DescribeAccountAssignmentCreationStatusCommand, DescribeAccountAssignmentDeletionStatusCommand, DescribePermissionSetCommand, + ListAccountAssignmentsCommand, ListAccountAssignmentsForPrincipalCommand, ListInstancesCommand, ListPermissionSetsCommand, @@ -25,6 +27,12 @@ import { SSOAdminClient, type TargetType, } from '@aws-sdk/client-sso-admin' +import { + AWS_FANOUT_CONCURRENCY, + mapWithConcurrency, + withThrottleRetry, +} from '@/lib/internal/identity-center/concurrency' +import { resolveOrganizationsRegion } from '@/lib/internal/identity-center/partition' interface IdentityCenterConnectionConfig { region: string @@ -32,8 +40,6 @@ interface IdentityCenterConnectionConfig { secretAccessKey: string } -const AWS_ORGANIZATIONS_REGION = 'us-east-1' - export function createSSOAdminClient(config: IdentityCenterConnectionConfig): SSOAdminClient { return new SSOAdminClient({ region: config.region, @@ -56,9 +62,13 @@ export function createIdentityStoreClient( }) } +/** + * AWS Organizations is global *per partition*, so the client signs for the + * caller's partition home region rather than the caller's own region. + */ export function createOrganizationsClient(config: IdentityCenterConnectionConfig) { return new OrganizationsClient({ - region: AWS_ORGANIZATIONS_REGION, + region: resolveOrganizationsRegion(config.region), credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, @@ -126,13 +136,18 @@ export async function listPermissionSets( const listResponse = await client.send(listCommand, { abortSignal: signal }) const permissionSetArns = listResponse.PermissionSets ?? [] - const permissionSets = await Promise.all( - permissionSetArns.map(async (arn) => { + const permissionSets = await mapWithConcurrency( + permissionSetArns, + AWS_FANOUT_CONCURRENCY, + async (arn) => { const describeCommand = new DescribePermissionSetCommand({ InstanceArn: instanceArn, PermissionSetArn: arn, }) - const describeResponse = await client.send(describeCommand, { abortSignal: signal }) + const describeResponse = await withThrottleRetry( + () => client.send(describeCommand, { abortSignal: signal }), + signal + ) const permissionSet = describeResponse.PermissionSet return { permissionSetArn: permissionSet?.PermissionSetArn ?? arn, @@ -141,7 +156,7 @@ export async function listPermissionSets( sessionDuration: permissionSet?.SessionDuration ?? null, createdDate: permissionSet?.CreatedDate?.toISOString() ?? null, } - }) + } ) return { @@ -371,3 +386,101 @@ export async function listAccountAssignmentsForPrincipal( })) return { assignments, nextToken: response.NextToken ?? null, count: assignments.length } } + +export async function listAccountAssignmentsForAccount( + client: SSOAdminClient, + instanceArn: string, + accountId: string, + permissionSetArn: string, + maxResults?: number | null, + nextToken?: string | null, + signal?: AbortSignal +) { + const command = new ListAccountAssignmentsCommand({ + InstanceArn: instanceArn, + AccountId: accountId, + PermissionSetArn: permissionSetArn, + ...(maxResults ? { MaxResults: maxResults } : {}), + ...(nextToken ? { NextToken: nextToken } : {}), + }) + const response = await client.send(command, { abortSignal: signal }) + const assignments = (response.AccountAssignments ?? []).map((assignment) => ({ + accountId: assignment.AccountId ?? accountId, + permissionSetArn: assignment.PermissionSetArn ?? permissionSetArn, + principalType: assignment.PrincipalType ?? '', + principalId: assignment.PrincipalId ?? '', + })) + return { assignments, nextToken: response.NextToken ?? null, count: assignments.length } +} + +export async function describeUserById( + client: IdentitystoreClient, + identityStoreId: string, + userId: string, + signal?: AbortSignal +) { + const command = new DescribeUserCommand({ IdentityStoreId: identityStoreId, UserId: userId }) + const response = await client.send(command, { abortSignal: signal }) + const primaryEmail = + response.Emails?.find((entry) => entry.Primary)?.Value ?? response.Emails?.[0]?.Value ?? null + + return { + userId: response.UserId ?? userId, + userName: response.UserName ?? '', + displayName: response.DisplayName ?? null, + email: primaryEmail, + userStatus: response.UserStatus ?? null, + title: response.Title ?? null, + externalIds: + response.ExternalIds?.map((externalId) => ({ + issuer: externalId.Issuer ?? '', + id: externalId.Id ?? '', + })) ?? [], + } +} + +export async function describeGroupById( + client: IdentitystoreClient, + identityStoreId: string, + groupId: string, + signal?: AbortSignal +) { + const command = new DescribeGroupCommand({ IdentityStoreId: identityStoreId, GroupId: groupId }) + const response = await client.send(command, { abortSignal: signal }) + return { + groupId: response.GroupId ?? groupId, + displayName: response.DisplayName ?? null, + description: response.Description ?? null, + externalIds: + response.ExternalIds?.map((externalId) => ({ + issuer: externalId.Issuer ?? '', + id: externalId.Id ?? '', + })) ?? [], + } +} + +export async function listGroupMemberships( + client: IdentitystoreClient, + identityStoreId: string, + groupId: string, + maxResults?: number | null, + nextToken?: string | null, + signal?: AbortSignal +) { + const command = new ListGroupMembershipsCommand({ + IdentityStoreId: identityStoreId, + GroupId: groupId, + ...(maxResults ? { MaxResults: maxResults } : {}), + ...(nextToken ? { NextToken: nextToken } : {}), + }) + const response = await client.send(command, { abortSignal: signal }) + const memberships = (response.GroupMemberships ?? []).map((membership) => ({ + membershipId: membership.MembershipId ?? '', + groupId: membership.GroupId ?? groupId, + userId: + membership.MemberId && 'UserId' in membership.MemberId + ? (membership.MemberId.UserId ?? null) + : null, + })) + return { memberships, nextToken: response.NextToken ?? null, count: memberships.length } +} diff --git a/apps/sim/lib/internal/identity-center/concurrency.test.ts b/apps/sim/lib/internal/identity-center/concurrency.test.ts new file mode 100644 index 00000000000..b0fde53c06c --- /dev/null +++ b/apps/sim/lib/internal/identity-center/concurrency.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { sleep } from '@sim/utils/helpers' +import { describe, expect, it, vi } from 'vitest' +import { + AWS_FANOUT_CONCURRENCY, + mapWithConcurrency, + withThrottleRetry, +} from '@/lib/internal/identity-center/concurrency' + +function throttlingError(): Error { + const error = new Error('Rate exceeded') + error.name = 'ThrottlingException' + return error +} + +describe('mapWithConcurrency', () => { + it('preserves input order in the result', async () => { + const items = [5, 4, 3, 2, 1] + const results = await mapWithConcurrency(items, 3, async (item) => { + await sleep(item) + return item * 2 + }) + expect(results).toEqual([10, 8, 6, 4, 2]) + }) + + it('never exceeds the concurrency ceiling', async () => { + const items = Array.from({ length: 100 }, (_, index) => index) + let inFlight = 0 + let peak = 0 + + await mapWithConcurrency(items, AWS_FANOUT_CONCURRENCY, async (item) => { + inFlight++ + peak = Math.max(peak, inFlight) + await sleep(1) + inFlight-- + return item + }) + + expect(peak).toBeLessThanOrEqual(AWS_FANOUT_CONCURRENCY) + expect(peak).toBeGreaterThan(1) + }) + + it('stops starting work after a rejection', async () => { + const items = Array.from({ length: 40 }, (_, index) => index) + let started = 0 + + await expect( + mapWithConcurrency(items, 2, async (item) => { + started++ + await sleep(1) + if (item === 0) throw new Error('boom') + return item + }) + ).rejects.toThrow('boom') + + expect(started).toBeLessThan(items.length) + }) +}) + +describe('withThrottleRetry', () => { + it('retries a throttled call until it succeeds', async () => { + const operation = vi + .fn() + .mockRejectedValueOnce(throttlingError()) + .mockRejectedValueOnce(throttlingError()) + .mockResolvedValue('ok') + + await expect(withThrottleRetry(operation)).resolves.toBe('ok') + expect(operation).toHaveBeenCalledTimes(3) + }) + + it('does not retry a non-throttling failure', async () => { + const operation = vi.fn().mockRejectedValue(new Error('AccessDeniedException')) + + await expect(withThrottleRetry(operation)).rejects.toThrow('AccessDeniedException') + expect(operation).toHaveBeenCalledTimes(1) + }) + + it('gives up after the attempt ceiling', async () => { + const operation = vi.fn().mockRejectedValue(throttlingError()) + + await expect(withThrottleRetry(operation)).rejects.toThrow('Rate exceeded') + expect(operation).toHaveBeenCalledTimes(4) + }) + + it('stops immediately when the caller aborts', async () => { + const controller = new AbortController() + controller.abort() + const operation = vi.fn() + + await expect(withThrottleRetry(operation, controller.signal)).rejects.toThrow() + expect(operation).not.toHaveBeenCalled() + }) + + it('exits a throttling backoff as soon as the caller aborts', async () => { + const controller = new AbortController() + let calls = 0 + let abortedAt = 0 + + /** + * Aborts on the third attempt, whose pending backoff is at least 640ms + * (200ms base doubled twice, minus the 20% jitter floor). A backoff that + * ignored the signal would therefore keep the caller waiting far longer + * than the assertion below allows. + */ + const operation = vi.fn(async () => { + calls++ + if (calls === 3) { + abortedAt = Date.now() + controller.abort() + } + throw throttlingError() + }) + + const rejection = await withThrottleRetry(operation, controller.signal).catch( + (error: unknown) => error + ) + const elapsedSinceAbort = Date.now() - abortedAt + + expect((rejection as Error).name).toBe('AbortError') + expect(operation).toHaveBeenCalledTimes(3) + expect(elapsedSinceAbort).toBeLessThan(200) + }) +}) diff --git a/apps/sim/lib/internal/identity-center/concurrency.ts b/apps/sim/lib/internal/identity-center/concurrency.ts new file mode 100644 index 00000000000..a80a4e8acb9 --- /dev/null +++ b/apps/sim/lib/internal/identity-center/concurrency.ts @@ -0,0 +1,89 @@ +import { interruptibleSleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' + +/** + * Ceiling on simultaneous per-item AWS calls issued while expanding a list + * page. `ListPermissionSets` returns up to 100 ARNs in one page, so an + * uncapped fan-out would fire 100 concurrent `DescribePermissionSet` calls and + * throttle itself. + */ +export const AWS_FANOUT_CONCURRENCY = 6 + +/** Total attempts (initial + retries) for a throttled AWS call. */ +const MAX_THROTTLE_ATTEMPTS = 4 + +const THROTTLE_ERROR_NAMES = new Set([ + 'ThrottlingException', + 'Throttling', + 'ThrottledException', + 'TooManyRequestsException', + 'RequestLimitExceeded', + 'RequestThrottled', + 'RequestThrottledException', + 'SlowDown', +]) + +function isThrottlingError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const candidate = error as { name?: unknown; $metadata?: { httpStatusCode?: unknown } } + if (typeof candidate.name === 'string' && THROTTLE_ERROR_NAMES.has(candidate.name)) return true + return candidate.$metadata?.httpStatusCode === 429 +} + +/** + * Runs an AWS call, retrying with jittered exponential backoff while the + * service reports throttling. Non-throttling failures and aborts propagate + * immediately. + * + * The backoff itself is interruptible: an abort during the wait resolves it + * early, so the next loop iteration's `throwIfAborted` fires without the caller + * having to sit out the remaining delay. + */ +export async function withThrottleRetry( + operation: () => Promise, + signal?: AbortSignal +): Promise { + for (let attempt = 1; ; attempt++) { + signal?.throwIfAborted() + try { + return await operation() + } catch (error) { + if (attempt >= MAX_THROTTLE_ATTEMPTS || !isThrottlingError(error)) throw error + await interruptibleSleep( + backoffWithJitter(attempt, null, { baseMs: 200, maxMs: 5_000 }), + signal + ) + } + } +} + +/** + * Maps `items` through `fn` with at most `limit` calls in flight, preserving + * input order in the result. The first rejection propagates and no further + * items are started. + */ +export async function mapWithConcurrency( + items: readonly TItem[], + limit: number, + fn: (item: TItem, index: number) => Promise +): Promise { + const results = new Array(items.length) + let cursor = 0 + let failed = false + + const worker = async (): Promise => { + while (!failed && cursor < items.length) { + const index = cursor++ + try { + results[index] = await fn(items[index], index) + } catch (error) { + failed = true + throw error + } + } + } + + const workerCount = Math.min(Math.max(1, limit), items.length) + await Promise.all(Array.from({ length: workerCount }, worker)) + return results +} diff --git a/apps/sim/lib/internal/identity-center/execute-tool.test.ts b/apps/sim/lib/internal/identity-center/execute-tool.test.ts index 8bff280c367..bd14eea48e5 100644 --- a/apps/sim/lib/internal/identity-center/execute-tool.test.ts +++ b/apps/sim/lib/internal/identity-center/execute-tool.test.ts @@ -16,6 +16,10 @@ const mockOperations = vi.hoisted(() => ({ executeIdentityCenterCheckAssignmentStatus: vi.fn(), executeIdentityCenterCheckAssignmentDeletionStatus: vi.fn(), executeIdentityCenterListAccountAssignments: vi.fn(), + executeIdentityCenterListAssignmentsForAccount: vi.fn(), + executeIdentityCenterDescribeUser: vi.fn(), + executeIdentityCenterDescribeGroup: vi.fn(), + executeIdentityCenterListGroupMemberships: vi.fn(), })) vi.mock('@/lib/internal/identity-center/operations', () => mockOperations) @@ -29,6 +33,13 @@ const CONNECTION = { secretAccessKey: 'secret-key', } +const INSTANCE_ARN = 'arn:aws:sso:::instance/ssoins-1234567890abcdef' +const PERMISSION_SET_ARN = 'arn:aws:sso:::permissionSet/ssoins-1234567890abcdef/ps-1234567890abcdef' +const IDENTITY_STORE_ID = 'd-1234567890' +const USER_PRINCIPAL_ID = '9067b2d8-8021-70f8-1234-5c6d7e8f9012' +const GROUP_PRINCIPAL_ID = '1234567890-a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d' +const REQUEST_ID = '11111111-2222-3333-4444-555555555555' + function createRequest( overrides: Partial = {} ): InternalToolOperationCall { @@ -65,33 +76,33 @@ const TOOL_CASES = [ }, { toolId: 'identity_center_list_permission_sets', - input: { ...CONNECTION, instanceArn: 'arn:aws:sso:::instance/ssoins-test' }, + input: { ...CONNECTION, instanceArn: INSTANCE_ARN }, operation: mockOperations.executeIdentityCenterListPermissionSets, }, { toolId: 'identity_center_get_user', - input: { ...CONNECTION, identityStoreId: 'd-test', email: 'user@example.com' }, + input: { ...CONNECTION, identityStoreId: IDENTITY_STORE_ID, email: 'user@example.com' }, operation: mockOperations.executeIdentityCenterGetUser, }, { toolId: 'identity_center_get_group', - input: { ...CONNECTION, identityStoreId: 'd-test', displayName: 'Engineering' }, + input: { ...CONNECTION, identityStoreId: IDENTITY_STORE_ID, displayName: 'Engineering' }, operation: mockOperations.executeIdentityCenterGetGroup, }, { toolId: 'identity_center_list_groups', - input: { ...CONNECTION, identityStoreId: 'd-test' }, + input: { ...CONNECTION, identityStoreId: IDENTITY_STORE_ID }, operation: mockOperations.executeIdentityCenterListGroups, }, { toolId: 'identity_center_create_account_assignment', input: { ...CONNECTION, - instanceArn: 'arn:aws:sso:::instance/ssoins-test', + instanceArn: INSTANCE_ARN, accountId: '123456789012', - permissionSetArn: 'arn:aws:sso:::permissionSet/ssoins-test/ps-test', + permissionSetArn: PERMISSION_SET_ARN, principalType: 'USER', - principalId: 'user-1', + principalId: USER_PRINCIPAL_ID, }, operation: mockOperations.executeIdentityCenterCreateAccountAssignment, }, @@ -99,11 +110,11 @@ const TOOL_CASES = [ toolId: 'identity_center_delete_account_assignment', input: { ...CONNECTION, - instanceArn: 'arn:aws:sso:::instance/ssoins-test', + instanceArn: INSTANCE_ARN, accountId: '123456789012', - permissionSetArn: 'arn:aws:sso:::permissionSet/ssoins-test/ps-test', + permissionSetArn: PERMISSION_SET_ARN, principalType: 'GROUP', - principalId: 'group-1', + principalId: GROUP_PRINCIPAL_ID, }, operation: mockOperations.executeIdentityCenterDeleteAccountAssignment, }, @@ -111,8 +122,8 @@ const TOOL_CASES = [ toolId: 'identity_center_check_assignment_status', input: { ...CONNECTION, - instanceArn: 'arn:aws:sso:::instance/ssoins-test', - requestId: 'request-1', + instanceArn: INSTANCE_ARN, + requestId: REQUEST_ID, }, operation: mockOperations.executeIdentityCenterCheckAssignmentStatus, }, @@ -120,8 +131,8 @@ const TOOL_CASES = [ toolId: 'identity_center_check_assignment_deletion_status', input: { ...CONNECTION, - instanceArn: 'arn:aws:sso:::instance/ssoins-test', - requestId: 'request-1', + instanceArn: INSTANCE_ARN, + requestId: REQUEST_ID, }, operation: mockOperations.executeIdentityCenterCheckAssignmentDeletionStatus, }, @@ -129,12 +140,49 @@ const TOOL_CASES = [ toolId: 'identity_center_list_account_assignments', input: { ...CONNECTION, - instanceArn: 'arn:aws:sso:::instance/ssoins-test', + instanceArn: INSTANCE_ARN, principalType: 'USER', - principalId: 'user-1', + principalId: USER_PRINCIPAL_ID, }, operation: mockOperations.executeIdentityCenterListAccountAssignments, }, + { + toolId: 'identity_center_list_assignments_for_account', + input: { + ...CONNECTION, + instanceArn: INSTANCE_ARN, + accountId: '123456789012', + permissionSetArn: PERMISSION_SET_ARN, + }, + operation: mockOperations.executeIdentityCenterListAssignmentsForAccount, + }, + { + toolId: 'identity_center_describe_user', + input: { + ...CONNECTION, + identityStoreId: IDENTITY_STORE_ID, + userId: USER_PRINCIPAL_ID, + }, + operation: mockOperations.executeIdentityCenterDescribeUser, + }, + { + toolId: 'identity_center_describe_group', + input: { + ...CONNECTION, + identityStoreId: IDENTITY_STORE_ID, + groupId: GROUP_PRINCIPAL_ID, + }, + operation: mockOperations.executeIdentityCenterDescribeGroup, + }, + { + toolId: 'identity_center_list_group_memberships', + input: { + ...CONNECTION, + identityStoreId: IDENTITY_STORE_ID, + groupId: GROUP_PRINCIPAL_ID, + }, + operation: mockOperations.executeIdentityCenterListGroupMemberships, + }, ] as const describe('executeIdentityCenterTool', () => { @@ -181,6 +229,18 @@ describe('executeIdentityCenterTool', () => { }) }) + it.each([ + ['a malformed instance ARN', { ...CONNECTION, instanceArn: 'ssoins-test' }], + ['a truncated request ID', { ...CONNECTION, instanceArn: INSTANCE_ARN, requestId: 'req-1' }], + ])('rejects %s before provider work', async (_label, input) => { + const response = await executeIdentityCenterTool( + createRequest({ toolId: 'identity_center_check_assignment_status', input }) + ) + + expect(response.status).toBe(400) + expect(mockOperations.executeIdentityCenterCheckAssignmentStatus).not.toHaveBeenCalled() + }) + it('propagates cancellation without starting provider work', async () => { const controller = new AbortController() controller.abort(new DOMException('cancelled', 'AbortError')) diff --git a/apps/sim/lib/internal/identity-center/execute-tool.ts b/apps/sim/lib/internal/identity-center/execute-tool.ts index 47e21a478da..9904d9f7d78 100644 --- a/apps/sim/lib/internal/identity-center/execute-tool.ts +++ b/apps/sim/lib/internal/identity-center/execute-tool.ts @@ -3,10 +3,14 @@ import { awsIdentityCenterCheckAssignmentStatusContract } from '@/lib/api/contra import { awsIdentityCenterCreateAccountAssignmentContract } from '@/lib/api/contracts/tools/aws/identity-center-create-account-assignment' import { awsIdentityCenterDeleteAccountAssignmentContract } from '@/lib/api/contracts/tools/aws/identity-center-delete-account-assignment' import { awsIdentityCenterDescribeAccountContract } from '@/lib/api/contracts/tools/aws/identity-center-describe-account' +import { awsIdentityCenterDescribeGroupContract } from '@/lib/api/contracts/tools/aws/identity-center-describe-group' +import { awsIdentityCenterDescribeUserContract } from '@/lib/api/contracts/tools/aws/identity-center-describe-user' import { awsIdentityCenterGetGroupContract } from '@/lib/api/contracts/tools/aws/identity-center-get-group' import { awsIdentityCenterGetUserContract } from '@/lib/api/contracts/tools/aws/identity-center-get-user' import { awsIdentityCenterListAccountAssignmentsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-account-assignments' import { awsIdentityCenterListAccountsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-accounts' +import { awsIdentityCenterListAssignmentsForAccountContract } from '@/lib/api/contracts/tools/aws/identity-center-list-assignments-for-account' +import { awsIdentityCenterListGroupMembershipsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-group-memberships' import { awsIdentityCenterListGroupsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-groups' import { awsIdentityCenterListInstancesContract } from '@/lib/api/contracts/tools/aws/identity-center-list-instances' import { awsIdentityCenterListPermissionSetsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-permission-sets' @@ -16,10 +20,14 @@ import { executeIdentityCenterCreateAccountAssignment, executeIdentityCenterDeleteAccountAssignment, executeIdentityCenterDescribeAccount, + executeIdentityCenterDescribeGroup, + executeIdentityCenterDescribeUser, executeIdentityCenterGetGroup, executeIdentityCenterGetUser, executeIdentityCenterListAccountAssignments, executeIdentityCenterListAccounts, + executeIdentityCenterListAssignmentsForAccount, + executeIdentityCenterListGroupMemberships, executeIdentityCenterListGroups, executeIdentityCenterListInstances, executeIdentityCenterListPermissionSets, @@ -131,6 +139,38 @@ export const executeIdentityCenterTool: InternalToolOperationHandler = async ({ 'Failed to list account assignments', signal ) + case 'identity_center_list_assignments_for_account': + return executeInternalJsonToolOperation( + awsIdentityCenterListAssignmentsForAccountContract, + input, + executeIdentityCenterListAssignmentsForAccount, + 'Failed to list assignments for account', + signal + ) + case 'identity_center_describe_user': + return executeInternalJsonToolOperation( + awsIdentityCenterDescribeUserContract, + input, + executeIdentityCenterDescribeUser, + 'Failed to describe user', + signal + ) + case 'identity_center_describe_group': + return executeInternalJsonToolOperation( + awsIdentityCenterDescribeGroupContract, + input, + executeIdentityCenterDescribeGroup, + 'Failed to describe group', + signal + ) + case 'identity_center_list_group_memberships': + return executeInternalJsonToolOperation( + awsIdentityCenterListGroupMembershipsContract, + input, + executeIdentityCenterListGroupMemberships, + 'Failed to list group memberships', + signal + ) default: return Response.json( { error: `Unsupported Identity Center tool: ${toolId}` }, diff --git a/apps/sim/lib/internal/identity-center/operations.test.ts b/apps/sim/lib/internal/identity-center/operations.test.ts index f78050438e0..52ce4318cbc 100644 --- a/apps/sim/lib/internal/identity-center/operations.test.ts +++ b/apps/sim/lib/internal/identity-center/operations.test.ts @@ -18,10 +18,14 @@ vi.mock('@/lib/internal/identity-center/client', () => ({ createSSOAdminClient: mockCreateSSOAdminClient, deleteAccountAssignment: vi.fn(), describeAccount: vi.fn(), + describeGroupById: vi.fn(), + describeUserById: vi.fn(), getGroupByDisplayName: vi.fn(), getUserByEmail: vi.fn(), + listAccountAssignmentsForAccount: vi.fn(), listAccountAssignmentsForPrincipal: vi.fn(), listAccounts: vi.fn(), + listGroupMemberships: vi.fn(), listGroups: vi.fn(), listInstances: mockListInstances, listPermissionSets: vi.fn(), diff --git a/apps/sim/lib/internal/identity-center/operations.ts b/apps/sim/lib/internal/identity-center/operations.ts index 0f88ce9d9f3..0a1adb7e25d 100644 --- a/apps/sim/lib/internal/identity-center/operations.ts +++ b/apps/sim/lib/internal/identity-center/operations.ts @@ -3,10 +3,14 @@ import type { AwsIdentityCenterCheckAssignmentStatusBody } from '@/lib/api/contr import type { AwsIdentityCenterCreateAccountAssignmentBody } from '@/lib/api/contracts/tools/aws/identity-center-create-account-assignment' import type { AwsIdentityCenterDeleteAccountAssignmentBody } from '@/lib/api/contracts/tools/aws/identity-center-delete-account-assignment' import type { AwsIdentityCenterDescribeAccountBody } from '@/lib/api/contracts/tools/aws/identity-center-describe-account' +import type { AwsIdentityCenterDescribeGroupBody } from '@/lib/api/contracts/tools/aws/identity-center-describe-group' +import type { AwsIdentityCenterDescribeUserBody } from '@/lib/api/contracts/tools/aws/identity-center-describe-user' import type { AwsIdentityCenterGetGroupBody } from '@/lib/api/contracts/tools/aws/identity-center-get-group' import type { AwsIdentityCenterGetUserBody } from '@/lib/api/contracts/tools/aws/identity-center-get-user' import type { AwsIdentityCenterListAccountAssignmentsBody } from '@/lib/api/contracts/tools/aws/identity-center-list-account-assignments' import type { AwsIdentityCenterListAccountsBody } from '@/lib/api/contracts/tools/aws/identity-center-list-accounts' +import type { AwsIdentityCenterListAssignmentsForAccountBody } from '@/lib/api/contracts/tools/aws/identity-center-list-assignments-for-account' +import type { AwsIdentityCenterListGroupMembershipsBody } from '@/lib/api/contracts/tools/aws/identity-center-list-group-memberships' import type { AwsIdentityCenterListGroupsBody } from '@/lib/api/contracts/tools/aws/identity-center-list-groups' import type { AwsIdentityCenterListInstancesBody } from '@/lib/api/contracts/tools/aws/identity-center-list-instances' import type { AwsIdentityCenterListPermissionSetsBody } from '@/lib/api/contracts/tools/aws/identity-center-list-permission-sets' @@ -19,10 +23,14 @@ import { createSSOAdminClient, deleteAccountAssignment, describeAccount, + describeGroupById, + describeUserById, getGroupByDisplayName, getUserByEmail, + listAccountAssignmentsForAccount, listAccountAssignmentsForPrincipal, listAccounts, + listGroupMemberships, listGroups, listInstances, listPermissionSets, @@ -211,3 +219,66 @@ export async function executeIdentityCenterListAccountAssignments( client.destroy() } } + +export async function executeIdentityCenterListAssignmentsForAccount( + input: AwsIdentityCenterListAssignmentsForAccountBody, + signal?: AbortSignal +) { + const client = createSSOAdminClient(input) + try { + return await listAccountAssignmentsForAccount( + client, + input.instanceArn, + input.accountId, + input.permissionSetArn, + input.maxResults, + input.nextToken, + signal + ) + } finally { + client.destroy() + } +} + +export async function executeIdentityCenterDescribeUser( + input: AwsIdentityCenterDescribeUserBody, + signal?: AbortSignal +) { + const client = createIdentityStoreClient(input) + try { + return await describeUserById(client, input.identityStoreId, input.userId, signal) + } finally { + client.destroy() + } +} + +export async function executeIdentityCenterDescribeGroup( + input: AwsIdentityCenterDescribeGroupBody, + signal?: AbortSignal +) { + const client = createIdentityStoreClient(input) + try { + return await describeGroupById(client, input.identityStoreId, input.groupId, signal) + } finally { + client.destroy() + } +} + +export async function executeIdentityCenterListGroupMemberships( + input: AwsIdentityCenterListGroupMembershipsBody, + signal?: AbortSignal +) { + const client = createIdentityStoreClient(input) + try { + return await listGroupMemberships( + client, + input.identityStoreId, + input.groupId, + input.maxResults, + input.nextToken, + signal + ) + } finally { + client.destroy() + } +} diff --git a/apps/sim/lib/internal/identity-center/partition.test.ts b/apps/sim/lib/internal/identity-center/partition.test.ts new file mode 100644 index 00000000000..8c8facd6f69 --- /dev/null +++ b/apps/sim/lib/internal/identity-center/partition.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { validateAwsRegion } from '@/lib/core/security/input-validation' +import { + getAwsPartition, + resolveOrganizationsRegion, +} from '@/lib/internal/identity-center/partition' + +describe('getAwsPartition', () => { + it.each([ + ['us-east-1', 'aws'], + ['eu-west-2', 'aws'], + ['ap-southeast-1', 'aws'], + ['us-gov-west-1', 'aws-us-gov'], + ['us-gov-east-1', 'aws-us-gov'], + ['cn-north-1', 'aws-cn'], + ['cn-northwest-1', 'aws-cn'], + ['us-iso-east-1', 'aws-iso'], + ['us-isob-east-1', 'aws-iso-b'], + ['us-isof-east-1', 'aws-iso-f'], + ['us-isof-south-1', 'aws-iso-f'], + ['eu-isoe-west-1', 'aws-iso-e'], + ['eusc-de-east-1', 'aws-eusc'], + ])('maps %s to the %s partition', (region, partition) => { + expect(getAwsPartition(region)).toBe(partition) + }) + + /** + * `getAwsPartition` falls through to the commercial `aws` partition for any region + * it does not recognize, so widening `validateAwsRegion` without adding the matching + * rule here silently routes an isolated-partition caller to a commercial endpoint. + * This couples the two so that drift fails the suite instead of shipping. + */ + it('claims a non-commercial partition for every isolated region the shared validator admits', () => { + const isolatedRegions = [ + 'us-gov-west-1', + 'us-gov-east-1', + 'cn-north-1', + 'cn-northwest-1', + 'us-iso-east-1', + 'us-isob-east-1', + 'us-isof-east-1', + 'us-isof-south-1', + 'eu-isoe-west-1', + 'eusc-de-east-1', + ] + + for (const region of isolatedRegions) { + expect(validateAwsRegion(region).isValid).toBe(true) + expect(getAwsPartition(region)).not.toBe('aws') + } + }) +}) + +describe('resolveOrganizationsRegion', () => { + it('signs commercial regions against the us-east-1 Organizations endpoint', () => { + expect(resolveOrganizationsRegion('us-east-1')).toBe('us-east-1') + expect(resolveOrganizationsRegion('eu-west-2')).toBe('us-east-1') + expect(resolveOrganizationsRegion('ap-northeast-1')).toBe('us-east-1') + }) + + it('never sends a GovCloud caller to a commercial endpoint', () => { + for (const region of ['us-gov-west-1', 'us-gov-east-1']) { + const resolved = resolveOrganizationsRegion(region) + expect(resolved).not.toBe('us-east-1') + expect(resolved).toBe('us-gov-west-1') + } + }) + + it('never sends a China caller to a commercial endpoint', () => { + for (const region of ['cn-north-1', 'cn-northwest-1']) { + const resolved = resolveOrganizationsRegion(region) + expect(resolved).not.toBe('us-east-1') + expect(resolved).toBe('cn-northwest-1') + } + }) + + it.each(['us-iso-east-1', 'us-isob-east-1', 'eu-isoe-west-1', 'eusc-de-east-1'])( + 'throws rather than guessing an endpoint for %s', + (region) => { + expect(() => resolveOrganizationsRegion(region)).toThrow(/does not publish/) + } + ) +}) diff --git a/apps/sim/lib/internal/identity-center/partition.ts b/apps/sim/lib/internal/identity-center/partition.ts new file mode 100644 index 00000000000..b1ce41ae739 --- /dev/null +++ b/apps/sim/lib/internal/identity-center/partition.ts @@ -0,0 +1,82 @@ +/** + * AWS partitions Sim can address, derived from the region prefixes the shared + * region validator admits (`validateAwsRegion`). + */ +export type AwsPartition = + | 'aws' + | 'aws-us-gov' + | 'aws-cn' + | 'aws-iso' + | 'aws-iso-b' + | 'aws-iso-e' + | 'aws-iso-f' + | 'aws-eusc' + +interface PartitionRule { + prefix: string + partition: AwsPartition +} + +/** + * Ordered longest-prefix-first so `us-isob-east-1` and `us-isof-south-1` are not + * mistaken for `us-iso-*`. Regions that match no prefix belong to the commercial + * `aws` partition, so every isolated partition the shared region validator admits + * must appear here — otherwise it falls through to `aws` and + * `resolveOrganizationsRegion` hands back a commercial endpoint instead of throwing. + */ +const PARTITION_RULES: readonly PartitionRule[] = [ + { prefix: 'us-gov-', partition: 'aws-us-gov' }, + { prefix: 'us-isob-', partition: 'aws-iso-b' }, + { prefix: 'us-isof-', partition: 'aws-iso-f' }, + { prefix: 'us-iso-', partition: 'aws-iso' }, + { prefix: 'eu-isoe-', partition: 'aws-iso-e' }, + { prefix: 'eusc-', partition: 'aws-eusc' }, + { prefix: 'cn-', partition: 'aws-cn' }, +] as const + +/** + * Resolves the AWS partition a region belongs to. + */ +export function getAwsPartition(region: string): AwsPartition { + const normalized = region.trim().toLowerCase() + for (const rule of PARTITION_RULES) { + if (normalized.startsWith(rule.prefix)) return rule.partition + } + return 'aws' +} + +/** + * AWS Organizations is a global service, but global *per partition* — each + * partition has exactly one Organizations endpoint and a caller must sign for + * that partition's home region. + * + * - `aws` → `organizations.us-east-1.amazonaws.com` + * - `aws-us-gov` → `organizations.us-gov-west-1.amazonaws.com` (both GovCloud regions) + * - `aws-cn` → `organizations.cn-northwest-1.amazonaws.com.cn` + * + * @see https://docs.aws.amazon.com/general/latest/gr/ao.html + * @see https://docs.aws.amazon.com/organizations/latest/APIReference/Welcome.html + */ +const ORGANIZATIONS_REGION_BY_PARTITION: Partial> = { + aws: 'us-east-1', + 'aws-us-gov': 'us-gov-west-1', + 'aws-cn': 'cn-northwest-1', +} + +/** + * Returns the Organizations home region to sign for, given the caller's region. + * + * Throws for the ISO and EU Sovereign partitions rather than silently falling + * back to a commercial endpoint the caller's credentials cannot sign for: AWS + * does not publish Organizations endpoints for those partitions. + */ +export function resolveOrganizationsRegion(region: string): string { + const partition = getAwsPartition(region) + const organizationsRegion = ORGANIZATIONS_REGION_BY_PARTITION[partition] + if (!organizationsRegion) { + throw new Error( + `AWS Organizations is not supported in the ${partition} partition (region "${region}"): AWS does not publish an Organizations endpoint for it. Use an operation that does not read AWS Organizations, such as List Instances or List Permission Sets.` + ) + } + return organizationsRegion +} diff --git a/apps/sim/lib/internal/quickbooks/execute-tool.test.ts b/apps/sim/lib/internal/quickbooks/execute-tool.test.ts new file mode 100644 index 00000000000..5cdd77e98e6 --- /dev/null +++ b/apps/sim/lib/internal/quickbooks/execute-tool.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + */ +import { createExecutionContext } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + addAttachment: vi.fn(), + createBillPayment: vi.fn(), + downloadDocument: vi.fn(), + updateBill: vi.fn(), + updateBillPayment: vi.fn(), + updateCreditMemo: vi.fn(), + updateCustomerPayment: vi.fn(), + updateEmployee: vi.fn(), + updateItem: vi.fn(), + updatePurchase: vi.fn(), + updatePurchaseOrder: vi.fn(), + updateRefundReceipt: vi.fn(), + updateVendor: vi.fn(), + updateVendorCredit: vi.fn(), +})) + +vi.mock('@/lib/internal/quickbooks/operations', () => ({ + QuickBooksInternalOperationError: class QuickBooksInternalOperationError extends Error { + constructor( + readonly status: number, + message: string + ) { + super(message) + } + }, + executeQuickBooksAddAttachment: mocks.addAttachment, + executeQuickBooksDownloadDocument: mocks.downloadDocument, +})) + +vi.mock('@/lib/internal/quickbooks/provider-operations', () => ({ + executeQuickBooksCreateBillPaymentOperation: mocks.createBillPayment, + executeQuickBooksUpdateBillOperation: mocks.updateBill, + executeQuickBooksUpdateBillPaymentOperation: mocks.updateBillPayment, + executeQuickBooksUpdateCreditMemoOperation: mocks.updateCreditMemo, + executeQuickBooksUpdateCustomerPaymentOperation: mocks.updateCustomerPayment, + executeQuickBooksUpdateEmployeeOperation: mocks.updateEmployee, + executeQuickBooksUpdateItemOperation: mocks.updateItem, + executeQuickBooksUpdatePurchaseOperation: mocks.updatePurchase, + executeQuickBooksUpdatePurchaseOrderOperation: mocks.updatePurchaseOrder, + executeQuickBooksUpdateRefundReceiptOperation: mocks.updateRefundReceipt, + executeQuickBooksUpdateVendorCreditOperation: mocks.updateVendorCredit, + executeQuickBooksUpdateVendorOperation: mocks.updateVendor, +})) + +import { executeQuickBooksTool } from '@/lib/internal/quickbooks/execute-tool' +import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' + +function request(overrides: Partial = {}): InternalToolOperationCall { + return { + toolId: 'quickbooks_download_attachment', + input: { + accessToken: 'token', + realmId: '123', + quickBooksEnvironment: 'sandbox', + attachmentId: 'attachment-1', + }, + headers: new Headers(), + context: { + ...createExecutionContext({ workflowId: 'workflow-1' }), + userId: 'user-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + }, + requestId: 'request-1', + ...overrides, + } +} + +describe('executeQuickBooksTool', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.addAttachment.mockResolvedValue({ attachmentId: 'attachment-1' }) + mocks.downloadDocument.mockResolvedValue({ attachmentId: 'attachment-1' }) + for (const operation of [ + mocks.createBillPayment, + mocks.updateBill, + mocks.updateBillPayment, + mocks.updateCreditMemo, + mocks.updateCustomerPayment, + mocks.updateEmployee, + mocks.updateItem, + mocks.updatePurchase, + mocks.updatePurchaseOrder, + mocks.updateRefundReceipt, + mocks.updateVendor, + mocks.updateVendorCredit, + ]) { + operation.mockResolvedValue({ + success: true, + output: { id: 'entity-1' }, + }) + } + }) + + it.each([ + ['quickbooks_create_bill_payment', mocks.createBillPayment], + ['quickbooks_update_bill', mocks.updateBill], + ['quickbooks_update_bill_payment', mocks.updateBillPayment], + ['quickbooks_update_credit_memo', mocks.updateCreditMemo], + ['quickbooks_update_customer_payment', mocks.updateCustomerPayment], + ['quickbooks_update_employee', mocks.updateEmployee], + ['quickbooks_update_item', mocks.updateItem], + ['quickbooks_update_purchase', mocks.updatePurchase], + ['quickbooks_update_purchase_order', mocks.updatePurchaseOrder], + ['quickbooks_update_refund_receipt', mocks.updateRefundReceipt], + ['quickbooks_update_vendor', mocks.updateVendor], + ['quickbooks_update_vendor_credit', mocks.updateVendorCredit], + ])('dispatches %s through its internal provider operation', async (toolId, operation) => { + const controller = new AbortController() + const operationRequest = request({ + toolId, + input: { + accessToken: 'token', + realmId: '123', + quickBooksEnvironment: 'sandbox', + entityId: 'entity-1', + }, + signal: controller.signal, + }) + + const response = await executeQuickBooksTool(operationRequest) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + output: { id: 'entity-1' }, + }) + expect(operation).toHaveBeenCalledWith( + operationRequest.input, + controller.signal, + operationRequest.context + ) + }) + + it('dispatches downloads with trusted execution context', async () => { + const controller = new AbortController() + + const response = await executeQuickBooksTool(request({ signal: controller.signal })) + + expect(response.status).toBe(200) + expect(mocks.downloadDocument).toHaveBeenCalledWith( + { + accessToken: 'token', + realmId: '123', + quickBooksEnvironment: 'sandbox', + documentKind: 'attachment', + attachmentId: 'attachment-1', + }, + { + userId: 'user-1', + requestId: 'request-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + signal: controller.signal, + } + ) + }) + + it('rejects missing trusted user identity', async () => { + const response = await executeQuickBooksTool(request({ context: { workflowId: 'workflow-1' } })) + + expect(response.status).toBe(401) + expect(mocks.downloadDocument).not.toHaveBeenCalled() + }) + + it('rejects malformed provider input', async () => { + const response = await executeQuickBooksTool(request({ input: { accessToken: '' } })) + + expect(response.status).toBe(400) + expect(mocks.downloadDocument).not.toHaveBeenCalled() + }) + + it('rejects oversized operation input before dispatch', async () => { + const response = await executeQuickBooksTool( + request({ + input: { + accessToken: 'token', + realmId: '123', + quickBooksEnvironment: 'sandbox', + attachmentId: 'attachment-1', + extra: 'x'.repeat(1024 * 1024 + 1), + }, + }) + ) + + expect(response.status).toBe(413) + expect(mocks.downloadDocument).not.toHaveBeenCalled() + }) + + it('propagates cancellation before validation or provider work', async () => { + const controller = new AbortController() + controller.abort(new DOMException('cancelled', 'AbortError')) + + await expect( + executeQuickBooksTool(request({ signal: controller.signal })) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(mocks.downloadDocument).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/quickbooks/execute-tool.ts b/apps/sim/lib/internal/quickbooks/execute-tool.ts new file mode 100644 index 00000000000..da73d506e80 --- /dev/null +++ b/apps/sim/lib/internal/quickbooks/execute-tool.ts @@ -0,0 +1,196 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + quickBooksAddAttachmentBodySchema, + quickBooksDownloadDocumentBodySchema, +} from '@/lib/api/contracts/tools/quickbooks' +import { getValidationErrorMessage } from '@/lib/api/server' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { + executeQuickBooksAddAttachment, + executeQuickBooksDownloadDocument, + QuickBooksInternalOperationError, + type QuickBooksOperationContext, +} from '@/lib/internal/quickbooks/operations' +import { + executeQuickBooksCreateBillPaymentOperation, + executeQuickBooksUpdateBillOperation, + executeQuickBooksUpdateBillPaymentOperation, + executeQuickBooksUpdateCreditMemoOperation, + executeQuickBooksUpdateCustomerPaymentOperation, + executeQuickBooksUpdateEmployeeOperation, + executeQuickBooksUpdateItemOperation, + executeQuickBooksUpdatePurchaseOperation, + executeQuickBooksUpdatePurchaseOrderOperation, + executeQuickBooksUpdateRefundReceiptOperation, + executeQuickBooksUpdateVendorCreditOperation, + executeQuickBooksUpdateVendorOperation, +} from '@/lib/internal/quickbooks/provider-operations' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { + InternalToolOperationCall, + InternalToolOperationHandler, +} from '@/lib/internal/tool-operations/types' + +const logger = createLogger('QuickBooksToolExecution') +const QUICKBOOKS_MAX_OPERATION_INPUT_BYTES = 1024 * 1024 +const QUICKBOOKS_FILE_TOOL_IDS = [ + 'quickbooks_add_attachment', + 'quickbooks_download_attachment', + 'quickbooks_download_transaction_pdf', +] as const + +type QuickBooksFileToolId = (typeof QUICKBOOKS_FILE_TOOL_IDS)[number] + +function isQuickBooksFileToolId(value: string): value is QuickBooksFileToolId { + return QUICKBOOKS_FILE_TOOL_IDS.some((toolId) => toolId === value) +} + +function inputSizeError(input: unknown): Response | null { + let serialized: string + try { + serialized = JSON.stringify(input) ?? '' + } catch { + return Response.json({ success: false, error: 'Invalid request data' }, { status: 400 }) + } + if (Buffer.byteLength(serialized, 'utf8') <= QUICKBOOKS_MAX_OPERATION_INPUT_BYTES) return null + return Response.json( + { + success: false, + error: `Request body exceeds the maximum allowed size of ${QUICKBOOKS_MAX_OPERATION_INPUT_BYTES} bytes`, + }, + { status: 413 } + ) +} + +function operationContext(request: InternalToolOperationCall): QuickBooksOperationContext | null { + const userId = request.context.userId + if (!userId) return null + return { + userId, + requestId: request.requestId, + workspaceId: request.context.workspaceId, + workflowId: request.context.workflowId, + executionId: request.context.executionId, + signal: request.signal ?? new AbortController().signal, + } +} + +export const executeQuickBooksTool: InternalToolOperationHandler = async (request) => { + request.signal?.throwIfAborted() + switch (request.toolId) { + case 'quickbooks_create_bill_payment': + return executeToolOperationImplementation( + executeQuickBooksCreateBillPaymentOperation, + request + ) + case 'quickbooks_update_bill': + return executeToolOperationImplementation(executeQuickBooksUpdateBillOperation, request) + case 'quickbooks_update_bill_payment': + return executeToolOperationImplementation( + executeQuickBooksUpdateBillPaymentOperation, + request + ) + case 'quickbooks_update_credit_memo': + return executeToolOperationImplementation(executeQuickBooksUpdateCreditMemoOperation, request) + case 'quickbooks_update_customer_payment': + return executeToolOperationImplementation( + executeQuickBooksUpdateCustomerPaymentOperation, + request + ) + case 'quickbooks_update_employee': + return executeToolOperationImplementation(executeQuickBooksUpdateEmployeeOperation, request) + case 'quickbooks_update_item': + return executeToolOperationImplementation(executeQuickBooksUpdateItemOperation, request) + case 'quickbooks_update_purchase': + return executeToolOperationImplementation(executeQuickBooksUpdatePurchaseOperation, request) + case 'quickbooks_update_purchase_order': + return executeToolOperationImplementation( + executeQuickBooksUpdatePurchaseOrderOperation, + request + ) + case 'quickbooks_update_refund_receipt': + return executeToolOperationImplementation( + executeQuickBooksUpdateRefundReceiptOperation, + request + ) + case 'quickbooks_update_vendor': + return executeToolOperationImplementation(executeQuickBooksUpdateVendorOperation, request) + case 'quickbooks_update_vendor_credit': + return executeToolOperationImplementation( + executeQuickBooksUpdateVendorCreditOperation, + request + ) + } + + if (!isQuickBooksFileToolId(request.toolId)) { + return Response.json( + { + success: false, + error: `Unsupported QuickBooks tool: ${request.toolId}`, + }, + { status: 500 } + ) + } + + const sizeError = inputSizeError(request.input) + if (sizeError) return sizeError + const context = operationContext(request) + if (!context) { + return Response.json({ success: false, error: 'Authentication required' }, { status: 401 }) + } + + try { + if (request.toolId === 'quickbooks_add_attachment') { + const parsed = quickBooksAddAttachmentBodySchema.safeParse(request.input) + if (!parsed.success) { + return Response.json( + { + success: false, + error: getValidationErrorMessage(parsed.error, 'Invalid request data'), + }, + { status: 400 } + ) + } + return Response.json({ + success: true, + output: await executeQuickBooksAddAttachment(parsed.data, context), + }) + } + + const documentInput = { + ...(request.input as Record), + documentKind: + request.toolId === 'quickbooks_download_attachment' ? 'attachment' : 'transaction_pdf', + } + const parsed = quickBooksDownloadDocumentBodySchema.safeParse(documentInput) + if (!parsed.success) { + return Response.json( + { + success: false, + error: getValidationErrorMessage(parsed.error, 'Invalid request data'), + }, + { status: 400 } + ) + } + return Response.json({ + success: true, + output: await executeQuickBooksDownloadDocument(parsed.data, context), + }) + } catch (error) { + request.signal?.throwIfAborted() + const status = + error instanceof QuickBooksInternalOperationError + ? error.status + : isPayloadSizeLimitError(error) + ? 413 + : 500 + const message = getErrorMessage(error, 'QuickBooks file operation failed') + logger.error('QuickBooks file operation failed', { + error: message, + requestId: request.requestId, + toolId: request.toolId, + }) + return Response.json({ success: false, error: message }, { status }) + } +} diff --git a/apps/sim/lib/internal/quickbooks/operations.test.ts b/apps/sim/lib/internal/quickbooks/operations.test.ts new file mode 100644 index 00000000000..a7784674c94 --- /dev/null +++ b/apps/sim/lib/internal/quickbooks/operations.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + uploadCopilotFile: vi.fn(), + uploadExecutionFile: vi.fn(), + guardedFetch: vi.fn(), + closeDispatcher: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/copilot', () => ({ + uploadCopilotFile: mocks.uploadCopilotFile, +})) +vi.mock('@/lib/uploads/contexts/execution', () => ({ + uploadExecutionFile: mocks.uploadExecutionFile, +})) +vi.mock('@/lib/core/security/input-validation.server', () => ({ + createSsrfGuardedFetchWithDispatcher: () => ({ + fetch: mocks.guardedFetch, + dispatcher: { close: mocks.closeDispatcher }, + }), +})) +vi.mock('@/tools/quickbooks/client', () => ({ + QUICKBOOKS_MAX_RESPONSE_BYTES: 8 * 1024 * 1024, + buildQuickBooksCompanyUrl: (realmId: string, resource: string) => { + const url = new URL(`https://quickbooks.api.intuit.com/v3/company/${realmId}/${resource}`) + url.searchParams.set('minorversion', '75') + return url + }, + buildQuickBooksHeaders: (accessToken: string) => ({ + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }), +})) + +import { executeQuickBooksDownloadDocument } from '@/lib/internal/quickbooks/operations' +import { QUICKBOOKS_MAX_ATTACHMENT_BYTES } from '@/tools/quickbooks/documents_utils' + +const COPILOT_FILE = { + id: 'file-1', + key: 'copilot/file-1', + context: 'copilot', + name: 'receipt.png', + url: '/api/files/serve/copilot/file-1', + size: 4, + type: 'image/png', +} + +function context(overrides: Record = {}) { + return { + userId: 'user-1', + requestId: 'request-1', + signal: new AbortController().signal, + ...overrides, + } +} + +describe('QuickBooks internal operations', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn()) + mocks.closeDispatcher.mockResolvedValue(undefined) + mocks.uploadCopilotFile.mockResolvedValue(COPILOT_FILE) + mocks.uploadExecutionFile.mockResolvedValue({ ...COPILOT_FILE, context: 'execution' }) + }) + + it('resolves Intuit temporary URLs and downloads attachment bytes without forwarding OAuth', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response('"https://attachments.example/receipt.png?signature=secret"', { + headers: { 'content-type': 'text/plain' }, + }) + ) + mocks.guardedFetch.mockResolvedValue( + new Response(new Uint8Array([1, 2, 3, 4]), { + headers: { + 'content-disposition': 'attachment; filename="receipt.png"', + 'content-length': '4', + 'content-type': 'image/png', + }, + }) + ) + + const result = await executeQuickBooksDownloadDocument( + { + documentKind: 'attachment', + accessToken: 'secret-token', + realmId: '123', + quickBooksEnvironment: 'sandbox', + attachmentId: 'attachment-1', + }, + context() + ) + + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(String(url)).toMatch( + /^https:\/\/(sandbox-)?quickbooks\.api\.intuit\.com\/v3\/company\/123\/download\/attachment-1\?minorversion=75$/ + ) + expect(init).toEqual( + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ Authorization: 'Bearer secret-token' }), + }) + ) + expect(mocks.guardedFetch).toHaveBeenCalledWith( + 'https://attachments.example/receipt.png?signature=secret', + expect.objectContaining({ + method: 'GET', + headers: { Accept: '*/*' }, + }) + ) + expect(mocks.closeDispatcher).toHaveBeenCalledOnce() + expect(mocks.uploadCopilotFile).toHaveBeenCalledWith({ + buffer: Buffer.from([1, 2, 3, 4]), + fileName: 'receipt.png', + contentType: 'image/png', + userId: 'user-1', + }) + expect(result).toMatchObject({ file: COPILOT_FILE, attachmentId: 'attachment-1' }) + }) + + it('rejects an oversized attachment from Content-Length before buffering it', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response('https://attachments.example/oversized.bin', { + headers: { 'content-type': 'text/plain' }, + }) + ) + mocks.guardedFetch.mockResolvedValue( + new Response(new Uint8Array([1]), { + headers: { 'content-length': String(QUICKBOOKS_MAX_ATTACHMENT_BYTES + 1) }, + }) + ) + + await expect( + executeQuickBooksDownloadDocument( + { + documentKind: 'attachment', + accessToken: 'secret-token', + realmId: '123', + quickBooksEnvironment: 'sandbox', + attachmentId: 'attachment-1', + }, + context() + ) + ).rejects.toThrow('exceeds maximum size') + expect(mocks.closeDispatcher).toHaveBeenCalledOnce() + expect(mocks.uploadCopilotFile).not.toHaveBeenCalled() + }) + + it('rejects malformed PDF content before storing it', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(new TextEncoder().encode('not a PDF'), { + headers: { 'content-type': 'application/pdf' }, + }) + ) + + await expect( + executeQuickBooksDownloadDocument( + { + documentKind: 'transaction_pdf', + accessToken: 'secret-token', + realmId: '123', + quickBooksEnvironment: 'sandbox', + transactionType: 'invoice', + transactionId: 'invoice-1', + }, + context() + ) + ).rejects.toThrow('malformed PDF') + expect(mocks.uploadCopilotFile).not.toHaveBeenCalled() + }) + + it('stores valid PDFs in trusted execution scope', async () => { + const pdf = new TextEncoder().encode('%PDF-1.7\n') + vi.mocked(fetch).mockResolvedValue( + new Response(pdf, { headers: { 'content-type': 'application/pdf' } }) + ) + + await executeQuickBooksDownloadDocument( + { + documentKind: 'transaction_pdf', + accessToken: 'secret-token', + realmId: '123', + quickBooksEnvironment: 'sandbox', + transactionType: 'invoice', + transactionId: 'invoice-1', + }, + context({ + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + + expect(mocks.uploadExecutionFile).toHaveBeenCalledWith( + { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + Buffer.from(pdf), + 'quickbooks-invoice-invoice-1.pdf', + 'application/pdf', + 'user-1' + ) + expect(mocks.uploadCopilotFile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/quickbooks/operations.ts b/apps/sim/lib/internal/quickbooks/operations.ts new file mode 100644 index 00000000000..13a53ffb154 --- /dev/null +++ b/apps/sim/lib/internal/quickbooks/operations.ts @@ -0,0 +1,373 @@ +import { createLogger } from '@sim/logger' +import { userFileSchema } from '@/lib/api/contracts/primitives' +import type { + QuickBooksAddAttachmentBody, + QuickBooksDownloadDocumentBody, +} from '@/lib/api/contracts/tools/quickbooks' +import { createSsrfGuardedFetchWithDispatcher } from '@/lib/core/security/input-validation.server' +import { + assertContentLengthWithinLimit, + assertKnownSizeWithinLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { buildQuickBooksCompanyUrl, buildQuickBooksHeaders } from '@/tools/quickbooks/client' +import { + assertQuickBooksAttachmentExtension, + assertSingleQuickBooksFile, + buildQuickBooksAttachableMetadata, + getQuickBooksDocumentError, + getQuickBooksDocumentTransaction, + parseQuickBooksAttachableResponse, + QUICKBOOKS_DOCUMENT_METADATA_TIMEOUT_MS, + QUICKBOOKS_DOCUMENT_TRANSFER_TIMEOUT_MS, + QUICKBOOKS_MAX_ATTACHMENT_BYTES, + QUICKBOOKS_TEMP_URL_MAX_BYTES, + quickBooksDocumentSignal, + sanitizeQuickBooksFileName, + validateQuickBooksAttachmentFileType, +} from '@/tools/quickbooks/documents_utils' + +const logger = createLogger('QuickBooksInternalOperations') + +export interface QuickBooksOperationContext { + userId: string + requestId: string + workspaceId?: string + workflowId?: string + executionId?: string + signal: AbortSignal +} + +export class QuickBooksInternalOperationError extends Error { + constructor( + readonly status: number, + message: string + ) { + super(message) + this.name = 'QuickBooksInternalOperationError' + } +} + +interface DownloadedDocument { + buffer: Buffer + mimeType: string + fileName: string +} + +async function errorFromResponse(response: Response, fallback: string): Promise { + let message = fallback + try { + const data = (await response.json()) as { error?: unknown } + if (typeof data.error === 'string' && data.error.trim()) message = data.error + } catch {} + throw new QuickBooksInternalOperationError(response.status, message) +} + +function contentDispositionFileName(value: string | null): string | undefined { + if (!value) return undefined + const utf8 = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + if (utf8) { + try { + return decodeURIComponent(utf8) + } catch { + return utf8 + } + } + return value.match(/filename="?([^";]+)"?/i)?.[1] +} + +async function downloadQuickBooksAttachment( + body: Extract, + signal: AbortSignal +): Promise { + const downloadUrl = buildQuickBooksCompanyUrl( + body.realmId, + `download/${encodeURIComponent(body.attachmentId)}`, + body.quickBooksEnvironment + ) + const metadataSignal = quickBooksDocumentSignal(signal, QUICKBOOKS_DOCUMENT_METADATA_TIMEOUT_MS) + const downloadUrlResponse = await fetch(downloadUrl, { + method: 'GET', + headers: { ...buildQuickBooksHeaders(body.accessToken), Accept: '*/*' }, + signal: metadataSignal, + }) + if (downloadUrlResponse.status === 404) { + throw new Error('This QuickBooks attachment has no downloadable file') + } + if (!downloadUrlResponse.ok) { + throw await getQuickBooksDocumentError(downloadUrlResponse, metadataSignal) + } + const temporaryUrlText = await readResponseTextWithLimit(downloadUrlResponse, { + maxBytes: QUICKBOOKS_TEMP_URL_MAX_BYTES, + label: 'QuickBooks attachment temporary URL response', + signal: metadataSignal, + }) + let temporaryUrl = temporaryUrlText.trim() + if (temporaryUrl.startsWith('"')) { + try { + const parsed = JSON.parse(temporaryUrl) + temporaryUrl = typeof parsed === 'string' ? parsed.trim() : '' + } catch { + throw new Error('QuickBooks returned a malformed attachment download URL') + } + } + if (!temporaryUrl) throw new Error('QuickBooks returned an empty attachment download URL') + + const guarded = createSsrfGuardedFetchWithDispatcher({ + profile: 'contentFetch', + maxResponseSize: QUICKBOOKS_MAX_ATTACHMENT_BYTES, + }) + const transferSignal = quickBooksDocumentSignal(signal, QUICKBOOKS_DOCUMENT_TRANSFER_TIMEOUT_MS) + let downloadResponse: Response + let buffer: Buffer + try { + downloadResponse = await guarded.fetch(temporaryUrl, { + method: 'GET', + headers: { Accept: '*/*' }, + signal: transferSignal, + }) + if (!downloadResponse.ok) { + throw await getQuickBooksDocumentError(downloadResponse, transferSignal) + } + assertContentLengthWithinLimit( + downloadResponse.headers, + QUICKBOOKS_MAX_ATTACHMENT_BYTES, + 'QuickBooks attachment file' + ) + buffer = await readResponseToBufferWithLimit(downloadResponse, { + maxBytes: QUICKBOOKS_MAX_ATTACHMENT_BYTES, + label: 'QuickBooks attachment file', + signal: transferSignal, + }) + } finally { + await guarded.dispatcher.close() + } + if (buffer.length === 0) throw new Error('QuickBooks attachment file is empty') + + const fallbackName = `quickbooks-attachment-${body.attachmentId}` + + return { + buffer, + mimeType: + downloadResponse.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() || + 'application/octet-stream', + fileName: sanitizeQuickBooksFileName( + body.fileName ?? undefined, + contentDispositionFileName(downloadResponse.headers.get('content-disposition')) || + fallbackName + ), + } +} + +async function downloadQuickBooksTransactionPdf( + body: Extract, + signal: AbortSignal +): Promise { + const fileName = sanitizeQuickBooksFileName( + body.fileName ?? undefined, + `quickbooks-${body.transactionType.replaceAll('_', '-')}-${body.transactionId}.pdf` + ) + if (!fileName.toLowerCase().endsWith('.pdf')) throw new Error('PDF filename must end in .pdf') + + const { resource } = getQuickBooksDocumentTransaction(body.transactionType) + const url = buildQuickBooksCompanyUrl( + body.realmId, + `${resource}/${encodeURIComponent(body.transactionId)}/pdf`, + body.quickBooksEnvironment + ) + const transferSignal = quickBooksDocumentSignal(signal, QUICKBOOKS_DOCUMENT_TRANSFER_TIMEOUT_MS) + const response = await fetch(url, { + method: 'GET', + headers: { ...buildQuickBooksHeaders(body.accessToken), Accept: 'application/pdf' }, + signal: transferSignal, + }) + if (!response.ok) throw await getQuickBooksDocumentError(response, signal) + + const mimeType = + response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() ?? '' + if (mimeType !== 'application/pdf') throw new Error('QuickBooks returned a non-PDF response') + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: QUICKBOOKS_MAX_ATTACHMENT_BYTES, + label: 'QuickBooks transaction PDF', + signal: transferSignal, + }) + if (buffer.length === 0) throw new Error('QuickBooks returned an empty PDF') + if (buffer.subarray(0, 5).toString('ascii') !== '%PDF-') { + throw new Error('QuickBooks returned malformed PDF content') + } + return { buffer, mimeType, fileName } +} + +export async function executeQuickBooksAddAttachment( + data: QuickBooksAddAttachmentBody, + context: QuickBooksOperationContext +) { + context.signal.throwIfAborted() + const url = buildQuickBooksCompanyUrl( + data.realmId, + data.attachmentKind === 'file' ? 'upload' : 'attachable', + data.quickBooksEnvironment + ) + let response: Response + + if (data.attachmentKind === 'note') { + const metadata = buildQuickBooksAttachableMetadata(data.targetType, data.targetId, { + note: data.note!, + }) + response = await fetch(url, { + method: 'POST', + headers: { + ...buildQuickBooksHeaders(data.accessToken), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(metadata), + signal: quickBooksDocumentSignal(context.signal, QUICKBOOKS_DOCUMENT_METADATA_TIMEOUT_MS), + }) + } else { + const rawFile = assertSingleQuickBooksFile(data.file ?? undefined) + const files = processFilesToUserFiles([rawFile], context.requestId, logger) + if (files.length !== 1) throw new Error('Exactly one valid file is required') + const file = files[0] + assertKnownSizeWithinLimit( + file.size, + QUICKBOOKS_MAX_ATTACHMENT_BYTES, + 'QuickBooks attachment file' + ) + const resolvedName = sanitizeQuickBooksFileName(data.fileName ?? undefined, file.name) + assertQuickBooksAttachmentExtension(resolvedName) + const denied = await assertToolFileAccess(file.key, context.userId, context.requestId, logger) + if (denied) await errorFromResponse(denied, 'Unable to access QuickBooks attachment file') + + let downloaded: Awaited> + try { + downloaded = await downloadServableFileFromStorage(file, context.requestId, logger, { + maxBytes: QUICKBOOKS_MAX_ATTACHMENT_BYTES, + signal: context.signal, + }) + } catch (error) { + const notReady = docNotReadyResponse(error) + if (notReady) await errorFromResponse(notReady, 'QuickBooks attachment file is not ready') + throw error + } + context.signal.throwIfAborted() + assertKnownSizeWithinLimit( + downloaded.buffer.length, + QUICKBOOKS_MAX_ATTACHMENT_BYTES, + 'QuickBooks attachment file' + ) + if (downloaded.buffer.length === 0) { + throw new Error('QuickBooks attachment file cannot be empty') + } + + const storedMime = (downloaded.contentType || file.type || '') + .split(';', 1)[0] + .trim() + .toLowerCase() + const requestedMime = data.contentType?.trim().toLowerCase() || storedMime + const mimeType = validateQuickBooksAttachmentFileType(resolvedName, requestedMime) + if (data.contentType && storedMime && requestedMime !== storedMime) { + validateQuickBooksAttachmentFileType(resolvedName, storedMime) + } + + const metadata = buildQuickBooksAttachableMetadata(data.targetType, data.targetId, { + fileName: resolvedName, + contentType: mimeType, + description: data.description ?? undefined, + }) + const formData = new FormData() + formData.append( + 'file_metadata_01', + new Blob([JSON.stringify(metadata)], { type: 'application/json' }), + 'attachment.json' + ) + formData.append( + 'file_content_01', + new Blob( + [ + new Uint8Array( + downloaded.buffer.buffer as ArrayBuffer, + downloaded.buffer.byteOffset, + downloaded.buffer.byteLength + ), + ], + { type: mimeType } + ), + resolvedName + ) + response = await fetch(url, { + method: 'POST', + headers: buildQuickBooksHeaders(data.accessToken), + body: formData, + signal: quickBooksDocumentSignal(context.signal, QUICKBOOKS_DOCUMENT_TRANSFER_TIMEOUT_MS), + }) + } + + if (!response.ok) throw await getQuickBooksDocumentError(response, context.signal) + const transformed = await parseQuickBooksAttachableResponse(response, context.signal) + return { + attachment: transformed.attachment, + attachmentId: transformed.attachment.Id.trim(), + attachmentKind: data.attachmentKind, + targetType: data.targetType, + targetId: data.targetId, + time: transformed.time, + } +} + +export async function executeQuickBooksDownloadDocument( + body: QuickBooksDownloadDocumentBody, + context: QuickBooksOperationContext +) { + context.signal.throwIfAborted() + const downloaded = + body.documentKind === 'attachment' + ? await downloadQuickBooksAttachment(body, context.signal) + : await downloadQuickBooksTransactionPdf(body, context.signal) + + context.signal.throwIfAborted() + const executionContext = + context.workspaceId && context.workflowId && context.executionId + ? { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + executionId: context.executionId, + } + : null + const storedFile = userFileSchema.parse( + executionContext + ? await uploadExecutionFile( + executionContext, + downloaded.buffer, + downloaded.fileName, + downloaded.mimeType, + context.userId + ) + : await uploadCopilotFile({ + buffer: downloaded.buffer, + fileName: downloaded.fileName, + contentType: downloaded.mimeType, + userId: context.userId, + }) + ) + + const shared = { + file: storedFile, + fileName: downloaded.fileName, + mimeType: downloaded.mimeType, + size: downloaded.buffer.length, + } + return body.documentKind === 'attachment' + ? { ...shared, attachmentId: body.attachmentId } + : { + ...shared, + transactionType: body.transactionType, + transactionId: body.transactionId, + } +} diff --git a/apps/sim/lib/internal/quickbooks/provider-operations.ts b/apps/sim/lib/internal/quickbooks/provider-operations.ts new file mode 100644 index 00000000000..c2060fac1ee --- /dev/null +++ b/apps/sim/lib/internal/quickbooks/provider-operations.ts @@ -0,0 +1,407 @@ +import { filterUndefined } from '@sim/utils/object' +import { + buildQuickBooksCreateBillPaymentBody, + buildQuickBooksUpdateBillBody, + buildQuickBooksUpdateBillPaymentBody, + buildQuickBooksUpdatePurchaseBody, + buildQuickBooksUpdatePurchaseOrderBody, + buildQuickBooksUpdateVendorCreditBody, +} from '@/tools/quickbooks/purchasing_utils' +import { + buildQuickBooksUpdatePaymentBody, + buildQuickBooksUpdateSalesDocumentBody, + parseQuickBooksInvoiceAllocations, +} from '@/tools/quickbooks/sales_utils' +import type { + QuickBooksAccount, + QuickBooksCreateBillPaymentParams, + QuickBooksItem, + QuickBooksPurchasingTransaction, + QuickBooksSalesTransaction, + QuickBooksUpdateBillParams, + QuickBooksUpdateBillPaymentParams, + QuickBooksUpdateCustomerPaymentParams, + QuickBooksUpdateEmployeeParams, + QuickBooksUpdateItemParams, + QuickBooksUpdatePurchaseOrderParams, + QuickBooksUpdatePurchaseParams, + QuickBooksUpdateRefundReceiptParams, + QuickBooksUpdateSalesDocumentParams, + QuickBooksUpdateVendorCreditParams, + QuickBooksUpdateVendorParams, + QuickBooksVendor, +} from '@/tools/quickbooks/types' +import { + addQuickBooksRequestId, + buildQuickBooksEntityUrl, + buildQuickBooksFullUpdateBody, + executeQuickBooksFullUpdate, + getQuickBooksOperationError, + getQuickBooksToolHeaders, + sanitizeQuickBooksEmployee, + sanitizeQuickBooksVendor, + transformQuickBooksEntityResponse, + transformQuickBooksMutationResponse, +} from '@/tools/quickbooks/utils' +import { + assertQuickBooksSparseUpdate, + optionalQuickBooksString, + parseQuickBooksAddress, + quickBooksActiveValue, + quickBooksEmailAddress, + quickBooksPhoneNumber, + quickBooksReference, + requiredQuickBooksString, + validateQuickBooksOptionalNumber, +} from '@/tools/quickbooks/values' + +function assertCompatiblePaymentAccount( + account: QuickBooksAccount, + paymentType: QuickBooksCreateBillPaymentParams['paymentType'], + paymentAccountId: string +): void { + const accountId = account.Id.trim() + if (accountId !== paymentAccountId) { + throw new Error('QuickBooks returned a different payment account than requested') + } + if (account.Active === false) { + throw new Error('QuickBooks payment account is inactive. Select an active account.') + } + + const expectedAccountType = paymentType === 'check' ? 'Bank' : 'Credit Card' + if (account.AccountType !== expectedAccountType) { + throw new Error( + `${paymentType === 'check' ? 'Check' : 'Credit-card'} Bill Payments require a QuickBooks ${expectedAccountType} account. Account ${paymentAccountId} is ${account.AccountType || 'missing an account type'}.` + ) + } +} + +function buildQuickBooksUpdateVendorBody( + params: QuickBooksUpdateVendorParams +): Record { + const body = filterUndefined({ + Id: requiredQuickBooksString(params.vendorId, 'vendorId'), + SyncToken: requiredQuickBooksString(params.syncToken, 'syncToken'), + sparse: true, + DisplayName: optionalQuickBooksString(params.displayName), + CompanyName: optionalQuickBooksString(params.companyName), + GivenName: optionalQuickBooksString(params.givenName), + FamilyName: optionalQuickBooksString(params.familyName), + PrimaryEmailAddr: quickBooksEmailAddress(params.primaryEmail), + PrimaryPhone: quickBooksPhoneNumber(params.primaryPhone), + BillAddr: parseQuickBooksAddress(params.billingAddress, 'billingAddress'), + PrintOnCheckName: optionalQuickBooksString(params.printOnCheckName), + AcctNum: optionalQuickBooksString(params.accountNumber), + Vendor1099: params.vendor1099, + Active: quickBooksActiveValue(params.activeStatus), + }) as Record + assertQuickBooksSparseUpdate(body) + return body +} + +function buildQuickBooksUpdateEmployeeBody( + params: QuickBooksUpdateEmployeeParams +): Record { + const body = filterUndefined({ + Id: requiredQuickBooksString(params.employeeId, 'employeeId'), + SyncToken: requiredQuickBooksString(params.syncToken, 'syncToken'), + sparse: true, + DisplayName: optionalQuickBooksString(params.displayName), + GivenName: optionalQuickBooksString(params.givenName), + FamilyName: optionalQuickBooksString(params.familyName), + PrimaryEmailAddr: quickBooksEmailAddress(params.primaryEmail), + PrimaryPhone: quickBooksPhoneNumber(params.primaryPhone), + PrimaryAddr: parseQuickBooksAddress(params.primaryAddress, 'primaryAddress'), + PrintOnCheckName: optionalQuickBooksString(params.printOnCheckName), + BillableTime: params.billableTime, + Active: quickBooksActiveValue(params.activeStatus), + }) as Record + assertQuickBooksSparseUpdate(body) + return body +} + +function buildQuickBooksUpdateItemBody( + params: QuickBooksUpdateItemParams +): Record { + const body = filterUndefined({ + Id: requiredQuickBooksString(params.itemId, 'itemId'), + SyncToken: requiredQuickBooksString(params.syncToken, 'syncToken'), + sparse: true, + Name: optionalQuickBooksString(params.name), + IncomeAccountRef: params.incomeAccountId + ? quickBooksReference(params.incomeAccountId, 'incomeAccountId') + : undefined, + Description: optionalQuickBooksString(params.description), + UnitPrice: validateQuickBooksOptionalNumber(params.unitPrice, 'unitPrice'), + PurchaseDesc: optionalQuickBooksString(params.purchaseDescription), + PurchaseCost: validateQuickBooksOptionalNumber(params.purchaseCost, 'purchaseCost'), + ExpenseAccountRef: params.expenseAccountId + ? quickBooksReference(params.expenseAccountId, 'expenseAccountId') + : undefined, + Taxable: params.taxable, + Active: quickBooksActiveValue(params.activeStatus), + }) as Record + assertQuickBooksSparseUpdate(body) + return body +} + +export async function executeQuickBooksCreateBillPaymentOperation( + params: QuickBooksCreateBillPaymentParams, + signal?: AbortSignal +) { + const body = buildQuickBooksCreateBillPaymentBody(params) + const paymentAccountId = params.paymentAccountId.trim() + if (!paymentAccountId) throw new Error('paymentAccountId is required') + + const accountResponse = await fetch( + buildQuickBooksEntityUrl(params, 'account', paymentAccountId), + { + method: 'GET', + headers: getQuickBooksToolHeaders(params.accessToken), + signal, + } + ) + if (!accountResponse.ok) { + throw await getQuickBooksOperationError(accountResponse, 'BillPayment', signal) + } + const { item: account } = await transformQuickBooksEntityResponse( + accountResponse, + 'Account', + signal + ) + assertCompatiblePaymentAccount(account, params.paymentType, paymentAccountId) + signal?.throwIfAborted() + + const paymentResponse = await fetch( + addQuickBooksRequestId(buildQuickBooksEntityUrl(params, 'billpayment'), params.requestId), + { + method: 'POST', + headers: getQuickBooksToolHeaders(params.accessToken, 'application/json'), + body: JSON.stringify(body), + signal, + } + ) + if (!paymentResponse.ok) { + throw await getQuickBooksOperationError(paymentResponse, 'BillPayment', signal) + } + return transformQuickBooksMutationResponse( + paymentResponse, + 'BillPayment', + undefined, + signal + ) +} + +export function executeQuickBooksUpdateBillOperation( + params: QuickBooksUpdateBillParams, + signal?: AbortSignal +) { + return executeQuickBooksFullUpdate({ + params, + signal, + entity: 'Bill', + resource: 'bill', + recordId: params.billId, + syncToken: params.syncToken, + buildPatch: buildQuickBooksUpdateBillBody, + }) +} + +export function executeQuickBooksUpdateBillPaymentOperation( + params: QuickBooksUpdateBillPaymentParams, + signal?: AbortSignal +) { + return executeQuickBooksFullUpdate({ + params, + signal, + entity: 'BillPayment', + resource: 'billpayment', + recordId: params.billPaymentId, + syncToken: params.syncToken, + buildPatch: buildQuickBooksUpdateBillPaymentBody, + }) +} + +export function executeQuickBooksUpdateCreditMemoOperation( + params: QuickBooksUpdateSalesDocumentParams, + signal?: AbortSignal +) { + return executeQuickBooksFullUpdate({ + params, + signal, + entity: 'CreditMemo', + resource: 'creditmemo', + recordId: params.transactionId, + syncToken: params.syncToken, + buildPatch: buildQuickBooksUpdateSalesDocumentBody, + }) +} + +export function executeQuickBooksUpdateRefundReceiptOperation( + params: QuickBooksUpdateRefundReceiptParams, + signal?: AbortSignal +) { + return executeQuickBooksFullUpdate({ + params, + signal, + entity: 'RefundReceipt', + resource: 'refundreceipt', + recordId: params.transactionId, + syncToken: params.syncToken, + buildPatch: buildQuickBooksUpdateSalesDocumentBody, + }) +} + +/** Preserves QuickBooks' all-or-none Payment lines across a full update. */ +export async function executeQuickBooksUpdateCustomerPaymentOperation( + params: QuickBooksUpdateCustomerPaymentParams, + signal?: AbortSignal +) { + const paymentId = params.paymentId?.trim() + if (!paymentId) throw new Error('paymentId is required') + parseQuickBooksInvoiceAllocations(params.invoiceAllocations) + + const syncToken = params.syncToken?.trim() + if (!syncToken) throw new Error('syncToken is required') + const readResponse = await fetch(buildQuickBooksEntityUrl(params, 'payment', paymentId), { + method: 'GET', + headers: getQuickBooksToolHeaders(params.accessToken), + signal, + }) + if (!readResponse.ok) { + throw await getQuickBooksOperationError(readResponse, 'Payment', signal) + } + const { item: currentPayment } = + await transformQuickBooksEntityResponse( + readResponse, + 'Payment', + signal + ) + const currentId = typeof currentPayment.Id === 'string' ? currentPayment.Id.trim() : '' + const currentSyncToken = + typeof currentPayment.SyncToken === 'string' ? currentPayment.SyncToken.trim() : '' + if (currentId !== paymentId) { + throw new Error('QuickBooks Payment read returned an unexpected record ID') + } + if (currentSyncToken !== syncToken) { + throw new Error( + `QuickBooks payment ${paymentId} changed since sync token ${syncToken} was read (current sync token ${currentSyncToken}). Re-read the payment and retry.` + ) + } + signal?.throwIfAborted() + + const patch = buildQuickBooksUpdatePaymentBody(params, currentPayment) + const fullBody = buildQuickBooksFullUpdateBody( + currentPayment as QuickBooksSalesTransaction & Record, + patch, + paymentId, + syncToken + ) + const updateResponse = await fetch(buildQuickBooksEntityUrl(params, 'payment'), { + method: 'POST', + headers: getQuickBooksToolHeaders(params.accessToken, 'application/json'), + body: JSON.stringify(fullBody), + signal, + }) + if (!updateResponse.ok) { + throw await getQuickBooksOperationError(updateResponse, 'Payment', signal) + } + return transformQuickBooksMutationResponse( + updateResponse, + 'Payment', + undefined, + signal + ) +} + +export function executeQuickBooksUpdateEmployeeOperation( + params: QuickBooksUpdateEmployeeParams, + signal?: AbortSignal +) { + return executeQuickBooksFullUpdate({ + params, + signal, + entity: 'Employee', + resource: 'employee', + recordId: params.employeeId, + syncToken: params.syncToken, + buildPatch: buildQuickBooksUpdateEmployeeBody, + sanitize: sanitizeQuickBooksEmployee, + }) +} + +export function executeQuickBooksUpdateItemOperation( + params: QuickBooksUpdateItemParams, + signal?: AbortSignal +) { + return executeQuickBooksFullUpdate({ + params, + signal, + entity: 'Item', + resource: 'item', + recordId: params.itemId, + syncToken: params.syncToken, + buildPatch: buildQuickBooksUpdateItemBody, + }) +} + +export function executeQuickBooksUpdatePurchaseOperation( + params: QuickBooksUpdatePurchaseParams, + signal?: AbortSignal +) { + return executeQuickBooksFullUpdate({ + params, + signal, + entity: 'Purchase', + resource: 'purchase', + recordId: params.purchaseId, + syncToken: params.syncToken, + buildPatch: buildQuickBooksUpdatePurchaseBody, + }) +} + +export function executeQuickBooksUpdatePurchaseOrderOperation( + params: QuickBooksUpdatePurchaseOrderParams, + signal?: AbortSignal +) { + return executeQuickBooksFullUpdate({ + params, + signal, + entity: 'PurchaseOrder', + resource: 'purchaseorder', + recordId: params.purchaseOrderId, + syncToken: params.syncToken, + buildPatch: buildQuickBooksUpdatePurchaseOrderBody, + }) +} + +export function executeQuickBooksUpdateVendorOperation( + params: QuickBooksUpdateVendorParams, + signal?: AbortSignal +) { + return executeQuickBooksFullUpdate({ + params, + signal, + entity: 'Vendor', + resource: 'vendor', + recordId: params.vendorId, + syncToken: params.syncToken, + buildPatch: buildQuickBooksUpdateVendorBody, + sanitize: sanitizeQuickBooksVendor, + }) +} + +export function executeQuickBooksUpdateVendorCreditOperation( + params: QuickBooksUpdateVendorCreditParams, + signal?: AbortSignal +) { + return executeQuickBooksFullUpdate({ + params, + signal, + entity: 'VendorCredit', + resource: 'vendorcredit', + recordId: params.vendorCreditId, + syncToken: params.syncToken, + buildPatch: buildQuickBooksUpdateVendorCreditBody, + }) +} diff --git a/apps/sim/lib/internal/sqs/client.ts b/apps/sim/lib/internal/sqs/client.ts index ec0f3875cf0..9c762c0439d 100644 --- a/apps/sim/lib/internal/sqs/client.ts +++ b/apps/sim/lib/internal/sqs/client.ts @@ -1,4 +1,4 @@ -import { SendMessageCommand, type SendMessageCommandOutput, SQSClient } from '@aws-sdk/client-sqs' +import { SQSClient } from '@aws-sdk/client-sqs' import type { SqsConnectionConfig } from '@/tools/sqs/types' export function createSqsClient(config: SqsConnectionConfig): SQSClient { @@ -10,32 +10,3 @@ export function createSqsClient(config: SqsConnectionConfig): SQSClient { }, }) } - -export async function sendMessage( - client: SQSClient, - queueUrl: string, - data: Record, - messageGroupId?: string | null, - messageDeduplicationId?: string | null, - signal?: AbortSignal -): Promise | null> { - const command = new SendMessageCommand({ - QueueUrl: queueUrl, - MessageBody: JSON.stringify(data), - MessageGroupId: messageGroupId ?? undefined, - ...(messageDeduplicationId ? { MessageDeduplicationId: messageDeduplicationId } : {}), - }) - - const response = await client.send(command, { abortSignal: signal }) - return parseSendMessageResponse(response) -} - -function parseSendMessageResponse( - response: SendMessageCommandOutput -): Record | null { - if (!response) { - return null - } - - return { id: response.MessageId } -} diff --git a/apps/sim/lib/internal/sqs/execute-tool.test.ts b/apps/sim/lib/internal/sqs/execute-tool.test.ts index 86c798b1206..7e1921d2ffe 100644 --- a/apps/sim/lib/internal/sqs/execute-tool.test.ts +++ b/apps/sim/lib/internal/sqs/execute-tool.test.ts @@ -3,22 +3,51 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockExecuteSqsSend } = vi.hoisted(() => ({ - mockExecuteSqsSend: vi.fn(), -})) +const { mockExecuteSqsSend, mockExecuteSqsReceiveMessage, mockOperations } = vi.hoisted(() => { + const names = [ + 'executeSqsCancelMessageMoveTask', + 'executeSqsChangeMessageVisibility', + 'executeSqsChangeMessageVisibilityBatch', + 'executeSqsCreateQueue', + 'executeSqsDeleteMessage', + 'executeSqsDeleteMessageBatch', + 'executeSqsDeleteQueue', + 'executeSqsGetQueueAttributes', + 'executeSqsGetQueueUrl', + 'executeSqsListDeadLetterSourceQueues', + 'executeSqsListMessageMoveTasks', + 'executeSqsListQueues', + 'executeSqsListQueueTags', + 'executeSqsPurgeQueue', + 'executeSqsReceiveMessage', + 'executeSqsSend', + 'executeSqsSendMessageBatch', + 'executeSqsSetQueueAttributes', + 'executeSqsStartMessageMoveTask', + 'executeSqsTagQueue', + 'executeSqsUntagQueue', + ] + const operations: Record> = {} + for (const name of names) operations[name] = vi.fn() + return { + mockOperations: operations, + mockExecuteSqsSend: operations.executeSqsSend, + mockExecuteSqsReceiveMessage: operations.executeSqsReceiveMessage, + } +}) -vi.mock('@/lib/internal/sqs/operations', () => ({ - executeSqsSend: mockExecuteSqsSend, -})) +vi.mock('@/lib/internal/sqs/operations', () => mockOperations) import { executeSqsTool } from '@/lib/internal/sqs/execute-tool' import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' +const QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/test-queue' + const BODY = { region: 'us-east-1', accessKeyId: 'access-key', secretAccessKey: 'secret-key', - queueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/test-queue', + queueUrl: QUEUE_URL, data: { action: 'process' }, messageGroupId: 'group-1', messageDeduplicationId: 'message-1', @@ -35,7 +64,6 @@ function createRequest( workflowId: 'workflow-1', workspaceId: 'workspace-1', userId: 'user-1', - metadata: {}, }, requestId: 'request-1', ...overrides, @@ -49,7 +77,7 @@ describe('executeSqsTool', () => { it('validates and executes the SQS send operation', async () => { const controller = new AbortController() - const result = { message: `Message sent to SQS queue ${BODY.queueUrl}`, id: 'message-id' } + const result = { message: `Message sent to SQS queue ${QUEUE_URL}`, id: 'message-id' } mockExecuteSqsSend.mockResolvedValue(result) const response = await executeSqsTool(createRequest({ signal: controller.signal })) @@ -59,6 +87,27 @@ describe('executeSqsTool', () => { expect(mockExecuteSqsSend).toHaveBeenCalledWith(BODY, controller.signal) }) + it('dispatches each tool id to its own operation', async () => { + mockExecuteSqsReceiveMessage.mockResolvedValue({ messages: [], count: 0 }) + + const response = await executeSqsTool( + createRequest({ + toolId: 'sqs_receive_message', + input: { + region: 'us-east-1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', + queueUrl: QUEUE_URL, + waitTimeSeconds: 20, + }, + }) + ) + + expect(response.status).toBe(200) + expect(mockExecuteSqsReceiveMessage).toHaveBeenCalledOnce() + expect(mockExecuteSqsSend).not.toHaveBeenCalled() + }) + it('returns the route-compatible validation envelope before provider work', async () => { const response = await executeSqsTool(createRequest({ input: { ...BODY, data: {} } })) @@ -70,6 +119,92 @@ describe('executeSqsTool', () => { expect(mockExecuteSqsSend).not.toHaveBeenCalled() }) + it('rejects an out-of-range receive batch size before provider work', async () => { + const response = await executeSqsTool( + createRequest({ + toolId: 'sqs_receive_message', + input: { + region: 'us-east-1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', + queueUrl: QUEUE_URL, + maxNumberOfMessages: 25, + }, + }) + ) + + expect(response.status).toBe(400) + expect(mockExecuteSqsReceiveMessage).not.toHaveBeenCalled() + }) + + it('rejects a Binary message attribute, which has no JSON-safe value form', async () => { + const response = await executeSqsTool( + createRequest({ + input: { + ...BODY, + messageAttributes: { thumbnail: { dataType: 'Binary', stringValue: 'AAAA' } }, + }, + }) + ) + + expect(response.status).toBe(400) + expect(mockExecuteSqsSend).not.toHaveBeenCalled() + }) + + it('accepts a custom-labelled Number message attribute', async () => { + mockExecuteSqsSend.mockResolvedValue({ message: 'sent', id: 'message-id' }) + + const response = await executeSqsTool( + createRequest({ + input: { + ...BODY, + messageAttributes: { ratio: { dataType: 'Number.float', stringValue: '1.5' } }, + }, + }) + ) + + expect(response.status).toBe(200) + expect(mockExecuteSqsSend).toHaveBeenCalledOnce() + }) + + it('rejects the read-only All pseudo-name on a queue attribute write', async () => { + const response = await executeSqsTool( + createRequest({ + toolId: 'sqs_set_queue_attributes', + input: { + region: 'us-east-1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', + queueUrl: QUEUE_URL, + attributes: { All: 'true' }, + }, + }) + ) + + expect(response.status).toBe(400) + expect(mockOperations.executeSqsSetQueueAttributes).not.toHaveBeenCalled() + }) + + it('accepts a real settable queue attribute', async () => { + mockOperations.executeSqsSetQueueAttributes.mockResolvedValue({ message: 'updated' }) + + const response = await executeSqsTool( + createRequest({ + toolId: 'sqs_set_queue_attributes', + input: { + region: 'us-east-1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', + queueUrl: QUEUE_URL, + attributes: { VisibilityTimeout: '60' }, + }, + }) + ) + + expect(response.status).toBe(200) + expect(mockOperations.executeSqsSetQueueAttributes).toHaveBeenCalledOnce() + }) + it('preserves the provider error envelope', async () => { mockExecuteSqsSend.mockRejectedValue(new Error('AWS rejected credentials')) @@ -81,6 +216,15 @@ describe('executeSqsTool', () => { }) }) + it('rejects an unsupported SQS tool id', async () => { + const response = await executeSqsTool(createRequest({ toolId: 'sqs_not_a_tool' })) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: 'Unsupported SQS tool: sqs_not_a_tool', + }) + }) + it('propagates cancellation without starting provider work', async () => { const controller = new AbortController() controller.abort(new DOMException('cancelled', 'AbortError')) diff --git a/apps/sim/lib/internal/sqs/execute-tool.ts b/apps/sim/lib/internal/sqs/execute-tool.ts index ddddcb6d6d9..c4d8d4dba4e 100644 --- a/apps/sim/lib/internal/sqs/execute-tool.ts +++ b/apps/sim/lib/internal/sqs/execute-tool.ts @@ -1,30 +1,249 @@ import { getErrorMessage } from '@sim/utils/errors' -import { executeSqsSend } from '@/lib/internal/sqs/operations' -import { sqsSendInputSchema } from '@/lib/internal/sqs/schema' +import type { AnyApiRouteContract, ContractBody } from '@/lib/api/contracts' +import { awsSqsCancelMessageMoveTaskContract } from '@/lib/api/contracts/tools/aws/sqs-cancel-message-move-task' +import { awsSqsChangeMessageVisibilityContract } from '@/lib/api/contracts/tools/aws/sqs-change-message-visibility' +import { awsSqsChangeMessageVisibilityBatchContract } from '@/lib/api/contracts/tools/aws/sqs-change-message-visibility-batch' +import { awsSqsCreateQueueContract } from '@/lib/api/contracts/tools/aws/sqs-create-queue' +import { awsSqsDeleteMessageContract } from '@/lib/api/contracts/tools/aws/sqs-delete-message' +import { awsSqsDeleteMessageBatchContract } from '@/lib/api/contracts/tools/aws/sqs-delete-message-batch' +import { awsSqsDeleteQueueContract } from '@/lib/api/contracts/tools/aws/sqs-delete-queue' +import { awsSqsGetQueueAttributesContract } from '@/lib/api/contracts/tools/aws/sqs-get-queue-attributes' +import { awsSqsGetQueueUrlContract } from '@/lib/api/contracts/tools/aws/sqs-get-queue-url' +import { awsSqsListDeadLetterSourceQueuesContract } from '@/lib/api/contracts/tools/aws/sqs-list-dead-letter-source-queues' +import { awsSqsListMessageMoveTasksContract } from '@/lib/api/contracts/tools/aws/sqs-list-message-move-tasks' +import { awsSqsListQueueTagsContract } from '@/lib/api/contracts/tools/aws/sqs-list-queue-tags' +import { awsSqsListQueuesContract } from '@/lib/api/contracts/tools/aws/sqs-list-queues' +import { awsSqsPurgeQueueContract } from '@/lib/api/contracts/tools/aws/sqs-purge-queue' +import { awsSqsReceiveMessageContract } from '@/lib/api/contracts/tools/aws/sqs-receive-message' +import { awsSqsSendMessageContract } from '@/lib/api/contracts/tools/aws/sqs-send-message' +import { awsSqsSendMessageBatchContract } from '@/lib/api/contracts/tools/aws/sqs-send-message-batch' +import { awsSqsSetQueueAttributesContract } from '@/lib/api/contracts/tools/aws/sqs-set-queue-attributes' +import { awsSqsStartMessageMoveTaskContract } from '@/lib/api/contracts/tools/aws/sqs-start-message-move-task' +import { awsSqsTagQueueContract } from '@/lib/api/contracts/tools/aws/sqs-tag-queue' +import { awsSqsUntagQueueContract } from '@/lib/api/contracts/tools/aws/sqs-untag-queue' +import { + executeSqsCancelMessageMoveTask, + executeSqsChangeMessageVisibility, + executeSqsChangeMessageVisibilityBatch, + executeSqsCreateQueue, + executeSqsDeleteMessage, + executeSqsDeleteMessageBatch, + executeSqsDeleteQueue, + executeSqsGetQueueAttributes, + executeSqsGetQueueUrl, + executeSqsListDeadLetterSourceQueues, + executeSqsListMessageMoveTasks, + executeSqsListQueues, + executeSqsListQueueTags, + executeSqsPurgeQueue, + executeSqsReceiveMessage, + executeSqsSend, + executeSqsSendMessageBatch, + executeSqsSetQueueAttributes, + executeSqsStartMessageMoveTask, + executeSqsTagQueue, + executeSqsUntagQueue, +} from '@/lib/internal/sqs/operations' +import { parseInternalToolInput } from '@/lib/internal/tool-operations/parse-input' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' -export const executeSqsTool: InternalToolOperationHandler = async ({ toolId, input, signal }) => { +async function executeOperation( + contract: C, + input: unknown, + execute: (input: ContractBody, signal?: AbortSignal) => Promise, + errorMessage: string, + signal?: AbortSignal +): Promise { signal?.throwIfAborted() - - if (toolId !== 'sqs_send') { - return Response.json({ error: `Unsupported SQS tool: ${toolId}` }, { status: 500 }) - } - - const parsed = sqsSendInputSchema.safeParse(input) - if (!parsed.success) { - return Response.json( - { error: 'Invalid request data', details: parsed.error.issues }, - { status: 400 } - ) - } + const parsed = parseInternalToolInput(contract, input) + if (!parsed.success) return parsed.response try { - return Response.json(await executeSqsSend(parsed.data, signal)) + const result = await execute(parsed.data, signal) + signal?.throwIfAborted() + return Response.json(result) } catch (error) { signal?.throwIfAborted() return Response.json( - { error: `SQS send message failed: ${getErrorMessage(error, 'Unknown error occurred')}` }, + { error: `${errorMessage}: ${getErrorMessage(error, 'Unknown error occurred')}` }, { status: 500 } ) } } + +export const executeSqsTool: InternalToolOperationHandler = async ({ toolId, input, signal }) => { + signal?.throwIfAborted() + + switch (toolId) { + case 'sqs_send': + return executeOperation( + awsSqsSendMessageContract, + input, + executeSqsSend, + 'SQS send message failed', + signal + ) + case 'sqs_send_message_batch': + return executeOperation( + awsSqsSendMessageBatchContract, + input, + executeSqsSendMessageBatch, + 'Failed to send SQS message batch', + signal + ) + case 'sqs_receive_message': + return executeOperation( + awsSqsReceiveMessageContract, + input, + executeSqsReceiveMessage, + 'Failed to receive SQS messages', + signal + ) + case 'sqs_delete_message': + return executeOperation( + awsSqsDeleteMessageContract, + input, + executeSqsDeleteMessage, + 'Failed to delete SQS message', + signal + ) + case 'sqs_delete_message_batch': + return executeOperation( + awsSqsDeleteMessageBatchContract, + input, + executeSqsDeleteMessageBatch, + 'Failed to delete SQS message batch', + signal + ) + case 'sqs_change_message_visibility': + return executeOperation( + awsSqsChangeMessageVisibilityContract, + input, + executeSqsChangeMessageVisibility, + 'Failed to change SQS message visibility', + signal + ) + case 'sqs_change_message_visibility_batch': + return executeOperation( + awsSqsChangeMessageVisibilityBatchContract, + input, + executeSqsChangeMessageVisibilityBatch, + 'Failed to change SQS message visibility batch', + signal + ) + case 'sqs_list_queues': + return executeOperation( + awsSqsListQueuesContract, + input, + executeSqsListQueues, + 'Failed to list SQS queues', + signal + ) + case 'sqs_get_queue_url': + return executeOperation( + awsSqsGetQueueUrlContract, + input, + executeSqsGetQueueUrl, + 'Failed to get SQS queue URL', + signal + ) + case 'sqs_get_queue_attributes': + return executeOperation( + awsSqsGetQueueAttributesContract, + input, + executeSqsGetQueueAttributes, + 'Failed to get SQS queue attributes', + signal + ) + case 'sqs_set_queue_attributes': + return executeOperation( + awsSqsSetQueueAttributesContract, + input, + executeSqsSetQueueAttributes, + 'Failed to set SQS queue attributes', + signal + ) + case 'sqs_create_queue': + return executeOperation( + awsSqsCreateQueueContract, + input, + executeSqsCreateQueue, + 'Failed to create SQS queue', + signal + ) + case 'sqs_delete_queue': + return executeOperation( + awsSqsDeleteQueueContract, + input, + executeSqsDeleteQueue, + 'Failed to delete SQS queue', + signal + ) + case 'sqs_purge_queue': + return executeOperation( + awsSqsPurgeQueueContract, + input, + executeSqsPurgeQueue, + 'Failed to purge SQS queue', + signal + ) + case 'sqs_list_dead_letter_source_queues': + return executeOperation( + awsSqsListDeadLetterSourceQueuesContract, + input, + executeSqsListDeadLetterSourceQueues, + 'Failed to list SQS dead-letter source queues', + signal + ) + case 'sqs_list_queue_tags': + return executeOperation( + awsSqsListQueueTagsContract, + input, + executeSqsListQueueTags, + 'Failed to list SQS queue tags', + signal + ) + case 'sqs_tag_queue': + return executeOperation( + awsSqsTagQueueContract, + input, + executeSqsTagQueue, + 'Failed to tag SQS queue', + signal + ) + case 'sqs_untag_queue': + return executeOperation( + awsSqsUntagQueueContract, + input, + executeSqsUntagQueue, + 'Failed to untag SQS queue', + signal + ) + case 'sqs_start_message_move_task': + return executeOperation( + awsSqsStartMessageMoveTaskContract, + input, + executeSqsStartMessageMoveTask, + 'Failed to start SQS message move task', + signal + ) + case 'sqs_list_message_move_tasks': + return executeOperation( + awsSqsListMessageMoveTasksContract, + input, + executeSqsListMessageMoveTasks, + 'Failed to list SQS message move tasks', + signal + ) + case 'sqs_cancel_message_move_task': + return executeOperation( + awsSqsCancelMessageMoveTaskContract, + input, + executeSqsCancelMessageMoveTask, + 'Failed to cancel SQS message move task', + signal + ) + default: + return Response.json({ error: `Unsupported SQS tool: ${toolId}` }, { status: 500 }) + } +} diff --git a/apps/sim/lib/internal/sqs/operations.test.ts b/apps/sim/lib/internal/sqs/operations.test.ts index f1447e6bd4c..a5ec1947173 100644 --- a/apps/sim/lib/internal/sqs/operations.test.ts +++ b/apps/sim/lib/internal/sqs/operations.test.ts @@ -3,58 +3,153 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCreateSqsClient, mockDestroy, mockSendMessage } = vi.hoisted(() => ({ +const { mockCreateSqsClient, mockDestroy, mockSend } = vi.hoisted(() => ({ mockCreateSqsClient: vi.fn(), mockDestroy: vi.fn(), - mockSendMessage: vi.fn(), + mockSend: vi.fn(), })) vi.mock('@/lib/internal/sqs/client', () => ({ createSqsClient: mockCreateSqsClient, - sendMessage: mockSendMessage, })) -import { executeSqsSend } from '@/lib/internal/sqs/operations' +import { + executeSqsDeleteMessageBatch, + executeSqsListDeadLetterSourceQueues, + executeSqsReceiveMessage, + executeSqsSend, +} from '@/lib/internal/sqs/operations' -const INPUT = { +const CONNECTION = { region: 'us-east-1', accessKeyId: 'access-key', secretAccessKey: 'secret-key', - queueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/test-queue', - data: { action: 'process' }, - messageGroupId: 'group-1', - messageDeduplicationId: 'message-1', } +const QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/test-queue' + describe('SQS operations', () => { beforeEach(() => { vi.clearAllMocks() - mockCreateSqsClient.mockReturnValue({ destroy: mockDestroy }) + mockCreateSqsClient.mockReturnValue({ send: mockSend, destroy: mockDestroy }) }) - it('forwards cancellation and destroys the AWS client after success', async () => { + it('sends a message, forwards cancellation, and destroys the AWS client', async () => { const controller = new AbortController() - mockSendMessage.mockResolvedValue({ id: 'message-id' }) + mockSend.mockResolvedValue({ MessageId: 'message-id', MD5OfMessageBody: 'digest' }) - await expect(executeSqsSend(INPUT, controller.signal)).resolves.toEqual({ - message: `Message sent to SQS queue ${INPUT.queueUrl}`, + await expect( + executeSqsSend( + { ...CONNECTION, queueUrl: QUEUE_URL, data: { action: 'process' } }, + controller.signal + ) + ).resolves.toEqual({ + message: `Message sent to SQS queue ${QUEUE_URL}`, id: 'message-id', + md5OfMessageBody: 'digest', + md5OfMessageAttributes: null, + sequenceNumber: null, + }) + + const [command, options] = mockSend.mock.calls[0] + expect(command.input).toMatchObject({ + QueueUrl: QUEUE_URL, + MessageBody: JSON.stringify({ action: 'process' }), }) - expect(mockSendMessage).toHaveBeenCalledWith( - { destroy: mockDestroy }, - INPUT.queueUrl, - INPUT.data, - INPUT.messageGroupId, - INPUT.messageDeduplicationId, - controller.signal - ) + expect(options).toEqual({ abortSignal: controller.signal }) expect(mockDestroy).toHaveBeenCalledOnce() }) + it('maps message attributes onto the SQS wire shape', async () => { + mockSend.mockResolvedValue({ MessageId: 'message-id' }) + + await executeSqsSend({ + ...CONNECTION, + queueUrl: QUEUE_URL, + data: { action: 'process' }, + delaySeconds: 30, + messageAttributes: { priority: { dataType: 'Number', stringValue: '1' } }, + }) + + expect(mockSend.mock.calls[0][0].input).toMatchObject({ + DelaySeconds: 30, + MessageAttributes: { priority: { DataType: 'Number', StringValue: '1' } }, + }) + }) + + it('projects received messages and their attributes', async () => { + mockSend.mockResolvedValue({ + Messages: [ + { + MessageId: 'message-id', + ReceiptHandle: 'receipt-handle', + Body: '{"action":"process"}', + MD5OfBody: 'digest', + Attributes: { SenderId: 'sender', Unset: undefined }, + MessageAttributes: { + priority: { DataType: 'Number', StringValue: '1' }, + }, + }, + ], + }) + + await expect( + executeSqsReceiveMessage({ ...CONNECTION, queueUrl: QUEUE_URL, waitTimeSeconds: 20 }) + ).resolves.toEqual({ + count: 1, + messages: [ + { + messageId: 'message-id', + receiptHandle: 'receipt-handle', + body: '{"action":"process"}', + md5OfBody: 'digest', + md5OfMessageAttributes: null, + attributes: { SenderId: 'sender' }, + messageAttributes: { + priority: { dataType: 'Number', stringValue: '1', stringListValues: [] }, + }, + }, + ], + }) + }) + + it('reports partial batch failures rather than throwing', async () => { + mockSend.mockResolvedValue({ + Successful: [{ Id: 'msg-1' }], + Failed: [{ Id: 'msg-2', SenderFault: true, Code: 'ReceiptHandleIsInvalid' }], + }) + + await expect( + executeSqsDeleteMessageBatch({ + ...CONNECTION, + queueUrl: QUEUE_URL, + entries: [ + { id: 'msg-1', receiptHandle: 'handle-1' }, + { id: 'msg-2', receiptHandle: 'handle-2' }, + ], + }) + ).resolves.toMatchObject({ + successful: [{ id: 'msg-1' }], + failed: [{ id: 'msg-2', senderFault: true, code: 'ReceiptHandleIsInvalid', message: null }], + successCount: 1, + failureCount: 1, + }) + }) + + it('reads the lowercase queueUrls field ListDeadLetterSourceQueues returns', async () => { + mockSend.mockResolvedValue({ queueUrls: [QUEUE_URL], NextToken: 'next' }) + + await expect( + executeSqsListDeadLetterSourceQueues({ ...CONNECTION, queueUrl: QUEUE_URL }) + ).resolves.toEqual({ queueUrls: [QUEUE_URL], nextToken: 'next', count: 1 }) + }) + it('destroys the AWS client when provider execution fails', async () => { - mockSendMessage.mockRejectedValue(new Error('provider failure')) + mockSend.mockRejectedValue(new Error('provider failure')) - await expect(executeSqsSend(INPUT)).rejects.toThrow('provider failure') + await expect( + executeSqsSend({ ...CONNECTION, queueUrl: QUEUE_URL, data: { action: 'process' } }) + ).rejects.toThrow('provider failure') expect(mockDestroy).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/lib/internal/sqs/operations.ts b/apps/sim/lib/internal/sqs/operations.ts index 2ad44d2e41f..f445711eb8a 100644 --- a/apps/sim/lib/internal/sqs/operations.ts +++ b/apps/sim/lib/internal/sqs/operations.ts @@ -1,24 +1,495 @@ -import { createSqsClient, sendMessage } from '@/lib/internal/sqs/client' -import type { SqsSendInput } from '@/lib/internal/sqs/schema' +import { + CancelMessageMoveTaskCommand, + ChangeMessageVisibilityBatchCommand, + ChangeMessageVisibilityCommand, + CreateQueueCommand, + DeleteMessageBatchCommand, + DeleteMessageCommand, + DeleteQueueCommand, + GetQueueAttributesCommand, + GetQueueUrlCommand, + ListDeadLetterSourceQueuesCommand, + ListMessageMoveTasksCommand, + ListQueuesCommand, + ListQueueTagsCommand, + type MessageAttributeValue, + PurgeQueueCommand, + ReceiveMessageCommand, + SendMessageBatchCommand, + SendMessageCommand, + SetQueueAttributesCommand, + type SQSClient, + StartMessageMoveTaskCommand, + TagQueueCommand, + UntagQueueCommand, +} from '@aws-sdk/client-sqs' +import type { AwsSqsCancelMessageMoveTaskBody } from '@/lib/api/contracts/tools/aws/sqs-cancel-message-move-task' +import type { AwsSqsChangeMessageVisibilityBody } from '@/lib/api/contracts/tools/aws/sqs-change-message-visibility' +import type { AwsSqsChangeMessageVisibilityBatchBody } from '@/lib/api/contracts/tools/aws/sqs-change-message-visibility-batch' +import type { AwsSqsCreateQueueBody } from '@/lib/api/contracts/tools/aws/sqs-create-queue' +import type { AwsSqsDeleteMessageBody } from '@/lib/api/contracts/tools/aws/sqs-delete-message' +import type { AwsSqsDeleteMessageBatchBody } from '@/lib/api/contracts/tools/aws/sqs-delete-message-batch' +import type { AwsSqsDeleteQueueBody } from '@/lib/api/contracts/tools/aws/sqs-delete-queue' +import type { AwsSqsGetQueueAttributesBody } from '@/lib/api/contracts/tools/aws/sqs-get-queue-attributes' +import type { AwsSqsGetQueueUrlBody } from '@/lib/api/contracts/tools/aws/sqs-get-queue-url' +import type { AwsSqsListDeadLetterSourceQueuesBody } from '@/lib/api/contracts/tools/aws/sqs-list-dead-letter-source-queues' +import type { AwsSqsListMessageMoveTasksBody } from '@/lib/api/contracts/tools/aws/sqs-list-message-move-tasks' +import type { AwsSqsListQueueTagsBody } from '@/lib/api/contracts/tools/aws/sqs-list-queue-tags' +import type { AwsSqsListQueuesBody } from '@/lib/api/contracts/tools/aws/sqs-list-queues' +import type { AwsSqsPurgeQueueBody } from '@/lib/api/contracts/tools/aws/sqs-purge-queue' +import type { AwsSqsReceiveMessageBody } from '@/lib/api/contracts/tools/aws/sqs-receive-message' +import type { AwsSqsSendMessageBody } from '@/lib/api/contracts/tools/aws/sqs-send-message' +import type { AwsSqsSendMessageBatchBody } from '@/lib/api/contracts/tools/aws/sqs-send-message-batch' +import type { AwsSqsSetQueueAttributesBody } from '@/lib/api/contracts/tools/aws/sqs-set-queue-attributes' +import type { AwsSqsStartMessageMoveTaskBody } from '@/lib/api/contracts/tools/aws/sqs-start-message-move-task' +import type { AwsSqsTagQueueBody } from '@/lib/api/contracts/tools/aws/sqs-tag-queue' +import type { AwsSqsUntagQueueBody } from '@/lib/api/contracts/tools/aws/sqs-untag-queue' +import { createSqsClient } from '@/lib/internal/sqs/client' +import type { SqsConnectionConfig } from '@/tools/sqs/types' -export async function executeSqsSend(input: SqsSendInput, signal?: AbortSignal) { - signal?.throwIfAborted() - const client = createSqsClient(input) +async function withSqsClient( + config: SqsConnectionConfig, + execute: (client: SQSClient) => Promise +): Promise { + const client = createSqsClient(config) try { - const result = await sendMessage( - client, - input.queueUrl, - input.data, - input.messageGroupId, - input.messageDeduplicationId, - signal + return await execute(client) + } finally { + client.destroy() + } +} + +/** Map Sim's JSON-safe message attribute input onto the SQS `MessageAttributeValue` shape. */ +function toMessageAttributes( + attributes: Record | null | undefined +): Record | undefined { + if (!attributes) return undefined + const entries = Object.entries(attributes) + if (entries.length === 0) return undefined + const mapped: Record = {} + for (const [name, value] of entries) { + mapped[name] = { DataType: value.dataType, StringValue: value.stringValue } + } + return mapped +} + +/** Project received `MessageAttributeValue` entries into their JSON-safe string forms. */ +function fromMessageAttributes(attributes: Record | undefined) { + const projected: Record< + string, + { dataType: string | null; stringValue: string | null; stringListValues: string[] } + > = {} + for (const [name, value] of Object.entries(attributes ?? {})) { + projected[name] = { + dataType: value.DataType ?? null, + stringValue: value.StringValue ?? null, + stringListValues: value.StringListValues ?? [], + } + } + return projected +} + +/** Drop the undefined values an SQS attribute map may carry so the result is JSON-stable. */ +function toStringMap(map: Record | undefined): Record { + const projected: Record = {} + for (const [key, value] of Object.entries(map ?? {})) { + if (value !== undefined) projected[key] = value + } + return projected +} + +/** Project the `BatchResultErrorEntry` list shared by all three SQS batch actions. */ +function projectBatchFailures( + failed: { Id?: string; SenderFault?: boolean; Code?: string; Message?: string }[] | undefined +) { + return (failed ?? []).map((entry) => ({ + id: entry.Id ?? null, + senderFault: entry.SenderFault ?? null, + code: entry.Code ?? null, + message: entry.Message ?? null, + })) +} + +export async function executeSqsSend(input: AwsSqsSendMessageBody, signal?: AbortSignal) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new SendMessageCommand({ + QueueUrl: input.queueUrl, + MessageBody: JSON.stringify(input.data), + DelaySeconds: input.delaySeconds ?? undefined, + MessageAttributes: toMessageAttributes(input.messageAttributes), + MessageGroupId: input.messageGroupId ?? undefined, + MessageDeduplicationId: input.messageDeduplicationId ?? undefined, + }), + { abortSignal: signal } ) - signal?.throwIfAborted() return { message: `Message sent to SQS queue ${input.queueUrl}`, - id: result?.id, + id: response.MessageId ?? null, + md5OfMessageBody: response.MD5OfMessageBody ?? null, + md5OfMessageAttributes: response.MD5OfMessageAttributes ?? null, + sequenceNumber: response.SequenceNumber ?? null, } - } finally { - client.destroy() - } + }) +} + +export async function executeSqsSendMessageBatch( + input: AwsSqsSendMessageBatchBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new SendMessageBatchCommand({ + QueueUrl: input.queueUrl, + Entries: input.entries.map((entry) => ({ + Id: entry.id, + MessageBody: JSON.stringify(entry.data), + DelaySeconds: entry.delaySeconds ?? undefined, + MessageAttributes: toMessageAttributes(entry.messageAttributes), + MessageGroupId: entry.messageGroupId ?? undefined, + MessageDeduplicationId: entry.messageDeduplicationId ?? undefined, + })), + }), + { abortSignal: signal } + ) + const successful = (response.Successful ?? []).map((entry) => ({ + id: entry.Id ?? null, + messageId: entry.MessageId ?? null, + md5OfMessageBody: entry.MD5OfMessageBody ?? null, + md5OfMessageAttributes: entry.MD5OfMessageAttributes ?? null, + sequenceNumber: entry.SequenceNumber ?? null, + })) + const failed = projectBatchFailures(response.Failed) + return { + message: `Sent ${successful.length} of ${input.entries.length} messages to SQS queue ${input.queueUrl}`, + successful, + failed, + successCount: successful.length, + failureCount: failed.length, + } + }) +} + +export async function executeSqsReceiveMessage( + input: AwsSqsReceiveMessageBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new ReceiveMessageCommand({ + QueueUrl: input.queueUrl, + MaxNumberOfMessages: input.maxNumberOfMessages ?? undefined, + VisibilityTimeout: input.visibilityTimeout ?? undefined, + WaitTimeSeconds: input.waitTimeSeconds ?? undefined, + MessageAttributeNames: input.messageAttributeNames ?? undefined, + MessageSystemAttributeNames: input.messageSystemAttributeNames ?? undefined, + ReceiveRequestAttemptId: input.receiveRequestAttemptId ?? undefined, + }), + { abortSignal: signal } + ) + const messages = (response.Messages ?? []).map((message) => ({ + messageId: message.MessageId ?? null, + receiptHandle: message.ReceiptHandle ?? null, + body: message.Body ?? null, + md5OfBody: message.MD5OfBody ?? null, + md5OfMessageAttributes: message.MD5OfMessageAttributes ?? null, + attributes: toStringMap(message.Attributes), + messageAttributes: fromMessageAttributes(message.MessageAttributes), + })) + return { messages, count: messages.length } + }) +} + +export async function executeSqsDeleteMessage( + input: AwsSqsDeleteMessageBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + await client.send( + new DeleteMessageCommand({ + QueueUrl: input.queueUrl, + ReceiptHandle: input.receiptHandle, + }), + { abortSignal: signal } + ) + return { message: `Message deleted from SQS queue ${input.queueUrl}` } + }) +} + +export async function executeSqsDeleteMessageBatch( + input: AwsSqsDeleteMessageBatchBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new DeleteMessageBatchCommand({ + QueueUrl: input.queueUrl, + Entries: input.entries.map((entry) => ({ + Id: entry.id, + ReceiptHandle: entry.receiptHandle, + })), + }), + { abortSignal: signal } + ) + const successful = (response.Successful ?? []).map((entry) => ({ id: entry.Id ?? null })) + const failed = projectBatchFailures(response.Failed) + return { + message: `Deleted ${successful.length} of ${input.entries.length} messages from SQS queue ${input.queueUrl}`, + successful, + failed, + successCount: successful.length, + failureCount: failed.length, + } + }) +} + +export async function executeSqsChangeMessageVisibility( + input: AwsSqsChangeMessageVisibilityBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + await client.send( + new ChangeMessageVisibilityCommand({ + QueueUrl: input.queueUrl, + ReceiptHandle: input.receiptHandle, + VisibilityTimeout: input.visibilityTimeout, + }), + { abortSignal: signal } + ) + return { + message: `Visibility timeout set to ${input.visibilityTimeout} seconds on SQS queue ${input.queueUrl}`, + } + }) +} + +export async function executeSqsChangeMessageVisibilityBatch( + input: AwsSqsChangeMessageVisibilityBatchBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new ChangeMessageVisibilityBatchCommand({ + QueueUrl: input.queueUrl, + Entries: input.entries.map((entry) => ({ + Id: entry.id, + ReceiptHandle: entry.receiptHandle, + VisibilityTimeout: entry.visibilityTimeout ?? undefined, + })), + }), + { abortSignal: signal } + ) + const successful = (response.Successful ?? []).map((entry) => ({ id: entry.Id ?? null })) + const failed = projectBatchFailures(response.Failed) + return { + message: `Changed visibility for ${successful.length} of ${input.entries.length} messages on SQS queue ${input.queueUrl}`, + successful, + failed, + successCount: successful.length, + failureCount: failed.length, + } + }) +} + +export async function executeSqsListQueues(input: AwsSqsListQueuesBody, signal?: AbortSignal) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new ListQueuesCommand({ + QueueNamePrefix: input.queueNamePrefix ?? undefined, + MaxResults: input.maxResults ?? undefined, + NextToken: input.nextToken ?? undefined, + }), + { abortSignal: signal } + ) + const queueUrls = response.QueueUrls ?? [] + return { queueUrls, nextToken: response.NextToken ?? null, count: queueUrls.length } + }) +} + +export async function executeSqsGetQueueUrl(input: AwsSqsGetQueueUrlBody, signal?: AbortSignal) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new GetQueueUrlCommand({ + QueueName: input.queueName, + QueueOwnerAWSAccountId: input.queueOwnerAwsAccountId ?? undefined, + }), + { abortSignal: signal } + ) + return { queueUrl: response.QueueUrl ?? null } + }) +} + +export async function executeSqsGetQueueAttributes( + input: AwsSqsGetQueueAttributesBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new GetQueueAttributesCommand({ + QueueUrl: input.queueUrl, + AttributeNames: input.attributeNames ?? undefined, + }), + { abortSignal: signal } + ) + return { attributes: toStringMap(response.Attributes) } + }) +} + +export async function executeSqsSetQueueAttributes( + input: AwsSqsSetQueueAttributesBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + await client.send( + new SetQueueAttributesCommand({ + QueueUrl: input.queueUrl, + Attributes: input.attributes, + }), + { abortSignal: signal } + ) + return { message: `Attributes updated on SQS queue ${input.queueUrl}` } + }) +} + +export async function executeSqsCreateQueue(input: AwsSqsCreateQueueBody, signal?: AbortSignal) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new CreateQueueCommand({ + QueueName: input.queueName, + Attributes: input.attributes ?? undefined, + tags: input.tags ?? undefined, + }), + { abortSignal: signal } + ) + return { + message: `SQS queue "${input.queueName}" created`, + queueUrl: response.QueueUrl ?? null, + } + }) +} + +export async function executeSqsDeleteQueue(input: AwsSqsDeleteQueueBody, signal?: AbortSignal) { + return withSqsClient(input, async (client) => { + await client.send(new DeleteQueueCommand({ QueueUrl: input.queueUrl }), { + abortSignal: signal, + }) + return { message: `SQS queue ${input.queueUrl} deleted` } + }) +} + +export async function executeSqsPurgeQueue(input: AwsSqsPurgeQueueBody, signal?: AbortSignal) { + return withSqsClient(input, async (client) => { + await client.send(new PurgeQueueCommand({ QueueUrl: input.queueUrl }), { abortSignal: signal }) + return { message: `SQS queue ${input.queueUrl} purged` } + }) +} + +export async function executeSqsListDeadLetterSourceQueues( + input: AwsSqsListDeadLetterSourceQueuesBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new ListDeadLetterSourceQueuesCommand({ + QueueUrl: input.queueUrl, + MaxResults: input.maxResults ?? undefined, + NextToken: input.nextToken ?? undefined, + }), + { abortSignal: signal } + ) + const queueUrls = response.queueUrls ?? [] + return { queueUrls, nextToken: response.NextToken ?? null, count: queueUrls.length } + }) +} + +export async function executeSqsListQueueTags( + input: AwsSqsListQueueTagsBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + const response = await client.send(new ListQueueTagsCommand({ QueueUrl: input.queueUrl }), { + abortSignal: signal, + }) + return { tags: toStringMap(response.Tags) } + }) +} + +export async function executeSqsTagQueue(input: AwsSqsTagQueueBody, signal?: AbortSignal) { + return withSqsClient(input, async (client) => { + await client.send(new TagQueueCommand({ QueueUrl: input.queueUrl, Tags: input.tags }), { + abortSignal: signal, + }) + return { message: `Tags applied to SQS queue ${input.queueUrl}` } + }) +} + +export async function executeSqsUntagQueue(input: AwsSqsUntagQueueBody, signal?: AbortSignal) { + return withSqsClient(input, async (client) => { + await client.send(new UntagQueueCommand({ QueueUrl: input.queueUrl, TagKeys: input.tagKeys }), { + abortSignal: signal, + }) + return { message: `Tags removed from SQS queue ${input.queueUrl}` } + }) +} + +export async function executeSqsStartMessageMoveTask( + input: AwsSqsStartMessageMoveTaskBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new StartMessageMoveTaskCommand({ + SourceArn: input.sourceArn, + DestinationArn: input.destinationArn ?? undefined, + MaxNumberOfMessagesPerSecond: input.maxNumberOfMessagesPerSecond ?? undefined, + }), + { abortSignal: signal } + ) + return { + message: `Message move task started for ${input.sourceArn}`, + taskHandle: response.TaskHandle ?? null, + } + }) +} + +export async function executeSqsListMessageMoveTasks( + input: AwsSqsListMessageMoveTasksBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new ListMessageMoveTasksCommand({ + SourceArn: input.sourceArn, + MaxResults: input.maxResults ?? undefined, + }), + { abortSignal: signal } + ) + const results = (response.Results ?? []).map((task) => ({ + taskHandle: task.TaskHandle ?? null, + status: task.Status ?? null, + sourceArn: task.SourceArn ?? null, + destinationArn: task.DestinationArn ?? null, + maxNumberOfMessagesPerSecond: task.MaxNumberOfMessagesPerSecond ?? null, + approximateNumberOfMessagesMoved: task.ApproximateNumberOfMessagesMoved ?? null, + approximateNumberOfMessagesToMove: task.ApproximateNumberOfMessagesToMove ?? null, + failureReason: task.FailureReason ?? null, + startedTimestamp: task.StartedTimestamp ?? null, + })) + return { results, count: results.length } + }) +} + +export async function executeSqsCancelMessageMoveTask( + input: AwsSqsCancelMessageMoveTaskBody, + signal?: AbortSignal +) { + return withSqsClient(input, async (client) => { + const response = await client.send( + new CancelMessageMoveTaskCommand({ TaskHandle: input.taskHandle }), + { abortSignal: signal } + ) + return { + message: 'Message move task cancelled', + approximateNumberOfMessagesMoved: response.ApproximateNumberOfMessagesMoved ?? null, + } + }) } diff --git a/apps/sim/lib/internal/sqs/schema.ts b/apps/sim/lib/internal/sqs/schema.ts deleted file mode 100644 index 6d56c0a69e6..00000000000 --- a/apps/sim/lib/internal/sqs/schema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod' - -export const sqsSendInputSchema = z.object({ - region: z.string().min(1, 'AWS region is required'), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - queueUrl: z.string().min(1, 'Queue URL is required'), - messageGroupId: z.string().nullish(), - messageDeduplicationId: z.string().nullish(), - data: z.record(z.string(), z.unknown()).refine((obj) => Object.keys(obj).length > 0, { - message: 'Data object must have at least one field', - }), -}) - -export type SqsSendInput = z.output diff --git a/apps/sim/lib/internal/ssm/client.test.ts b/apps/sim/lib/internal/ssm/client.test.ts new file mode 100644 index 00000000000..f253228b236 --- /dev/null +++ b/apps/sim/lib/internal/ssm/client.test.ts @@ -0,0 +1,266 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn() })) + +/** + * Stands in for every `@aws-sdk/client-ssm` command class. Each stub keeps the + * request object on `input`, exactly like the real command, so the assertions + * below read the parameter names the client actually sends to AWS. + */ +vi.mock('@aws-sdk/client-ssm', () => { + class CommandStub { + input: unknown + constructor(input: unknown) { + this.input = input + } + } + + const commandNames = [ + 'CancelCommandCommand', + 'DeleteParameterCommand', + 'DescribeAutomationExecutionsCommand', + 'DescribeInstanceInformationCommand', + 'DescribeInstancePatchStatesCommand', + 'DescribeInstancePatchesCommand', + 'DescribeParametersCommand', + 'GetAutomationExecutionCommand', + 'GetCommandInvocationCommand', + 'GetDocumentCommand', + 'GetParameterCommand', + 'GetParametersByPathCommand', + 'GetParametersCommand', + 'ListCommandInvocationsCommand', + 'ListCommandsCommand', + 'ListComplianceItemsCommand', + 'ListComplianceSummariesCommand', + 'ListDocumentsCommand', + 'PutParameterCommand', + 'SendCommandCommand', + 'StartAutomationExecutionCommand', + 'StopAutomationExecutionCommand', + ] as const + + const commands = Object.fromEntries(commandNames.map((name) => [name, CommandStub])) + + return { + ...commands, + SSMClient: class { + send = mockSend + destroy = vi.fn() + }, + } +}) + +import { + createSsmClient, + describeInstancePatchStates, + getCommandInvocation, + getParameter, + listCommands, + putParameter, + sendCommand, +} from '@/lib/internal/ssm/client' + +const CONNECTION = { + region: 'us-east-1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', +} + +function lastCommandInput(): Record { + return mockSend.mock.calls.at(-1)?.[0].input +} + +describe('ssm client', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('maps send_command input onto the documented AWS parameter names', async () => { + mockSend.mockResolvedValue({ Command: {} }) + + await sendCommand(createSsmClient(CONNECTION), { + ...CONNECTION, + documentName: 'AWS-RunShellScript', + instanceIds: ['i-0123456789abcdef0'], + parameters: { commands: ['df -h'] }, + comment: 'disk check', + executionTimeoutSeconds: 600, + maxConcurrency: '50%', + maxErrors: '0', + outputS3BucketName: 'bucket', + outputS3KeyPrefix: 'prefix/', + serviceRoleArn: 'arn:aws:iam::123456789012:role/notify', + }) + + expect(lastCommandInput()).toEqual({ + DocumentName: 'AWS-RunShellScript', + InstanceIds: ['i-0123456789abcdef0'], + Parameters: { commands: ['df -h'] }, + Comment: 'disk check', + TimeoutSeconds: 600, + MaxConcurrency: '50%', + MaxErrors: '0', + OutputS3BucketName: 'bucket', + OutputS3KeyPrefix: 'prefix/', + ServiceRoleArn: 'arn:aws:iam::123456789012:role/notify', + }) + }) + + it('omits every optional send_command field the caller did not set', async () => { + mockSend.mockResolvedValue({ Command: {} }) + + await sendCommand(createSsmClient(CONNECTION), { + ...CONNECTION, + documentName: 'AWS-RunShellScript', + targets: [{ Key: 'tag:Environment', Values: ['prod'] }], + }) + + expect(lastCommandInput()).toEqual({ + DocumentName: 'AWS-RunShellScript', + Targets: [{ Key: 'tag:Environment', Values: ['prod'] }], + }) + }) + + it('projects the Command response and defaults absent collections', async () => { + mockSend.mockResolvedValue({ + Command: { + CommandId: 'command-1', + DocumentName: 'AWS-RunShellScript', + Status: 'Pending', + RequestedDateTime: new Date('2026-01-02T03:04:05.000Z'), + TimeoutSeconds: 600, + }, + }) + + const result = await sendCommand(createSsmClient(CONNECTION), { + ...CONNECTION, + documentName: 'AWS-RunShellScript', + instanceIds: ['i-0123456789abcdef0'], + }) + + expect(result).toMatchObject({ + commandId: 'command-1', + status: 'Pending', + requestedDateTime: '2026-01-02T03:04:05.000Z', + executionTimeoutSeconds: 600, + instanceIds: [], + targets: [], + comment: null, + targetCount: null, + }) + }) + + it('passes GetCommandInvocation timestamps through as the strings AWS returns', async () => { + mockSend.mockResolvedValue({ + CommandId: 'command-1', + InstanceId: 'i-0123456789abcdef0', + Status: 'Success', + ExecutionStartDateTime: '2026-01-02T03:04:05.000Z', + ExecutionEndDateTime: '2026-01-02T03:04:09.000Z', + ExecutionElapsedTime: 'PT4S', + StandardOutputContent: 'ok', + }) + + const result = await getCommandInvocation(createSsmClient(CONNECTION), { + ...CONNECTION, + commandId: '11111111-2222-3333-4444-555555555555', + instanceId: 'i-0123456789abcdef0', + }) + + expect(result.executionStartDateTime).toBe('2026-01-02T03:04:05.000Z') + expect(result.executionElapsedTime).toBe('PT4S') + expect(result.standardOutputContent).toBe('ok') + expect(result.standardErrorContent).toBe('') + }) + + it('sends lowercase key/value CommandFilter members', async () => { + mockSend.mockResolvedValue({ Commands: [] }) + + await listCommands(createSsmClient(CONNECTION), { + ...CONNECTION, + filters: [{ key: 'Status', value: 'Failed' }], + }) + + expect(lastCommandInput()).toEqual({ Filters: [{ key: 'Status', value: 'Failed' }] }) + }) + + it('only sends WithDecryption when the caller opted in', async () => { + mockSend.mockResolvedValue({ Parameter: { Name: '/prod/app/db', Value: 'v' } }) + + await getParameter(createSsmClient(CONNECTION), { ...CONNECTION, name: '/prod/app/db' }) + expect(lastCommandInput()).toEqual({ Name: '/prod/app/db' }) + + await getParameter(createSsmClient(CONNECTION), { + ...CONNECTION, + name: '/prod/app/db', + withDecryption: true, + }) + expect(lastCommandInput()).toEqual({ Name: '/prod/app/db', WithDecryption: true }) + }) + + it('never echoes the written value in the put_parameter result', async () => { + mockSend.mockResolvedValue({ Version: 4, Tier: 'Standard' }) + + const result = await putParameter(createSsmClient(CONNECTION), { + ...CONNECTION, + name: '/prod/app/db-password', + value: 'super-secret-value', + type: 'SecureString', + }) + + expect(JSON.stringify(result)).not.toContain('super-secret-value') + expect(result).toEqual({ + message: 'Parameter "/prod/app/db-password" written successfully', + name: '/prod/app/db-password', + version: 4, + tier: 'Standard', + }) + }) + + it('projects InstancePatchState counts and timestamps', async () => { + mockSend.mockResolvedValue({ + InstancePatchStates: [ + { + InstanceId: 'i-0123456789abcdef0', + PatchGroup: 'prod', + BaselineId: 'pb-1', + Operation: 'Scan', + OperationStartTime: new Date('2026-01-02T03:04:05.000Z'), + OperationEndTime: new Date('2026-01-02T03:14:05.000Z'), + MissingCount: 3, + }, + ], + }) + + const result = await describeInstancePatchStates(createSsmClient(CONNECTION), { + ...CONNECTION, + instanceIds: ['i-0123456789abcdef0'], + }) + + expect(result.count).toBe(1) + expect(result.nextToken).toBeNull() + expect(result.instancePatchStates[0]).toMatchObject({ + instanceId: 'i-0123456789abcdef0', + patchGroup: 'prod', + baselineId: 'pb-1', + operation: 'Scan', + operationStartTime: '2026-01-02T03:04:05.000Z', + operationEndTime: '2026-01-02T03:14:05.000Z', + missingCount: 3, + installedCount: null, + }) + }) + + it('forwards the abort signal to every SDK call', async () => { + const controller = new AbortController() + mockSend.mockResolvedValue({ Commands: [] }) + + await listCommands(createSsmClient(CONNECTION), CONNECTION, controller.signal) + + expect(mockSend.mock.calls.at(-1)?.[1]).toEqual({ abortSignal: controller.signal }) + }) +}) diff --git a/apps/sim/lib/internal/ssm/client.ts b/apps/sim/lib/internal/ssm/client.ts new file mode 100644 index 00000000000..584708bad35 --- /dev/null +++ b/apps/sim/lib/internal/ssm/client.ts @@ -0,0 +1,846 @@ +import type { + AutomationExecutionMetadata, + Command, + CommandInvocation, + CommandPlugin, + ComplianceItem, + ComplianceSummaryItem, + DocumentIdentifier, + InstanceInformation, + InstancePatchState, + Parameter, + ParameterMetadata, + PatchComplianceData, + SeveritySummary, + StepExecution, + Tag, + Target, +} from '@aws-sdk/client-ssm' +import { + CancelCommandCommand, + DeleteParameterCommand, + DescribeAutomationExecutionsCommand, + DescribeInstanceInformationCommand, + DescribeInstancePatchesCommand, + DescribeInstancePatchStatesCommand, + DescribeParametersCommand, + GetAutomationExecutionCommand, + GetCommandInvocationCommand, + GetDocumentCommand, + GetParameterCommand, + GetParametersByPathCommand, + GetParametersCommand, + ListCommandInvocationsCommand, + ListCommandsCommand, + ListComplianceItemsCommand, + ListComplianceSummariesCommand, + ListDocumentsCommand, + PutParameterCommand, + SendCommandCommand, + SSMClient, + StartAutomationExecutionCommand, + StopAutomationExecutionCommand, +} from '@aws-sdk/client-ssm' +import type { AwsSsmCancelCommandBody } from '@/lib/api/contracts/tools/aws/ssm-cancel-command' +import type { AwsSsmDeleteParameterBody } from '@/lib/api/contracts/tools/aws/ssm-delete-parameter' +import type { AwsSsmDescribeAutomationExecutionsBody } from '@/lib/api/contracts/tools/aws/ssm-describe-automation-executions' +import type { AwsSsmDescribeInstanceInformationBody } from '@/lib/api/contracts/tools/aws/ssm-describe-instance-information' +import type { AwsSsmDescribeInstancePatchStatesBody } from '@/lib/api/contracts/tools/aws/ssm-describe-instance-patch-states' +import type { AwsSsmDescribeInstancePatchesBody } from '@/lib/api/contracts/tools/aws/ssm-describe-instance-patches' +import type { AwsSsmDescribeParametersBody } from '@/lib/api/contracts/tools/aws/ssm-describe-parameters' +import type { AwsSsmGetAutomationExecutionBody } from '@/lib/api/contracts/tools/aws/ssm-get-automation-execution' +import type { AwsSsmGetCommandInvocationBody } from '@/lib/api/contracts/tools/aws/ssm-get-command-invocation' +import type { AwsSsmGetDocumentBody } from '@/lib/api/contracts/tools/aws/ssm-get-document' +import type { AwsSsmGetParameterBody } from '@/lib/api/contracts/tools/aws/ssm-get-parameter' +import type { AwsSsmGetParametersBody } from '@/lib/api/contracts/tools/aws/ssm-get-parameters' +import type { AwsSsmGetParametersByPathBody } from '@/lib/api/contracts/tools/aws/ssm-get-parameters-by-path' +import type { AwsSsmListCommandInvocationsBody } from '@/lib/api/contracts/tools/aws/ssm-list-command-invocations' +import type { AwsSsmListCommandsBody } from '@/lib/api/contracts/tools/aws/ssm-list-commands' +import type { AwsSsmListComplianceItemsBody } from '@/lib/api/contracts/tools/aws/ssm-list-compliance-items' +import type { AwsSsmListComplianceSummariesBody } from '@/lib/api/contracts/tools/aws/ssm-list-compliance-summaries' +import type { AwsSsmListDocumentsBody } from '@/lib/api/contracts/tools/aws/ssm-list-documents' +import type { AwsSsmPutParameterBody } from '@/lib/api/contracts/tools/aws/ssm-put-parameter' +import type { AwsSsmSendCommandBody } from '@/lib/api/contracts/tools/aws/ssm-send-command' +import type { AwsSsmStartAutomationExecutionBody } from '@/lib/api/contracts/tools/aws/ssm-start-automation-execution' +import type { AwsSsmStopAutomationExecutionBody } from '@/lib/api/contracts/tools/aws/ssm-stop-automation-execution' + +interface SsmConnectionConfig { + region: string + accessKeyId: string + secretAccessKey: string +} + +export function createSsmClient(config: SsmConnectionConfig): SSMClient { + return new SSMClient({ + region: config.region, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }) +} + +function isoDate(value: Date | undefined): string | null { + return value?.toISOString() ?? null +} + +function mapTargets(targets: Target[] | undefined) { + return (targets ?? []).map((target) => ({ + key: target.Key ?? null, + values: target.Values ?? [], + })) +} + +function mapTags(tags: Tag[] | undefined) { + return (tags ?? []).map((tag) => ({ key: tag.Key ?? '', value: tag.Value ?? '' })) +} + +function mapCommand(command: Command | undefined) { + return { + commandId: command?.CommandId ?? '', + documentName: command?.DocumentName ?? '', + documentVersion: command?.DocumentVersion ?? null, + comment: command?.Comment ?? null, + status: command?.Status ?? '', + statusDetails: command?.StatusDetails ?? null, + requestedDateTime: isoDate(command?.RequestedDateTime), + expiresAfter: isoDate(command?.ExpiresAfter), + instanceIds: command?.InstanceIds ?? [], + targets: mapTargets(command?.Targets), + maxConcurrency: command?.MaxConcurrency ?? null, + maxErrors: command?.MaxErrors ?? null, + targetCount: command?.TargetCount ?? null, + completedCount: command?.CompletedCount ?? null, + errorCount: command?.ErrorCount ?? null, + deliveryTimedOutCount: command?.DeliveryTimedOutCount ?? null, + executionTimeoutSeconds: command?.TimeoutSeconds ?? null, + outputS3BucketName: command?.OutputS3BucketName ?? null, + outputS3KeyPrefix: command?.OutputS3KeyPrefix ?? null, + outputS3Region: command?.OutputS3Region ?? null, + serviceRole: command?.ServiceRole ?? null, + } +} + +function mapCommandPlugin(plugin: CommandPlugin) { + return { + name: plugin.Name ?? '', + status: plugin.Status ?? '', + statusDetails: plugin.StatusDetails ?? null, + responseCode: plugin.ResponseCode ?? null, + responseStartDateTime: isoDate(plugin.ResponseStartDateTime), + responseFinishDateTime: isoDate(plugin.ResponseFinishDateTime), + output: plugin.Output ?? null, + standardOutputUrl: plugin.StandardOutputUrl ?? null, + standardErrorUrl: plugin.StandardErrorUrl ?? null, + } +} + +function mapCommandInvocation(invocation: CommandInvocation) { + return { + commandId: invocation.CommandId ?? '', + instanceId: invocation.InstanceId ?? '', + instanceName: invocation.InstanceName ?? null, + documentName: invocation.DocumentName ?? null, + documentVersion: invocation.DocumentVersion ?? null, + comment: invocation.Comment ?? null, + requestedDateTime: isoDate(invocation.RequestedDateTime), + status: invocation.Status ?? '', + statusDetails: invocation.StatusDetails ?? null, + traceOutput: invocation.TraceOutput ?? null, + standardOutputUrl: invocation.StandardOutputUrl ?? null, + standardErrorUrl: invocation.StandardErrorUrl ?? null, + serviceRole: invocation.ServiceRole ?? null, + commandPlugins: (invocation.CommandPlugins ?? []).map(mapCommandPlugin), + } +} + +function mapParameter(parameter: Parameter) { + return { + name: parameter.Name ?? '', + type: parameter.Type ?? '', + value: parameter.Value ?? '', + version: parameter.Version ?? null, + selector: parameter.Selector ?? null, + sourceResult: parameter.SourceResult ?? null, + lastModifiedDate: isoDate(parameter.LastModifiedDate), + arn: parameter.ARN ?? '', + dataType: parameter.DataType ?? null, + } +} + +function mapParameterMetadata(metadata: ParameterMetadata) { + return { + name: metadata.Name ?? '', + arn: metadata.ARN ?? '', + type: metadata.Type ?? '', + keyId: metadata.KeyId ?? null, + lastModifiedDate: isoDate(metadata.LastModifiedDate), + lastModifiedUser: metadata.LastModifiedUser ?? null, + description: metadata.Description ?? null, + allowedPattern: metadata.AllowedPattern ?? null, + version: metadata.Version ?? null, + tier: metadata.Tier ?? null, + dataType: metadata.DataType ?? null, + policies: (metadata.Policies ?? []).map((policy) => ({ + policyText: policy.PolicyText ?? null, + policyType: policy.PolicyType ?? null, + policyStatus: policy.PolicyStatus ?? null, + })), + } +} + +function mapInstanceInformation(instance: InstanceInformation) { + return { + instanceId: instance.InstanceId ?? '', + pingStatus: instance.PingStatus ?? '', + lastPingDateTime: isoDate(instance.LastPingDateTime), + agentVersion: instance.AgentVersion ?? null, + isLatestVersion: instance.IsLatestVersion ?? null, + platformType: instance.PlatformType ?? null, + platformName: instance.PlatformName ?? null, + platformVersion: instance.PlatformVersion ?? null, + activationId: instance.ActivationId ?? null, + iamRole: instance.IamRole ?? null, + registrationDate: isoDate(instance.RegistrationDate), + resourceType: instance.ResourceType ?? null, + name: instance.Name ?? null, + ipAddress: instance.IPAddress ?? null, + computerName: instance.ComputerName ?? null, + associationStatus: instance.AssociationStatus ?? null, + lastAssociationExecutionDate: isoDate(instance.LastAssociationExecutionDate), + lastSuccessfulAssociationExecutionDate: isoDate( + instance.LastSuccessfulAssociationExecutionDate + ), + sourceId: instance.SourceId ?? null, + sourceType: instance.SourceType ?? null, + } +} + +function mapPatchComplianceData(patch: PatchComplianceData) { + return { + title: patch.Title ?? '', + kbId: patch.KBId ?? '', + classification: patch.Classification ?? '', + severity: patch.Severity ?? '', + state: patch.State ?? '', + installedTime: isoDate(patch.InstalledTime), + cveIds: patch.CVEIds ?? null, + } +} + +function mapInstancePatchState(state: InstancePatchState) { + return { + instanceId: state.InstanceId ?? '', + patchGroup: state.PatchGroup ?? '', + baselineId: state.BaselineId ?? '', + snapshotId: state.SnapshotId ?? null, + ownerInformation: state.OwnerInformation ?? null, + installedCount: state.InstalledCount ?? null, + installedOtherCount: state.InstalledOtherCount ?? null, + installedPendingRebootCount: state.InstalledPendingRebootCount ?? null, + installedRejectedCount: state.InstalledRejectedCount ?? null, + missingCount: state.MissingCount ?? null, + failedCount: state.FailedCount ?? null, + unreportedNotApplicableCount: state.UnreportedNotApplicableCount ?? null, + notApplicableCount: state.NotApplicableCount ?? null, + criticalNonCompliantCount: state.CriticalNonCompliantCount ?? null, + securityNonCompliantCount: state.SecurityNonCompliantCount ?? null, + otherNonCompliantCount: state.OtherNonCompliantCount ?? null, + operation: state.Operation ?? '', + operationStartTime: isoDate(state.OperationStartTime), + operationEndTime: isoDate(state.OperationEndTime), + lastNoRebootInstallOperationTime: isoDate(state.LastNoRebootInstallOperationTime), + rebootOption: state.RebootOption ?? null, + } +} + +function mapComplianceItem(item: ComplianceItem) { + return { + complianceType: item.ComplianceType ?? '', + resourceType: item.ResourceType ?? '', + resourceId: item.ResourceId ?? '', + id: item.Id ?? '', + title: item.Title ?? '', + status: item.Status ?? '', + severity: item.Severity ?? '', + executionTime: isoDate(item.ExecutionSummary?.ExecutionTime), + executionId: item.ExecutionSummary?.ExecutionId ?? null, + executionType: item.ExecutionSummary?.ExecutionType ?? null, + details: item.Details ?? null, + } +} + +function mapSeveritySummary(summary: SeveritySummary | undefined) { + if (!summary) return null + return { + criticalCount: summary.CriticalCount ?? null, + highCount: summary.HighCount ?? null, + mediumCount: summary.MediumCount ?? null, + lowCount: summary.LowCount ?? null, + informationalCount: summary.InformationalCount ?? null, + unspecifiedCount: summary.UnspecifiedCount ?? null, + } +} + +function mapComplianceSummaryItem(item: ComplianceSummaryItem) { + return { + complianceType: item.ComplianceType ?? '', + compliantCount: item.CompliantSummary?.CompliantCount ?? null, + compliantSeveritySummary: mapSeveritySummary(item.CompliantSummary?.SeveritySummary), + nonCompliantCount: item.NonCompliantSummary?.NonCompliantCount ?? null, + nonCompliantSeveritySummary: mapSeveritySummary(item.NonCompliantSummary?.SeveritySummary), + } +} + +function mapAutomationExecutionMetadata(execution: AutomationExecutionMetadata) { + return { + automationExecutionId: execution.AutomationExecutionId ?? '', + documentName: execution.DocumentName ?? '', + documentVersion: execution.DocumentVersion ?? null, + automationExecutionStatus: execution.AutomationExecutionStatus ?? '', + executionStartTime: isoDate(execution.ExecutionStartTime), + executionEndTime: isoDate(execution.ExecutionEndTime), + executedBy: execution.ExecutedBy ?? null, + logFile: execution.LogFile ?? null, + mode: execution.Mode ?? null, + parentAutomationExecutionId: execution.ParentAutomationExecutionId ?? null, + currentStepName: execution.CurrentStepName ?? null, + currentAction: execution.CurrentAction ?? null, + failureMessage: execution.FailureMessage ?? null, + targetParameterName: execution.TargetParameterName ?? null, + target: execution.Target ?? null, + automationType: execution.AutomationType ?? null, + maxConcurrency: execution.MaxConcurrency ?? null, + maxErrors: execution.MaxErrors ?? null, + outputs: execution.Outputs ?? null, + } +} + +function mapStepExecution(step: StepExecution) { + return { + stepName: step.StepName ?? null, + action: step.Action ?? null, + stepStatus: step.StepStatus ?? null, + stepExecutionId: step.StepExecutionId ?? null, + executionStartTime: isoDate(step.ExecutionStartTime), + executionEndTime: isoDate(step.ExecutionEndTime), + failureMessage: step.FailureMessage ?? null, + response: step.Response ?? null, + isEnd: step.IsEnd ?? null, + nextStep: step.NextStep ?? null, + } +} + +function mapDocumentIdentifier(document: DocumentIdentifier) { + return { + name: document.Name ?? '', + displayName: document.DisplayName ?? null, + owner: document.Owner ?? null, + createdDate: isoDate(document.CreatedDate), + versionName: document.VersionName ?? null, + documentVersion: document.DocumentVersion ?? null, + documentType: document.DocumentType ?? null, + documentFormat: document.DocumentFormat ?? null, + schemaVersion: document.SchemaVersion ?? null, + platformTypes: document.PlatformTypes ?? [], + targetType: document.TargetType ?? null, + reviewStatus: document.ReviewStatus ?? null, + author: document.Author ?? null, + tags: mapTags(document.Tags), + } +} + +export async function sendCommand( + client: SSMClient, + input: AwsSsmSendCommandBody, + signal?: AbortSignal +) { + const response = await client.send( + new SendCommandCommand({ + DocumentName: input.documentName, + ...(input.documentVersion ? { DocumentVersion: input.documentVersion } : {}), + ...(input.instanceIds?.length ? { InstanceIds: input.instanceIds } : {}), + ...(input.targets?.length ? { Targets: input.targets } : {}), + ...(input.comment ? { Comment: input.comment } : {}), + ...(input.parameters ? { Parameters: input.parameters } : {}), + ...(input.executionTimeoutSeconds != null + ? { TimeoutSeconds: input.executionTimeoutSeconds } + : {}), + ...(input.maxConcurrency ? { MaxConcurrency: input.maxConcurrency } : {}), + ...(input.maxErrors ? { MaxErrors: input.maxErrors } : {}), + ...(input.outputS3BucketName ? { OutputS3BucketName: input.outputS3BucketName } : {}), + ...(input.outputS3KeyPrefix ? { OutputS3KeyPrefix: input.outputS3KeyPrefix } : {}), + ...(input.serviceRoleArn ? { ServiceRoleArn: input.serviceRoleArn } : {}), + }), + { abortSignal: signal } + ) + + return mapCommand(response.Command) +} + +export async function listCommands( + client: SSMClient, + input: AwsSsmListCommandsBody, + signal?: AbortSignal +) { + const response = await client.send( + new ListCommandsCommand({ + ...(input.commandId ? { CommandId: input.commandId } : {}), + ...(input.instanceId ? { InstanceId: input.instanceId } : {}), + ...(input.filters?.length + ? { Filters: input.filters.map((filter) => ({ key: filter.key, value: filter.value })) } + : {}), + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const commands = (response.Commands ?? []).map((command) => mapCommand(command)) + return { commands, nextToken: response.NextToken ?? null, count: commands.length } +} + +export async function listCommandInvocations( + client: SSMClient, + input: AwsSsmListCommandInvocationsBody, + signal?: AbortSignal +) { + const response = await client.send( + new ListCommandInvocationsCommand({ + ...(input.commandId ? { CommandId: input.commandId } : {}), + ...(input.instanceId ? { InstanceId: input.instanceId } : {}), + ...(input.filters?.length + ? { Filters: input.filters.map((filter) => ({ key: filter.key, value: filter.value })) } + : {}), + ...(input.details != null ? { Details: input.details } : {}), + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const commandInvocations = (response.CommandInvocations ?? []).map(mapCommandInvocation) + return { + commandInvocations, + nextToken: response.NextToken ?? null, + count: commandInvocations.length, + } +} + +export async function getCommandInvocation( + client: SSMClient, + input: AwsSsmGetCommandInvocationBody, + signal?: AbortSignal +) { + const response = await client.send( + new GetCommandInvocationCommand({ + CommandId: input.commandId, + InstanceId: input.instanceId, + ...(input.pluginName ? { PluginName: input.pluginName } : {}), + }), + { abortSignal: signal } + ) + + return { + commandId: response.CommandId ?? '', + instanceId: response.InstanceId ?? '', + comment: response.Comment ?? null, + documentName: response.DocumentName ?? null, + documentVersion: response.DocumentVersion ?? null, + pluginName: response.PluginName ?? null, + responseCode: response.ResponseCode ?? null, + executionStartDateTime: response.ExecutionStartDateTime ?? null, + executionElapsedTime: response.ExecutionElapsedTime ?? null, + executionEndDateTime: response.ExecutionEndDateTime ?? null, + status: response.Status ?? '', + statusDetails: response.StatusDetails ?? null, + standardOutputContent: response.StandardOutputContent ?? '', + standardOutputUrl: response.StandardOutputUrl ?? null, + standardErrorContent: response.StandardErrorContent ?? '', + standardErrorUrl: response.StandardErrorUrl ?? null, + } +} + +export async function cancelCommand( + client: SSMClient, + input: AwsSsmCancelCommandBody, + signal?: AbortSignal +) { + await client.send( + new CancelCommandCommand({ + CommandId: input.commandId, + ...(input.instanceIds?.length ? { InstanceIds: input.instanceIds } : {}), + }), + { abortSignal: signal } + ) + + return { message: 'Command cancellation requested', commandId: input.commandId } +} + +export async function getParameter( + client: SSMClient, + input: AwsSsmGetParameterBody, + signal?: AbortSignal +) { + const response = await client.send( + new GetParameterCommand({ + Name: input.name, + ...(input.withDecryption != null ? { WithDecryption: input.withDecryption } : {}), + }), + { abortSignal: signal } + ) + + return mapParameter(response.Parameter ?? {}) +} + +export async function getParameters( + client: SSMClient, + input: AwsSsmGetParametersBody, + signal?: AbortSignal +) { + const response = await client.send( + new GetParametersCommand({ + Names: input.names, + ...(input.withDecryption != null ? { WithDecryption: input.withDecryption } : {}), + }), + { abortSignal: signal } + ) + + const parameters = (response.Parameters ?? []).map(mapParameter) + return { + parameters, + invalidParameters: response.InvalidParameters ?? [], + count: parameters.length, + } +} + +export async function getParametersByPath( + client: SSMClient, + input: AwsSsmGetParametersByPathBody, + signal?: AbortSignal +) { + const response = await client.send( + new GetParametersByPathCommand({ + Path: input.path, + ...(input.recursive != null ? { Recursive: input.recursive } : {}), + ...(input.withDecryption != null ? { WithDecryption: input.withDecryption } : {}), + ...(input.parameterFilters?.length ? { ParameterFilters: input.parameterFilters } : {}), + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const parameters = (response.Parameters ?? []).map(mapParameter) + return { parameters, nextToken: response.NextToken ?? null, count: parameters.length } +} + +export async function putParameter( + client: SSMClient, + input: AwsSsmPutParameterBody, + signal?: AbortSignal +) { + const response = await client.send( + new PutParameterCommand({ + Name: input.name, + Value: input.value, + ...(input.type ? { Type: input.type } : {}), + ...(input.description ? { Description: input.description } : {}), + ...(input.keyId ? { KeyId: input.keyId } : {}), + ...(input.overwrite != null ? { Overwrite: input.overwrite } : {}), + ...(input.allowedPattern ? { AllowedPattern: input.allowedPattern } : {}), + ...(input.tier ? { Tier: input.tier } : {}), + ...(input.dataType ? { DataType: input.dataType } : {}), + ...(input.policies ? { Policies: input.policies } : {}), + }), + { abortSignal: signal } + ) + + return { + message: `Parameter "${input.name}" written successfully`, + name: input.name, + version: response.Version ?? null, + tier: response.Tier ?? null, + } +} + +export async function deleteParameter( + client: SSMClient, + input: AwsSsmDeleteParameterBody, + signal?: AbortSignal +) { + await client.send(new DeleteParameterCommand({ Name: input.name }), { abortSignal: signal }) + return { message: `Parameter "${input.name}" deleted successfully`, name: input.name } +} + +export async function describeParameters( + client: SSMClient, + input: AwsSsmDescribeParametersBody, + signal?: AbortSignal +) { + const response = await client.send( + new DescribeParametersCommand({ + ...(input.parameterFilters?.length ? { ParameterFilters: input.parameterFilters } : {}), + ...(input.shared != null ? { Shared: input.shared } : {}), + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const parameters = (response.Parameters ?? []).map(mapParameterMetadata) + return { parameters, nextToken: response.NextToken ?? null, count: parameters.length } +} + +export async function describeInstanceInformation( + client: SSMClient, + input: AwsSsmDescribeInstanceInformationBody, + signal?: AbortSignal +) { + const response = await client.send( + new DescribeInstanceInformationCommand({ + ...(input.filters?.length ? { Filters: input.filters } : {}), + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const instances = (response.InstanceInformationList ?? []).map(mapInstanceInformation) + return { instances, nextToken: response.NextToken ?? null, count: instances.length } +} + +export async function describeInstancePatches( + client: SSMClient, + input: AwsSsmDescribeInstancePatchesBody, + signal?: AbortSignal +) { + const response = await client.send( + new DescribeInstancePatchesCommand({ + InstanceId: input.instanceId, + ...(input.filters?.length ? { Filters: input.filters } : {}), + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const patches = (response.Patches ?? []).map(mapPatchComplianceData) + return { patches, nextToken: response.NextToken ?? null, count: patches.length } +} + +export async function describeInstancePatchStates( + client: SSMClient, + input: AwsSsmDescribeInstancePatchStatesBody, + signal?: AbortSignal +) { + const response = await client.send( + new DescribeInstancePatchStatesCommand({ + InstanceIds: input.instanceIds, + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const instancePatchStates = (response.InstancePatchStates ?? []).map(mapInstancePatchState) + return { + instancePatchStates, + nextToken: response.NextToken ?? null, + count: instancePatchStates.length, + } +} + +export async function listComplianceItems( + client: SSMClient, + input: AwsSsmListComplianceItemsBody, + signal?: AbortSignal +) { + const response = await client.send( + new ListComplianceItemsCommand({ + ...(input.resourceIds?.length ? { ResourceIds: input.resourceIds } : {}), + ...(input.resourceTypes?.length ? { ResourceTypes: input.resourceTypes } : {}), + ...(input.filters?.length ? { Filters: input.filters } : {}), + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const complianceItems = (response.ComplianceItems ?? []).map(mapComplianceItem) + return { complianceItems, nextToken: response.NextToken ?? null, count: complianceItems.length } +} + +export async function listComplianceSummaries( + client: SSMClient, + input: AwsSsmListComplianceSummariesBody, + signal?: AbortSignal +) { + const response = await client.send( + new ListComplianceSummariesCommand({ + ...(input.filters?.length ? { Filters: input.filters } : {}), + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const complianceSummaryItems = (response.ComplianceSummaryItems ?? []).map( + mapComplianceSummaryItem + ) + return { + complianceSummaryItems, + nextToken: response.NextToken ?? null, + count: complianceSummaryItems.length, + } +} + +export async function startAutomationExecution( + client: SSMClient, + input: AwsSsmStartAutomationExecutionBody, + signal?: AbortSignal +) { + const response = await client.send( + new StartAutomationExecutionCommand({ + DocumentName: input.documentName, + ...(input.documentVersion ? { DocumentVersion: input.documentVersion } : {}), + ...(input.parameters ? { Parameters: input.parameters } : {}), + ...(input.mode ? { Mode: input.mode } : {}), + ...(input.targetParameterName ? { TargetParameterName: input.targetParameterName } : {}), + ...(input.targets?.length ? { Targets: input.targets } : {}), + ...(input.maxConcurrency ? { MaxConcurrency: input.maxConcurrency } : {}), + ...(input.maxErrors ? { MaxErrors: input.maxErrors } : {}), + ...(input.clientToken ? { ClientToken: input.clientToken } : {}), + }), + { abortSignal: signal } + ) + + return { automationExecutionId: response.AutomationExecutionId ?? '' } +} + +export async function describeAutomationExecutions( + client: SSMClient, + input: AwsSsmDescribeAutomationExecutionsBody, + signal?: AbortSignal +) { + const response = await client.send( + new DescribeAutomationExecutionsCommand({ + ...(input.filters?.length ? { Filters: input.filters } : {}), + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const automationExecutions = (response.AutomationExecutionMetadataList ?? []).map( + mapAutomationExecutionMetadata + ) + return { + automationExecutions, + nextToken: response.NextToken ?? null, + count: automationExecutions.length, + } +} + +export async function getAutomationExecution( + client: SSMClient, + input: AwsSsmGetAutomationExecutionBody, + signal?: AbortSignal +) { + const response = await client.send( + new GetAutomationExecutionCommand({ AutomationExecutionId: input.automationExecutionId }), + { abortSignal: signal } + ) + + const execution = response.AutomationExecution + + return { + automationExecutionId: execution?.AutomationExecutionId ?? '', + documentName: execution?.DocumentName ?? '', + documentVersion: execution?.DocumentVersion ?? null, + automationExecutionStatus: execution?.AutomationExecutionStatus ?? '', + executionStartTime: isoDate(execution?.ExecutionStartTime), + executionEndTime: isoDate(execution?.ExecutionEndTime), + executedBy: execution?.ExecutedBy ?? null, + mode: execution?.Mode ?? null, + parentAutomationExecutionId: execution?.ParentAutomationExecutionId ?? null, + currentStepName: execution?.CurrentStepName ?? null, + currentAction: execution?.CurrentAction ?? null, + failureMessage: execution?.FailureMessage ?? null, + targetParameterName: execution?.TargetParameterName ?? null, + target: execution?.Target ?? null, + maxConcurrency: execution?.MaxConcurrency ?? null, + maxErrors: execution?.MaxErrors ?? null, + parameters: execution?.Parameters ?? null, + outputs: execution?.Outputs ?? null, + stepExecutions: (execution?.StepExecutions ?? []).map(mapStepExecution), + stepExecutionsTruncated: execution?.StepExecutionsTruncated ?? null, + } +} + +export async function stopAutomationExecution( + client: SSMClient, + input: AwsSsmStopAutomationExecutionBody, + signal?: AbortSignal +) { + await client.send( + new StopAutomationExecutionCommand({ + AutomationExecutionId: input.automationExecutionId, + ...(input.stopType ? { Type: input.stopType } : {}), + }), + { abortSignal: signal } + ) + + return { + message: 'Automation execution stop requested', + automationExecutionId: input.automationExecutionId, + } +} + +export async function listDocuments( + client: SSMClient, + input: AwsSsmListDocumentsBody, + signal?: AbortSignal +) { + const response = await client.send( + new ListDocumentsCommand({ + ...(input.filters?.length ? { Filters: input.filters } : {}), + ...(input.maxResults != null ? { MaxResults: input.maxResults } : {}), + ...(input.nextToken ? { NextToken: input.nextToken } : {}), + }), + { abortSignal: signal } + ) + + const documents = (response.DocumentIdentifiers ?? []).map(mapDocumentIdentifier) + return { documents, nextToken: response.NextToken ?? null, count: documents.length } +} + +export async function getDocument( + client: SSMClient, + input: AwsSsmGetDocumentBody, + signal?: AbortSignal +) { + const response = await client.send( + new GetDocumentCommand({ + Name: input.name, + ...(input.documentVersion ? { DocumentVersion: input.documentVersion } : {}), + ...(input.versionName ? { VersionName: input.versionName } : {}), + ...(input.documentFormat ? { DocumentFormat: input.documentFormat } : {}), + }), + { abortSignal: signal } + ) + + return { + name: response.Name ?? '', + displayName: response.DisplayName ?? null, + createdDate: isoDate(response.CreatedDate), + versionName: response.VersionName ?? null, + documentVersion: response.DocumentVersion ?? null, + status: response.Status ?? null, + statusInformation: response.StatusInformation ?? null, + content: response.Content ?? '', + documentType: response.DocumentType ?? null, + documentFormat: response.DocumentFormat ?? null, + reviewStatus: response.ReviewStatus ?? null, + } +} diff --git a/apps/sim/lib/internal/ssm/execute-tool.test.ts b/apps/sim/lib/internal/ssm/execute-tool.test.ts new file mode 100644 index 00000000000..9fa92cf8710 --- /dev/null +++ b/apps/sim/lib/internal/ssm/execute-tool.test.ts @@ -0,0 +1,318 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockOperations = vi.hoisted(() => ({ + executeSsmCancelCommand: vi.fn(), + executeSsmDeleteParameter: vi.fn(), + executeSsmDescribeAutomationExecutions: vi.fn(), + executeSsmDescribeInstanceInformation: vi.fn(), + executeSsmDescribeInstancePatchStates: vi.fn(), + executeSsmDescribeInstancePatches: vi.fn(), + executeSsmDescribeParameters: vi.fn(), + executeSsmGetAutomationExecution: vi.fn(), + executeSsmGetCommandInvocation: vi.fn(), + executeSsmGetDocument: vi.fn(), + executeSsmGetParameter: vi.fn(), + executeSsmGetParameters: vi.fn(), + executeSsmGetParametersByPath: vi.fn(), + executeSsmListCommandInvocations: vi.fn(), + executeSsmListCommands: vi.fn(), + executeSsmListComplianceItems: vi.fn(), + executeSsmListComplianceSummaries: vi.fn(), + executeSsmListDocuments: vi.fn(), + executeSsmPutParameter: vi.fn(), + executeSsmSendCommand: vi.fn(), + executeSsmStartAutomationExecution: vi.fn(), + executeSsmStopAutomationExecution: vi.fn(), +})) + +vi.mock('@/lib/internal/ssm/operations', () => mockOperations) + +import { executeSsmTool } from '@/lib/internal/ssm/execute-tool' +import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' + +const CONNECTION = { + region: 'us-east-1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', +} + +const COMMAND_ID = '11111111-2222-3333-4444-555555555555' +const INSTANCE_ID = 'i-0123456789abcdef0' + +function createRequest( + overrides: Partial = {} +): InternalToolOperationCall { + return { + toolId: 'ssm_list_commands', + input: CONNECTION, + headers: new Headers({ 'content-type': 'application/json' }), + context: { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + metadata: {}, + }, + requestId: 'request-1', + ...overrides, + } +} + +const TOOL_CASES = [ + { + toolId: 'ssm_send_command', + input: { ...CONNECTION, documentName: 'AWS-RunShellScript', instanceIds: [INSTANCE_ID] }, + operation: mockOperations.executeSsmSendCommand, + }, + { + toolId: 'ssm_list_commands', + input: CONNECTION, + operation: mockOperations.executeSsmListCommands, + }, + { + toolId: 'ssm_list_command_invocations', + input: { ...CONNECTION, commandId: COMMAND_ID }, + operation: mockOperations.executeSsmListCommandInvocations, + }, + { + toolId: 'ssm_get_command_invocation', + input: { ...CONNECTION, commandId: COMMAND_ID, instanceId: INSTANCE_ID }, + operation: mockOperations.executeSsmGetCommandInvocation, + }, + { + toolId: 'ssm_cancel_command', + input: { ...CONNECTION, commandId: COMMAND_ID }, + operation: mockOperations.executeSsmCancelCommand, + }, + { + toolId: 'ssm_get_parameter', + input: { ...CONNECTION, name: '/prod/app/database-url' }, + operation: mockOperations.executeSsmGetParameter, + }, + { + toolId: 'ssm_get_parameters', + input: { ...CONNECTION, names: ['/prod/app/database-url'] }, + operation: mockOperations.executeSsmGetParameters, + }, + { + toolId: 'ssm_get_parameters_by_path', + input: { ...CONNECTION, path: '/prod/app' }, + operation: mockOperations.executeSsmGetParametersByPath, + }, + { + toolId: 'ssm_put_parameter', + input: { ...CONNECTION, name: '/prod/app/database-url', value: 'postgres://example' }, + operation: mockOperations.executeSsmPutParameter, + }, + { + toolId: 'ssm_delete_parameter', + input: { ...CONNECTION, name: '/prod/app/database-url' }, + operation: mockOperations.executeSsmDeleteParameter, + }, + { + toolId: 'ssm_describe_parameters', + input: CONNECTION, + operation: mockOperations.executeSsmDescribeParameters, + }, + { + toolId: 'ssm_describe_instance_information', + input: CONNECTION, + operation: mockOperations.executeSsmDescribeInstanceInformation, + }, + { + toolId: 'ssm_describe_instance_patches', + input: { ...CONNECTION, instanceId: INSTANCE_ID }, + operation: mockOperations.executeSsmDescribeInstancePatches, + }, + { + toolId: 'ssm_describe_instance_patch_states', + input: { ...CONNECTION, instanceIds: [INSTANCE_ID] }, + operation: mockOperations.executeSsmDescribeInstancePatchStates, + }, + { + toolId: 'ssm_list_compliance_items', + input: { ...CONNECTION, resourceIds: [INSTANCE_ID] }, + operation: mockOperations.executeSsmListComplianceItems, + }, + { + toolId: 'ssm_list_compliance_summaries', + input: CONNECTION, + operation: mockOperations.executeSsmListComplianceSummaries, + }, + { + toolId: 'ssm_start_automation_execution', + input: { ...CONNECTION, documentName: 'AWS-RestartEC2Instance' }, + operation: mockOperations.executeSsmStartAutomationExecution, + }, + { + toolId: 'ssm_describe_automation_executions', + input: CONNECTION, + operation: mockOperations.executeSsmDescribeAutomationExecutions, + }, + { + toolId: 'ssm_get_automation_execution', + input: { ...CONNECTION, automationExecutionId: COMMAND_ID }, + operation: mockOperations.executeSsmGetAutomationExecution, + }, + { + toolId: 'ssm_stop_automation_execution', + input: { ...CONNECTION, automationExecutionId: COMMAND_ID }, + operation: mockOperations.executeSsmStopAutomationExecution, + }, + { + toolId: 'ssm_list_documents', + input: CONNECTION, + operation: mockOperations.executeSsmListDocuments, + }, + { + toolId: 'ssm_get_document', + input: { ...CONNECTION, name: 'AWS-RunShellScript' }, + operation: mockOperations.executeSsmGetDocument, + }, +] as const + +describe('executeSsmTool', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each(TOOL_CASES)('validates and dispatches $toolId', async ({ toolId, input, operation }) => { + const controller = new AbortController() + operation.mockResolvedValue({ toolId }) + + const response = await executeSsmTool( + createRequest({ toolId, input, signal: controller.signal }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ toolId }) + expect(operation).toHaveBeenCalledWith(input, controller.signal) + }) + + it('returns the route-compatible validation envelope before provider work', async () => { + const response = await executeSsmTool(createRequest({ input: { region: 'invalid' } })) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: 'Invalid request data', + details: expect.any(Array), + }) + expect(mockOperations.executeSsmListCommands).not.toHaveBeenCalled() + }) + + it('rejects a malformed instance ID before calling the provider', async () => { + const response = await executeSsmTool( + createRequest({ + toolId: 'ssm_get_command_invocation', + input: { ...CONNECTION, commandId: COMMAND_ID, instanceId: 'not-an-instance' }, + }) + ) + + expect(response.status).toBe(400) + expect(mockOperations.executeSsmGetCommandInvocation).not.toHaveBeenCalled() + }) + + it('rejects a hierarchy path without the leading slash AWS requires', async () => { + const response = await executeSsmTool( + createRequest({ + toolId: 'ssm_get_parameters_by_path', + input: { ...CONNECTION, path: 'prod/app' }, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + details: expect.arrayContaining([ + expect.objectContaining({ + message: 'path must start with a forward slash (e.g., /prod/app)', + }), + ]), + }) + expect(mockOperations.executeSsmGetParametersByPath).not.toHaveBeenCalled() + }) + + it('accepts a hierarchy path that starts with a slash', async () => { + mockOperations.executeSsmGetParametersByPath.mockResolvedValue({ parameters: [] }) + + const response = await executeSsmTool( + createRequest({ + toolId: 'ssm_get_parameters_by_path', + input: { ...CONNECTION, path: '/prod/app' }, + }) + ) + + expect(response.status).toBe(200) + expect(mockOperations.executeSsmGetParametersByPath).toHaveBeenCalled() + }) + + it('rejects an unsupported tool id', async () => { + const response = await executeSsmTool(createRequest({ toolId: 'ssm_not_a_tool' })) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: 'Unsupported Systems Manager tool: ssm_not_a_tool', + }) + }) + + describe('Parameter Store secret handling', () => { + it('keeps a decrypted parameter value out of the failure envelope', async () => { + const secret = 'super-secret-database-password' + mockOperations.executeSsmGetParameter.mockRejectedValue( + new Error('AccessDeniedException: not authorized to perform ssm:GetParameter') + ) + + const response = await executeSsmTool( + createRequest({ + toolId: 'ssm_get_parameter', + input: { ...CONNECTION, name: '/prod/app/db-password', withDecryption: true }, + }) + ) + + expect(response.status).toBe(500) + const body = await response.text() + expect(body).not.toContain(secret) + expect(body).not.toContain(CONNECTION.secretAccessKey) + expect(body).toContain('Failed to get parameter') + }) + + it('keeps the written value out of the put_parameter failure envelope', async () => { + const secret = 'postgres://user:hunter2@db.example.com/app' + mockOperations.executeSsmPutParameter.mockRejectedValue( + new Error('ParameterAlreadyExists: the parameter already exists') + ) + + const response = await executeSsmTool( + createRequest({ + toolId: 'ssm_put_parameter', + input: { + ...CONNECTION, + name: '/prod/app/database-url', + value: secret, + type: 'SecureString', + }, + }) + ) + + expect(response.status).toBe(500) + const body = await response.text() + expect(body).not.toContain(secret) + expect(body).not.toContain('hunter2') + }) + + it('does not decrypt unless the caller opts in', async () => { + mockOperations.executeSsmGetParameter.mockResolvedValue({ name: '/prod/app/db-password' }) + + await executeSsmTool( + createRequest({ + toolId: 'ssm_get_parameter', + input: { ...CONNECTION, name: '/prod/app/db-password' }, + }) + ) + + const [passedInput] = mockOperations.executeSsmGetParameter.mock.calls[0] + expect(passedInput.withDecryption).toBeUndefined() + }) + }) +}) diff --git a/apps/sim/lib/internal/ssm/execute-tool.ts b/apps/sim/lib/internal/ssm/execute-tool.ts new file mode 100644 index 00000000000..51abfb1e665 --- /dev/null +++ b/apps/sim/lib/internal/ssm/execute-tool.ts @@ -0,0 +1,261 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { AnyApiRouteContract, ContractBody } from '@/lib/api/contracts' +import { awsSsmCancelCommandContract } from '@/lib/api/contracts/tools/aws/ssm-cancel-command' +import { awsSsmDeleteParameterContract } from '@/lib/api/contracts/tools/aws/ssm-delete-parameter' +import { awsSsmDescribeAutomationExecutionsContract } from '@/lib/api/contracts/tools/aws/ssm-describe-automation-executions' +import { awsSsmDescribeInstanceInformationContract } from '@/lib/api/contracts/tools/aws/ssm-describe-instance-information' +import { awsSsmDescribeInstancePatchStatesContract } from '@/lib/api/contracts/tools/aws/ssm-describe-instance-patch-states' +import { awsSsmDescribeInstancePatchesContract } from '@/lib/api/contracts/tools/aws/ssm-describe-instance-patches' +import { awsSsmDescribeParametersContract } from '@/lib/api/contracts/tools/aws/ssm-describe-parameters' +import { awsSsmGetAutomationExecutionContract } from '@/lib/api/contracts/tools/aws/ssm-get-automation-execution' +import { awsSsmGetCommandInvocationContract } from '@/lib/api/contracts/tools/aws/ssm-get-command-invocation' +import { awsSsmGetDocumentContract } from '@/lib/api/contracts/tools/aws/ssm-get-document' +import { awsSsmGetParameterContract } from '@/lib/api/contracts/tools/aws/ssm-get-parameter' +import { awsSsmGetParametersContract } from '@/lib/api/contracts/tools/aws/ssm-get-parameters' +import { awsSsmGetParametersByPathContract } from '@/lib/api/contracts/tools/aws/ssm-get-parameters-by-path' +import { awsSsmListCommandInvocationsContract } from '@/lib/api/contracts/tools/aws/ssm-list-command-invocations' +import { awsSsmListCommandsContract } from '@/lib/api/contracts/tools/aws/ssm-list-commands' +import { awsSsmListComplianceItemsContract } from '@/lib/api/contracts/tools/aws/ssm-list-compliance-items' +import { awsSsmListComplianceSummariesContract } from '@/lib/api/contracts/tools/aws/ssm-list-compliance-summaries' +import { awsSsmListDocumentsContract } from '@/lib/api/contracts/tools/aws/ssm-list-documents' +import { awsSsmPutParameterContract } from '@/lib/api/contracts/tools/aws/ssm-put-parameter' +import { awsSsmSendCommandContract } from '@/lib/api/contracts/tools/aws/ssm-send-command' +import { awsSsmStartAutomationExecutionContract } from '@/lib/api/contracts/tools/aws/ssm-start-automation-execution' +import { awsSsmStopAutomationExecutionContract } from '@/lib/api/contracts/tools/aws/ssm-stop-automation-execution' +import { + executeSsmCancelCommand, + executeSsmDeleteParameter, + executeSsmDescribeAutomationExecutions, + executeSsmDescribeInstanceInformation, + executeSsmDescribeInstancePatches, + executeSsmDescribeInstancePatchStates, + executeSsmDescribeParameters, + executeSsmGetAutomationExecution, + executeSsmGetCommandInvocation, + executeSsmGetDocument, + executeSsmGetParameter, + executeSsmGetParameters, + executeSsmGetParametersByPath, + executeSsmListCommandInvocations, + executeSsmListCommands, + executeSsmListComplianceItems, + executeSsmListComplianceSummaries, + executeSsmListDocuments, + executeSsmPutParameter, + executeSsmSendCommand, + executeSsmStartAutomationExecution, + executeSsmStopAutomationExecution, +} from '@/lib/internal/ssm/operations' +import { parseInternalToolInput } from '@/lib/internal/tool-operations/parse-input' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +async function executeOperation( + contract: C, + input: unknown, + execute: (input: ContractBody, signal?: AbortSignal) => Promise, + errorMessage: string, + signal?: AbortSignal +): Promise { + const parsed = parseInternalToolInput(contract, input) + if (!parsed.success) return parsed.response + + try { + const result = await execute(parsed.data, signal) + signal?.throwIfAborted() + return Response.json(result) + } catch (error) { + signal?.throwIfAborted() + return Response.json( + { error: `${errorMessage}: ${getErrorMessage(error, 'Unknown error occurred')}` }, + { status: 500 } + ) + } +} + +export const executeSsmTool: InternalToolOperationHandler = async ({ toolId, input, signal }) => { + signal?.throwIfAborted() + + switch (toolId) { + case 'ssm_send_command': + return executeOperation( + awsSsmSendCommandContract, + input, + executeSsmSendCommand, + 'Failed to send command', + signal + ) + case 'ssm_list_commands': + return executeOperation( + awsSsmListCommandsContract, + input, + executeSsmListCommands, + 'Failed to list commands', + signal + ) + case 'ssm_list_command_invocations': + return executeOperation( + awsSsmListCommandInvocationsContract, + input, + executeSsmListCommandInvocations, + 'Failed to list command invocations', + signal + ) + case 'ssm_get_command_invocation': + return executeOperation( + awsSsmGetCommandInvocationContract, + input, + executeSsmGetCommandInvocation, + 'Failed to get command invocation', + signal + ) + case 'ssm_cancel_command': + return executeOperation( + awsSsmCancelCommandContract, + input, + executeSsmCancelCommand, + 'Failed to cancel command', + signal + ) + case 'ssm_get_parameter': + return executeOperation( + awsSsmGetParameterContract, + input, + executeSsmGetParameter, + 'Failed to get parameter', + signal + ) + case 'ssm_get_parameters': + return executeOperation( + awsSsmGetParametersContract, + input, + executeSsmGetParameters, + 'Failed to get parameters', + signal + ) + case 'ssm_get_parameters_by_path': + return executeOperation( + awsSsmGetParametersByPathContract, + input, + executeSsmGetParametersByPath, + 'Failed to get parameters by path', + signal + ) + case 'ssm_put_parameter': + return executeOperation( + awsSsmPutParameterContract, + input, + executeSsmPutParameter, + 'Failed to put parameter', + signal + ) + case 'ssm_delete_parameter': + return executeOperation( + awsSsmDeleteParameterContract, + input, + executeSsmDeleteParameter, + 'Failed to delete parameter', + signal + ) + case 'ssm_describe_parameters': + return executeOperation( + awsSsmDescribeParametersContract, + input, + executeSsmDescribeParameters, + 'Failed to describe parameters', + signal + ) + case 'ssm_describe_instance_information': + return executeOperation( + awsSsmDescribeInstanceInformationContract, + input, + executeSsmDescribeInstanceInformation, + 'Failed to describe instance information', + signal + ) + case 'ssm_describe_instance_patches': + return executeOperation( + awsSsmDescribeInstancePatchesContract, + input, + executeSsmDescribeInstancePatches, + 'Failed to describe instance patches', + signal + ) + case 'ssm_describe_instance_patch_states': + return executeOperation( + awsSsmDescribeInstancePatchStatesContract, + input, + executeSsmDescribeInstancePatchStates, + 'Failed to describe instance patch states', + signal + ) + case 'ssm_list_compliance_items': + return executeOperation( + awsSsmListComplianceItemsContract, + input, + executeSsmListComplianceItems, + 'Failed to list compliance items', + signal + ) + case 'ssm_list_compliance_summaries': + return executeOperation( + awsSsmListComplianceSummariesContract, + input, + executeSsmListComplianceSummaries, + 'Failed to list compliance summaries', + signal + ) + case 'ssm_start_automation_execution': + return executeOperation( + awsSsmStartAutomationExecutionContract, + input, + executeSsmStartAutomationExecution, + 'Failed to start automation execution', + signal + ) + case 'ssm_describe_automation_executions': + return executeOperation( + awsSsmDescribeAutomationExecutionsContract, + input, + executeSsmDescribeAutomationExecutions, + 'Failed to describe automation executions', + signal + ) + case 'ssm_get_automation_execution': + return executeOperation( + awsSsmGetAutomationExecutionContract, + input, + executeSsmGetAutomationExecution, + 'Failed to get automation execution', + signal + ) + case 'ssm_stop_automation_execution': + return executeOperation( + awsSsmStopAutomationExecutionContract, + input, + executeSsmStopAutomationExecution, + 'Failed to stop automation execution', + signal + ) + case 'ssm_list_documents': + return executeOperation( + awsSsmListDocumentsContract, + input, + executeSsmListDocuments, + 'Failed to list documents', + signal + ) + case 'ssm_get_document': + return executeOperation( + awsSsmGetDocumentContract, + input, + executeSsmGetDocument, + 'Failed to get document', + signal + ) + default: + return Response.json( + { error: `Unsupported Systems Manager tool: ${toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/ssm/operations.ts b/apps/sim/lib/internal/ssm/operations.ts new file mode 100644 index 00000000000..350be48fdac --- /dev/null +++ b/apps/sim/lib/internal/ssm/operations.ts @@ -0,0 +1,296 @@ +import type { AwsSsmCancelCommandBody } from '@/lib/api/contracts/tools/aws/ssm-cancel-command' +import type { AwsSsmDeleteParameterBody } from '@/lib/api/contracts/tools/aws/ssm-delete-parameter' +import type { AwsSsmDescribeAutomationExecutionsBody } from '@/lib/api/contracts/tools/aws/ssm-describe-automation-executions' +import type { AwsSsmDescribeInstanceInformationBody } from '@/lib/api/contracts/tools/aws/ssm-describe-instance-information' +import type { AwsSsmDescribeInstancePatchStatesBody } from '@/lib/api/contracts/tools/aws/ssm-describe-instance-patch-states' +import type { AwsSsmDescribeInstancePatchesBody } from '@/lib/api/contracts/tools/aws/ssm-describe-instance-patches' +import type { AwsSsmDescribeParametersBody } from '@/lib/api/contracts/tools/aws/ssm-describe-parameters' +import type { AwsSsmGetAutomationExecutionBody } from '@/lib/api/contracts/tools/aws/ssm-get-automation-execution' +import type { AwsSsmGetCommandInvocationBody } from '@/lib/api/contracts/tools/aws/ssm-get-command-invocation' +import type { AwsSsmGetDocumentBody } from '@/lib/api/contracts/tools/aws/ssm-get-document' +import type { AwsSsmGetParameterBody } from '@/lib/api/contracts/tools/aws/ssm-get-parameter' +import type { AwsSsmGetParametersBody } from '@/lib/api/contracts/tools/aws/ssm-get-parameters' +import type { AwsSsmGetParametersByPathBody } from '@/lib/api/contracts/tools/aws/ssm-get-parameters-by-path' +import type { AwsSsmListCommandInvocationsBody } from '@/lib/api/contracts/tools/aws/ssm-list-command-invocations' +import type { AwsSsmListCommandsBody } from '@/lib/api/contracts/tools/aws/ssm-list-commands' +import type { AwsSsmListComplianceItemsBody } from '@/lib/api/contracts/tools/aws/ssm-list-compliance-items' +import type { AwsSsmListComplianceSummariesBody } from '@/lib/api/contracts/tools/aws/ssm-list-compliance-summaries' +import type { AwsSsmListDocumentsBody } from '@/lib/api/contracts/tools/aws/ssm-list-documents' +import type { AwsSsmPutParameterBody } from '@/lib/api/contracts/tools/aws/ssm-put-parameter' +import type { AwsSsmSendCommandBody } from '@/lib/api/contracts/tools/aws/ssm-send-command' +import type { AwsSsmStartAutomationExecutionBody } from '@/lib/api/contracts/tools/aws/ssm-start-automation-execution' +import type { AwsSsmStopAutomationExecutionBody } from '@/lib/api/contracts/tools/aws/ssm-stop-automation-execution' +import { + cancelCommand, + createSsmClient, + deleteParameter, + describeAutomationExecutions, + describeInstanceInformation, + describeInstancePatches, + describeInstancePatchStates, + describeParameters, + getAutomationExecution, + getCommandInvocation, + getDocument, + getParameter, + getParameters, + getParametersByPath, + listCommandInvocations, + listCommands, + listComplianceItems, + listComplianceSummaries, + listDocuments, + putParameter, + sendCommand, + startAutomationExecution, + stopAutomationExecution, +} from '@/lib/internal/ssm/client' + +export async function executeSsmSendCommand(input: AwsSsmSendCommandBody, signal?: AbortSignal) { + const client = createSsmClient(input) + try { + return await sendCommand(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmListCommands(input: AwsSsmListCommandsBody, signal?: AbortSignal) { + const client = createSsmClient(input) + try { + return await listCommands(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmListCommandInvocations( + input: AwsSsmListCommandInvocationsBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await listCommandInvocations(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmGetCommandInvocation( + input: AwsSsmGetCommandInvocationBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await getCommandInvocation(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmCancelCommand( + input: AwsSsmCancelCommandBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await cancelCommand(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmGetParameter(input: AwsSsmGetParameterBody, signal?: AbortSignal) { + const client = createSsmClient(input) + try { + return await getParameter(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmGetParameters( + input: AwsSsmGetParametersBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await getParameters(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmGetParametersByPath( + input: AwsSsmGetParametersByPathBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await getParametersByPath(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmPutParameter(input: AwsSsmPutParameterBody, signal?: AbortSignal) { + const client = createSsmClient(input) + try { + return await putParameter(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmDeleteParameter( + input: AwsSsmDeleteParameterBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await deleteParameter(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmDescribeParameters( + input: AwsSsmDescribeParametersBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await describeParameters(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmDescribeInstanceInformation( + input: AwsSsmDescribeInstanceInformationBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await describeInstanceInformation(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmDescribeInstancePatches( + input: AwsSsmDescribeInstancePatchesBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await describeInstancePatches(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmDescribeInstancePatchStates( + input: AwsSsmDescribeInstancePatchStatesBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await describeInstancePatchStates(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmListComplianceItems( + input: AwsSsmListComplianceItemsBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await listComplianceItems(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmListComplianceSummaries( + input: AwsSsmListComplianceSummariesBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await listComplianceSummaries(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmStartAutomationExecution( + input: AwsSsmStartAutomationExecutionBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await startAutomationExecution(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmDescribeAutomationExecutions( + input: AwsSsmDescribeAutomationExecutionsBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await describeAutomationExecutions(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmGetAutomationExecution( + input: AwsSsmGetAutomationExecutionBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await getAutomationExecution(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmStopAutomationExecution( + input: AwsSsmStopAutomationExecutionBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await stopAutomationExecution(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmListDocuments( + input: AwsSsmListDocumentsBody, + signal?: AbortSignal +) { + const client = createSsmClient(input) + try { + return await listDocuments(client, input, signal) + } finally { + client.destroy() + } +} + +export async function executeSsmGetDocument(input: AwsSsmGetDocumentBody, signal?: AbortSignal) { + const client = createSsmClient(input) + try { + return await getDocument(client, input, signal) + } finally { + client.destroy() + } +} diff --git a/apps/sim/lib/internal/tool-operations/registry.server.ts b/apps/sim/lib/internal/tool-operations/registry.server.ts index 45bb6cb5cd4..b683b447938 100644 --- a/apps/sim/lib/internal/tool-operations/registry.server.ts +++ b/apps/sim/lib/internal/tool-operations/registry.server.ts @@ -52,8 +52,10 @@ const IAM_TOOL_IDS = [ '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', @@ -62,6 +64,7 @@ const IAM_TOOL_IDS = [ 'iam_list_users', 'iam_remove_user_from_group', 'iam_simulate_principal_policy', + 'iam_update_access_key', ] as const const IDENTITY_CENTER_TOOL_IDS = [ @@ -77,6 +80,10 @@ const IDENTITY_CENTER_TOOL_IDS = [ 'identity_center_check_assignment_status', 'identity_center_check_assignment_deletion_status', 'identity_center_list_account_assignments', + 'identity_center_list_assignments_for_account', + 'identity_center_describe_user', + 'identity_center_describe_group', + 'identity_center_list_group_memberships', ] as const const SECRETS_MANAGER_TOOL_IDS = [ @@ -92,6 +99,31 @@ const SECRETS_MANAGER_TOOL_IDS = [ 'secrets_manager_rotate_secret', ] as const +const SSM_TOOL_IDS = [ + 'ssm_send_command', + 'ssm_list_commands', + 'ssm_list_command_invocations', + 'ssm_get_command_invocation', + 'ssm_cancel_command', + 'ssm_get_parameter', + 'ssm_get_parameters', + 'ssm_get_parameters_by_path', + 'ssm_put_parameter', + 'ssm_delete_parameter', + 'ssm_describe_parameters', + 'ssm_describe_instance_information', + 'ssm_describe_instance_patches', + 'ssm_describe_instance_patch_states', + 'ssm_list_compliance_items', + 'ssm_list_compliance_summaries', + 'ssm_start_automation_execution', + 'ssm_describe_automation_executions', + 'ssm_get_automation_execution', + 'ssm_stop_automation_execution', + 'ssm_list_documents', + 'ssm_get_document', +] as const + const DYNAMODB_TOOL_IDS = [ 'dynamodb_delete', 'dynamodb_get', @@ -124,7 +156,29 @@ const SES_TOOL_IDS = [ 'ses_update_template', ] as const -const SQS_TOOL_IDS = ['sqs_send'] as const +const SQS_TOOL_IDS = [ + 'sqs_send', + 'sqs_send_message_batch', + 'sqs_receive_message', + 'sqs_delete_message', + 'sqs_delete_message_batch', + 'sqs_change_message_visibility', + 'sqs_change_message_visibility_batch', + 'sqs_list_queues', + 'sqs_get_queue_url', + 'sqs_get_queue_attributes', + 'sqs_set_queue_attributes', + 'sqs_create_queue', + 'sqs_delete_queue', + 'sqs_purge_queue', + 'sqs_list_dead_letter_source_queues', + 'sqs_list_queue_tags', + 'sqs_tag_queue', + 'sqs_untag_queue', + 'sqs_start_message_move_task', + 'sqs_list_message_move_tasks', + 'sqs_cancel_message_move_task', +] as const const RDS_TOOL_IDS = [ 'rds_query', @@ -142,6 +196,23 @@ const TEXTRACT_TOOL_IDS = [ 'textract_analyze_id', ] as const +const CLOUDTRAIL_TOOL_IDS = [ + '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', +] as const + const CLOUDWATCH_TOOL_IDS = [ 'cloudwatch_describe_alarm_history', 'cloudwatch_describe_alarms', @@ -694,6 +765,24 @@ const DOCUSIGN_TOOL_IDS = [ 'docusign_void_envelope', ] as const +const QUICKBOOKS_TOOL_IDS = [ + 'quickbooks_add_attachment', + 'quickbooks_create_bill_payment', + 'quickbooks_download_attachment', + 'quickbooks_download_transaction_pdf', + 'quickbooks_update_bill', + 'quickbooks_update_bill_payment', + 'quickbooks_update_credit_memo', + 'quickbooks_update_customer_payment', + 'quickbooks_update_employee', + 'quickbooks_update_item', + 'quickbooks_update_purchase', + 'quickbooks_update_purchase_order', + 'quickbooks_update_refund_receipt', + 'quickbooks_update_vendor', + 'quickbooks_update_vendor_credit', +] as const + const THINKING_TOOL_IDS = ['thinking_tool'] as const const BITBUCKET_TOOL_IDS = [ @@ -1225,6 +1314,7 @@ const EMBEDDINGS_TOOL_IDS = [ 'embeddings_gemini', 'embeddings_cohere', 'embeddings_mistral', + 'embeddings_ollama', 'openai_embeddings', ] as const @@ -1302,6 +1392,9 @@ registerFamily(handlerLoaders, IDENTITY_CENTER_TOOL_IDS, async () => { registerFamily(handlerLoaders, SECRETS_MANAGER_TOOL_IDS, async () => { return (await import('@/lib/internal/secrets-manager/execute-tool')).executeSecretsManagerTool }) +registerFamily(handlerLoaders, SSM_TOOL_IDS, async () => { + return (await import('@/lib/internal/ssm/execute-tool')).executeSsmTool +}) registerFamily(handlerLoaders, DYNAMODB_TOOL_IDS, async () => { return (await import('@/lib/internal/dynamodb/execute-tool')).executeDynamodbTool }) @@ -1320,6 +1413,9 @@ registerFamily(handlerLoaders, TEXTRACT_TOOL_IDS, async () => { registerFamily(handlerLoaders, CLOUDWATCH_TOOL_IDS, async () => { return (await import('@/lib/internal/cloudwatch/execute-tool')).executeCloudwatchTool }) +registerFamily(handlerLoaders, CLOUDTRAIL_TOOL_IDS, async () => { + return (await import('@/lib/internal/cloudtrail/execute-tool')).executeCloudtrailTool +}) registerFamily(handlerLoaders, POSTGRESQL_TOOL_IDS, async () => { return (await import('@/lib/internal/postgresql/execute-tool')).executePostgresqlTool }) @@ -1401,6 +1497,9 @@ registerFamily(handlerLoaders, ASANA_TOOL_IDS, async () => { registerFamily(handlerLoaders, DOCUSIGN_TOOL_IDS, async () => { return (await import('@/lib/internal/docusign/execute-tool')).executeDocuSignTool }) +registerFamily(handlerLoaders, QUICKBOOKS_TOOL_IDS, async () => { + return (await import('@/lib/internal/quickbooks/execute-tool')).executeQuickBooksTool +}) registerFamily(handlerLoaders, THINKING_TOOL_IDS, async () => { return (await import('@/lib/internal/thinking/execute-tool')).executeThinkingTool }) diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts index 5f44cae43a1..bead72bf04f 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts @@ -76,8 +76,7 @@ vi.mock('@/lib/knowledge/application/folder-paths', () => ({ })) vi.mock('@/lib/knowledge/embeddings', () => ({ - EMBEDDING_DIMENSIONS: 1536, - getConfiguredEmbeddingModel: () => 'text-embedding-3-small', + getConfiguredKbEmbedding: () => ({ model: 'text-embedding-3-small', dimensions: 1536 }), })) vi.mock('@/lib/knowledge/service', () => ({ diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index 95d5f072452..2fe53227a69 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -45,7 +45,7 @@ import { DEFAULT_CHUNKING_CONFIG, MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE, } from '@/lib/knowledge/constants' -import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { getConfiguredKbEmbedding } from '@/lib/knowledge/embeddings' import { getRestorableKnowledgeBase, performDeleteKnowledgeBase, @@ -315,6 +315,7 @@ async function executeCreateKnowledgeBase(args: { ...DEFAULT_CHUNKING_CONFIG, ...args.input.chunkingConfig, } + const { model: embeddingModel, dimensions: embeddingDimension } = await getConfiguredKbEmbedding() const knowledgeBase = await createAuthorizedKnowledgeBase( { name: args.input.name, @@ -322,8 +323,8 @@ async function executeCreateKnowledgeBase(args: { workspaceId: args.context.workspaceId, folderId, userId: resolveKnowledgeAttributedUserId(args.principal, args.context), - embeddingModel: getConfiguredEmbeddingModel(), - embeddingDimension: EMBEDDING_DIMENSIONS, + embeddingModel, + embeddingDimension, chunkingConfig, }, generateRequestId() diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index e5566f681bc..f5afce0d3fa 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -96,6 +96,7 @@ const knowledgeBase = { name: 'Docs', workspaceId: 'workspace-1', embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, } describe('knowledge search application use case', () => { diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index ae87287303b..000e0758587 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -34,8 +34,8 @@ import { } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' -import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' -import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' +import { getEmbeddingModelInfo, toKbEmbeddingDimensions } from '@/lib/knowledge/embedding-models' +import { generateSearchEmbedding, type KbEmbeddingTarget } from '@/lib/knowledge/embeddings' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' import { rerank } from '@/lib/knowledge/reranker' import type { RerankerStatus } from '@/lib/knowledge/reranker-models' @@ -274,21 +274,48 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ definitionsByKnowledgeBase = built.definitionsByKnowledgeBase } - const embeddingModels = [...new Set(context.knowledgeBases.map((kb) => kb.embeddingModel))] - if (hasQuery && embeddingModels.length > 1) { + /** + * One query embedding serves every leg, so every base in the request has to + * be indexed the same way. The width is part of that: it selects the + * pgvector column each comparison reads, and two bases on the same model at + * different widths still live in different columns. + * + * Built only for a query search. A tag-only request never embeds anything, + * so resolving a width it will not use would let one base recorded at an + * unstorable width fail a request that does not depend on it. + */ + const embeddingTargets = new Map( + context.knowledgeBases.map((kb) => [ + `${kb.embeddingModel}:${kb.embeddingDimension}`, + { model: kb.embeddingModel, dimensions: kb.embeddingDimension }, + ]) + ) + if (hasQuery && embeddingTargets.size > 1) { throw new OrchestrationError( 'validation', - 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' + 'Selected knowledge bases use different embedding models or vector widths and cannot be searched together. Search them separately.' ) } - const embeddingModel = embeddingModels[0] + const selectedTarget = [...embeddingTargets.values()][0] + const embeddingModel = selectedTarget.model + /** + * The width is narrowed to a storable one only for a query search, which is + * the only kind that reads a vector column. A tag-only search must not fail + * on a width it never uses. + */ + const embeddingTarget: KbEmbeddingTarget | undefined = hasQuery + ? { + model: selectedTarget.model, + dimensions: toKbEmbeddingDimensions(selectedTarget.dimensions), + } + : undefined const preparedRegistry = input.prepareModelInputProvenance ? await input.prepareModelInputProvenance({ userId, workspaceId: context.workspaceId }) : undefined const resultSecretRegistry = preparedRegistry ?? input.resultSecretRegistry const queryEmbeddingPromise = hasQuery ? runWithKnowledgeModelInputProvenance(resultSecretRegistry, () => - generateSearchEmbedding(input.query!, embeddingModel, context.workspaceId) + generateSearchEmbedding(input.query!, embeddingTarget!, context.workspaceId) ) : Promise.resolve(null) /** Resolved alongside the embedding call; both are needed before the first leg runs. */ @@ -317,7 +344,10 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ boostRecency: searchDefaults.boostRecency, query: input.query, queryVector: hasQuery - ? JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null) + ? { + vector: JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null), + dimensions: embeddingTarget!.dimensions, + } : undefined, structuredFilters: structuredFilters.length > 0 ? structuredFilters : undefined, }) diff --git a/apps/sim/lib/knowledge/chunks/service.ts b/apps/sim/lib/knowledge/chunks/service.ts index 6016be05265..fb19d830cbf 100644 --- a/apps/sim/lib/knowledge/chunks/service.ts +++ b/apps/sim/lib/knowledge/chunks/service.ts @@ -25,9 +25,10 @@ import type { ChunkSortBy, CreateChunkData, } from '@/lib/knowledge/chunks/types' -import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' -import { generateEmbeddings } from '@/lib/knowledge/embeddings' +import { getEmbeddingModelInfo, toKbEmbeddingDimensions } from '@/lib/knowledge/embedding-models' +import { generateEmbeddings, type KbEmbeddingTarget } from '@/lib/knowledge/embeddings' import { replaceKnowledgeEmbeddingSecretProvenanceInTx } from '@/lib/knowledge/secret-provenance' +import { embeddingVectorValues } from '@/lib/knowledge/vector-columns' import { estimateTokenCount } from '@/lib/tokenization/estimators' const logger = createLogger('ChunksService') @@ -196,19 +197,22 @@ export async function createChunk( ): Promise { logger.info(`[${requestId}] Generating embedding for manual chunk`) const kbRow = await db - .select({ embeddingModel: knowledgeBase.embeddingModel }) + .select({ + embeddingModel: knowledgeBase.embeddingModel, + embeddingDimension: knowledgeBase.embeddingDimension, + }) .from(knowledgeBase) .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (kbRow.length === 0) { throw new Error('Knowledge base not found') } - const kbEmbeddingModel = kbRow[0].embeddingModel - const { embeddings } = await generateEmbeddings( - [chunkData.content], - kbEmbeddingModel, - workspaceId - ) + const kbEmbedding: KbEmbeddingTarget = { + model: kbRow[0].embeddingModel, + dimensions: toKbEmbeddingDimensions(kbRow[0].embeddingDimension), + } + const kbEmbeddingModel = kbEmbedding.model + const { embeddings } = await generateEmbeddings([chunkData.content], kbEmbedding, workspaceId) const tokenCount = estimateTokenCount( chunkData.content, @@ -264,7 +268,7 @@ export async function createChunk( secretProvenanceVersion: secretProvenance ? 1 : null, contentLength: chunkData.content.length, tokenCount: tokenCount.count, - embedding: embeddings[0], + ...embeddingVectorValues(kbEmbedding.dimensions, embeddings[0]), embeddingModel: kbEmbeddingModel, startOffset: 0, // Manual chunks don't have document offsets endOffset: chunkData.content.length, @@ -458,26 +462,36 @@ export async function updateChunk( // The embedding is a function of the new content alone, so generating it // outside the transaction is always valid. - let regenerated: { embedding: number[]; tokenCount: number } | null = null + let regenerated: { + vectorValues: ReturnType + tokenCount: number + } | null = null if (content !== preRead.content) { const kbRow = await db - .select({ embeddingModel: knowledgeBase.embeddingModel }) + .select({ + embeddingModel: knowledgeBase.embeddingModel, + embeddingDimension: knowledgeBase.embeddingDimension, + }) .from(knowledgeBase) .innerJoin(document, eq(document.knowledgeBaseId, knowledgeBase.id)) .where(eq(document.id, preRead.documentId)) .limit(1) - const chunkEmbeddingModel = kbRow[0]?.embeddingModel - if (!chunkEmbeddingModel) { + const kbEmbeddingRow = kbRow[0] + if (!kbEmbeddingRow) { throw new Error('Knowledge base for chunk not found') } + const chunkEmbedding: KbEmbeddingTarget = { + model: kbEmbeddingRow.embeddingModel, + dimensions: toKbEmbeddingDimensions(kbEmbeddingRow.embeddingDimension), + } logger.info(`[${requestId}] Content changed, regenerating embedding for chunk ${chunkId}`) - const { embeddings } = await generateEmbeddings([content], chunkEmbeddingModel, workspaceId) + const { embeddings } = await generateEmbeddings([content], chunkEmbedding, workspaceId) regenerated = { - embedding: embeddings[0], + vectorValues: embeddingVectorValues(chunkEmbedding.dimensions, embeddings[0]), tokenCount: estimateTokenCount( content, - getEmbeddingModelInfo(chunkEmbeddingModel).tokenizerProvider + getEmbeddingModelInfo(chunkEmbedding.model).tokenizerProvider ).count, } } @@ -518,7 +532,7 @@ export async function updateChunk( chunkHash: sha256Hex(content), tokenCount: regenerated ? regenerated.tokenCount : oldTokenCount, secretProvenanceVersion: secretProvenance ? 1 : currentChunk[0].secretProvenanceVersion, - ...(regenerated ? { embedding: regenerated.embedding } : {}), + ...(regenerated ? regenerated.vectorValues : {}), ...(updateData.enabled !== undefined ? { enabled: updateData.enabled } : {}), } diff --git a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts index ed540e42a0e..0ffe80a0ad2 100644 --- a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts +++ b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts @@ -42,7 +42,8 @@ vi.mock('@/lib/knowledge/documents/document-processor', () => ({ })) vi.mock('@/lib/knowledge/embedding-models', () => ({ - EMBEDDING_DIMENSIONS: 1536, + MAX_KB_EMBEDDING_DIMENSIONS: 3072, + toKbEmbeddingDimensions: (value: number) => value, getEmbeddingModelInfo: vi.fn(() => ({ tokenizerProvider: 'openai' })), })) @@ -81,6 +82,7 @@ const PERSISTED_CONTEXT = { knowledgeBaseUserId: 'knowledge-owner', chunkingConfig: null, embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, billedAccountUserId: null, uploadedBy: 'uploader-1', filename: 'persisted.pdf', 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 435c0c38116..1cb5df8ac67 100644 --- a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts @@ -46,7 +46,8 @@ vi.mock('@/lib/knowledge/documents/document-processor', () => ({ })) vi.mock('@/lib/knowledge/embedding-models', () => ({ - EMBEDDING_DIMENSIONS: 1536, + MAX_KB_EMBEDDING_DIMENSIONS: 3072, + toKbEmbeddingDimensions: (value: number) => value, getEmbeddingModelInfo: mockGetEmbeddingModelInfo, })) @@ -95,6 +96,7 @@ const PERSISTED_CONTEXT = { knowledgeBaseUserId: 'knowledge-owner', chunkingConfig: null, embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, billedAccountUserId: null, uploadedBy: 'uploader-1', filename: 'persisted.pdf', diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index cab2ea2f4f7..077f23e8605 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -121,8 +121,12 @@ import { QUEUED_DISPATCH_GRACE_MS, type SortOrder, } from '@/lib/knowledge/documents/types' -import { EMBEDDING_DIMENSIONS, getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' -import { generateEmbeddings } from '@/lib/knowledge/embeddings' +import { + getEmbeddingModelInfo, + MAX_KB_EMBEDDING_DIMENSIONS, + toKbEmbeddingDimensions, +} from '@/lib/knowledge/embedding-models' +import { generateEmbeddings, type KbEmbeddingTarget } from '@/lib/knowledge/embeddings' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' import { bindKnowledgeDocumentFieldSecretProvenance, @@ -143,6 +147,7 @@ import { validateTagValue, } from '@/lib/knowledge/tags/utils' import type { ProcessedDocumentTags } from '@/lib/knowledge/types' +import { embeddingVectorValues } from '@/lib/knowledge/vector-columns' import { estimateTokenCount } from '@/lib/tokenization/estimators' import { getBoundWorkspaceFileSecretProvenanceByMetadata, @@ -318,9 +323,17 @@ const TIMEOUTS = { const LARGE_DOC_CONFIG = { MAX_CHUNKS_PER_BATCH: 500, + /** + * One module-level constant serves every knowledge base, whose width is not + * known here, so it is sized for the widest one that can exist. The item + * ceiling falls as the width grows — 2,126 items at 1,536 but 1,064 at 3,072 — + * and `generateEmbeddings` *rejects* an oversized batch rather than splitting + * it, so sizing this against the default width would fail every 3,072-wide + * document past that many chunks. + */ MAX_EMBEDDING_BATCH: Math.min( envNumber(env.KB_CONFIG_BATCH_SIZE, 2000, { min: 1, integer: true }), - getEmbeddingAggregateItemLimit(EMBEDDING_DIMENSIONS) + getEmbeddingAggregateItemLimit(MAX_KB_EMBEDDING_DIMENSIONS) ), MAX_FILE_SIZE: 100 * 1024 * 1024, } @@ -1393,6 +1406,7 @@ export async function processDocumentAsync( knowledgeBaseUserId: knowledgeBase.userId, chunkingConfig: knowledgeBase.chunkingConfig, embeddingModel: knowledgeBase.embeddingModel, + embeddingDimension: knowledgeBase.embeddingDimension, billedAccountUserId: workspaceTable.billedAccountUserId, uploadedBy: document.uploadedBy, connectorId: document.connectorId, @@ -1530,7 +1544,11 @@ export async function processDocumentAsync( overlap: rawConfig?.overlap ?? 200, } - const kbEmbeddingModel = ctx.embeddingModel + const kbEmbedding: KbEmbeddingTarget = { + model: ctx.embeddingModel, + dimensions: toKbEmbeddingDimensions(ctx.embeddingDimension), + } + const kbEmbeddingModel = kbEmbedding.model const queuedBillingContext = hasDocumentProcessingBillingScope(providedBillingContext) ? assertDocumentProcessingBillingContext(providedBillingContext) : undefined @@ -1652,7 +1670,7 @@ export async function processDocumentAsync( billableTokens: batchBillableTokens, modelName, pricingId, - } = await generateEmbeddings(batch, kbEmbeddingModel, ctx.workspaceId) + } = await generateEmbeddings(batch, kbEmbedding, ctx.workspaceId) for (const emb of batchEmbeddings) { embeddings.push(emb) } @@ -1664,6 +1682,17 @@ export async function processDocumentAsync( } } + /** + * Every chunk must carry a vector. The row's width column would + * otherwise be NULL and `embedding_width_check` would reject the whole + * batch, naming a constraint rather than the chunk that went missing. + */ + if (embeddings.length !== processed.chunks.length) { + throw new Error( + `Embedding generation returned ${embeddings.length} vectors for ${processed.chunks.length} chunks` + ) + } + // Tag values prefetched above; reuse for the embedding rows. const documentTags = ctx @@ -1688,7 +1717,7 @@ export async function processDocumentAsync( secretProvenanceVersion: chunkProvenances[chunkIndex] ? 1 : null, contentLength: chunk.text.length, tokenCount: estimateTokenCount(chunk.text, tokenizerProvider).count, - embedding: embeddings[chunkIndex] || null, + ...embeddingVectorValues(kbEmbedding.dimensions, embeddings[chunkIndex]), embeddingModel: kbEmbeddingModel, startOffset: chunk.metadata.startIndex, endOffset: chunk.metadata.endIndex, diff --git a/apps/sim/lib/knowledge/embedding-models.ts b/apps/sim/lib/knowledge/embedding-models.ts index 83dee5e3063..99fd9ea25a9 100644 --- a/apps/sim/lib/knowledge/embedding-models.ts +++ b/apps/sim/lib/knowledge/embedding-models.ts @@ -1,21 +1,35 @@ /** * Knowledge-base view over the platform embedding catalog - * (`@/lib/embeddings/catalog`). Knowledge bases store every vector at a fixed - * width, so only catalog models flagged `kbEligible` are selectable here. - * Selection happens server-side via the `KB_EMBEDDING_MODEL` env var; this - * module resolves provider, tokenizer, and pricing metadata at runtime for any - * model recorded on a knowledge base row. + * (`@/lib/embeddings/catalog`). A knowledge base stores every vector at one + * fixed width chosen at creation, so only catalog models that can emit one of + * the widths the `embedding` table has a column for are selectable here. + * Selection happens server-side via the `KB_EMBEDDING_MODEL` and + * `EMBEDDING_OUTPUT_DIMS` env vars; this module resolves provider, tokenizer, + * width, and pricing metadata at runtime for any model recorded on a knowledge + * base row. */ import { DEFAULT_EMBEDDING_MODEL as CATALOG_DEFAULT_EMBEDDING_MODEL, + DEFAULT_KB_EMBEDDING_DIMENSIONS, + findEmbeddingModelInfo, getEmbeddingModelInfo as getCatalogModelInfo, getKbEligibleModels, - KB_EMBEDDING_DIMENSIONS, + getKbEmbeddingDimensions, + isKbEmbeddingDimensions, + isOllamaEmbeddingModel, + KB_EMBEDDING_STORAGE_DIMENSIONS, + type KbEmbeddingDimensions, } from '@/lib/embeddings/catalog' import type { EmbeddingProviderKind, TokenizerProviderId } from '@/lib/embeddings/types' -export const EMBEDDING_DIMENSIONS = KB_EMBEDDING_DIMENSIONS +export type { KbEmbeddingDimensions } from '@/lib/embeddings/catalog' +export { + DEFAULT_KB_EMBEDDING_DIMENSIONS, + isKbEmbeddingDimensions, + KB_EMBEDDING_STORAGE_DIMENSIONS, + MAX_KB_EMBEDDING_DIMENSIONS, +} from '@/lib/embeddings/catalog' export const DEFAULT_EMBEDDING_MODEL = CATALOG_DEFAULT_EMBEDDING_MODEL @@ -29,39 +43,112 @@ export interface EmbeddingModelInfo { tokenizerProvider: TokenizerProviderId /** Maximum tokens accepted for one embedding input by the selected model. */ maxInputTokens: number + /** Widths a knowledge base using this model can be created at, largest first. */ + dimensions: KbEmbeddingDimensions[] +} + +function toKbModelInfo(model: string): EmbeddingModelInfo { + const info = getCatalogModelInfo(model) + return { + provider: info.provider, + pricingId: info.pricingId, + tokenizerProvider: info.tokenizerProvider, + maxInputTokens: info.maxInputTokens, + dimensions: getKbEmbeddingDimensions(info), + } } +/** + * Statically catalogued models selectable for knowledge-base indexing. Ollama + * models are absent by construction — they are whatever the operator pulled + * onto their own server — and resolve through {@link getEmbeddingModelInfo} + * instead. + */ export const SUPPORTED_EMBEDDING_MODELS: Partial> = Object.fromEntries( - getKbEligibleModels().map((id) => { - const info = getCatalogModelInfo(id) - return [ - id, - { - provider: info.provider, - pricingId: info.pricingId, - tokenizerProvider: info.tokenizerProvider, - maxInputTokens: info.maxInputTokens, - }, - ] - }) + getKbEligibleModels() + .map((id) => [id, toKbModelInfo(id)] as const) + .filter(([, info]) => info.dimensions.length > 0) ) /** - * Throws unless `model` is selectable for knowledge-base indexing. Call before - * handing a model to `embed()` so an ineligible id fails here, naming the - * knowledge-base constraint, rather than deeper in the provider path. + * Own-property lookup, not indexing: the record inherits from + * `Object.prototype`, so `SUPPORTED_EMBEDDING_MODELS['toString']` would + * otherwise resolve to an inherited function and read as a supported model. + */ +function findKbModelInfo(model: string): EmbeddingModelInfo | undefined { + return Object.hasOwn(SUPPORTED_EMBEDDING_MODELS, model) + ? SUPPORTED_EMBEDDING_MODELS[model] + : undefined +} + +/** True when `model` may be recorded on a knowledge base, whatever its width. */ +export function isKbEmbeddingModel(model: string): boolean { + if (isOllamaEmbeddingModel(model)) return findEmbeddingModelInfo(model) !== undefined + return findKbModelInfo(model) !== undefined +} + +/** + * Throws unless `model` is selectable for knowledge-base indexing at `dimensions`. + * Call before handing a model to `embed()` so an ineligible pairing fails here, + * naming the knowledge-base constraint, rather than deeper in the provider path. */ -export function assertKbEmbeddingModel(model: string): void { - getEmbeddingModelInfo(model) +export function assertKbEmbeddingModel(model: string, dimensions: number): void { + const info = getEmbeddingModelInfo(model) + if (!info.dimensions.includes(dimensions as KbEmbeddingDimensions)) { + throw new Error( + `${model} cannot produce ${dimensions}-dimensional embeddings. Supported: ${info.dimensions.join(', ')}` + ) + } } export function getEmbeddingModelInfo(model: string): EmbeddingModelInfo { - const info = SUPPORTED_EMBEDDING_MODELS[model] - if (!info) { - /** Surfaces the catalog's error for unknown ids, and a KB-specific one for ineligible models. */ - getCatalogModelInfo(model) - throw new Error(`Embedding model is not available for knowledge bases: ${model}`) + const info = findKbModelInfo(model) + if (info) return info + + /** + * An Ollama id is resolved rather than looked up, because Sim has no catalog + * of what a given server has pulled. Every other unknown id surfaces the + * catalog's error, and a catalogued but ineligible model gets a KB-specific + * one. + */ + if (isOllamaEmbeddingModel(model)) return toKbModelInfo(model) + getCatalogModelInfo(model) + throw new Error(`Embedding model is not available for knowledge bases: ${model}`) +} + +/** + * Narrows a persisted `knowledge_base.embedding_dimension` to a width the + * `embedding` table has a column for. A base recorded at any other width cannot + * be read or written, so this fails where the width is first used rather than + * letting a column lookup come back undefined. + */ +export function toKbEmbeddingDimensions(value: number): KbEmbeddingDimensions { + if (!isKbEmbeddingDimensions(value)) { + throw new Error( + `Knowledge base vector width ${value} has no storage column. Supported: ${KB_EMBEDDING_STORAGE_DIMENSIONS.join(', ')}` + ) + } + return value +} + +/** + * Width a knowledge base should be created at for `model` when the deployment + * configured none, or configured one the model cannot emit. Prefers the + * platform default so an unconfigured deployment keeps writing the column it + * always has, and otherwise takes the model's largest storable width rather + * than refusing to create a base at all. + */ +export function defaultKbEmbeddingDimensions(model: string): KbEmbeddingDimensions { + const { dimensions } = getEmbeddingModelInfo(model) + if (dimensions.includes(DEFAULT_KB_EMBEDDING_DIMENSIONS)) { + return DEFAULT_KB_EMBEDDING_DIMENSIONS + } + const widest = dimensions[0] + if (widest === undefined) { + throw new Error( + `${model} emits no width knowledge bases can store. Supported: ${KB_EMBEDDING_STORAGE_DIMENSIONS.join(', ')}` + ) } - return info + return widest } diff --git a/apps/sim/lib/knowledge/embeddings-configuration.test.ts b/apps/sim/lib/knowledge/embeddings-configuration.test.ts new file mode 100644 index 00000000000..1cfde763e3d --- /dev/null +++ b/apps/sim/lib/knowledge/embeddings-configuration.test.ts @@ -0,0 +1,159 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { OllamaEmbeddingModelNotFoundError } from '@/lib/embeddings/ollama-model-catalog.server' + +const { mockEnv, mockOllamaMetadata } = vi.hoisted(() => ({ + mockEnv: {} as { KB_EMBEDDING_MODEL?: string; EMBEDDING_OUTPUT_DIMS?: string }, + mockOllamaMetadata: vi.fn(), +})) + +/** + * `envNumber` is the real implementation, not a stub: the whole point of the + * cases below is that `createEnv` runs with `skipValidation`, so every value + * arrives as the raw string from the environment however its schema is declared. + */ +vi.mock('@/lib/core/config/env', async (importOriginal) => ({ + ...(await importOriginal()), + env: mockEnv, +})) + +vi.mock('@/lib/embeddings/ollama-model-catalog.server', async (importOriginal) => ({ + ...(await importOriginal()), + getOllamaEmbeddingModelMetadata: mockOllamaMetadata, +})) + +import { getConfiguredKbEmbedding } from '@/lib/knowledge/embeddings' + +describe('getConfiguredKbEmbedding', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnv.KB_EMBEDDING_MODEL = undefined + mockEnv.EMBEDDING_OUTPUT_DIMS = undefined + mockOllamaMetadata.mockRejectedValue(new Error('no server configured')) + }) + + it('defaults to the model and width knowledge bases were always created at', async () => { + await expect(getConfiguredKbEmbedding()).resolves.toEqual({ + model: 'text-embedding-3-small', + dimensions: 1536, + }) + }) + + it('stores at the configured width when the model can emit it', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'text-embedding-3-large' + mockEnv.EMBEDDING_OUTPUT_DIMS = '3072' + await expect(getConfiguredKbEmbedding()).resolves.toEqual({ + model: 'text-embedding-3-large', + dimensions: 3072, + }) + }) + + it('accepts any storable width from a model on the deployment’s own Ollama', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'ollama/nomic-embed-text' + mockEnv.EMBEDDING_OUTPUT_DIMS = '768' + await expect(getConfiguredKbEmbedding()).resolves.toEqual({ + model: 'ollama/nomic-embed-text', + dimensions: 768, + }) + }) + + /** + * Sim can read a local model's width off the server it is installed on, so an + * unstated width is resolved rather than defaulted — defaulting would create + * every base at 1,536 and fail each document against a 768-wide model. + */ + it('reads an unstated Ollama width from the server the model is installed on', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'ollama/nomic-embed-text' + mockOllamaMetadata.mockResolvedValue({ id: 'nomic-embed-text:latest', dimensions: 768 }) + + await expect(getConfiguredKbEmbedding()).resolves.toEqual({ + model: 'ollama/nomic-embed-text', + dimensions: 768, + }) + expect(mockOllamaMetadata).toHaveBeenCalledWith('ollama/nomic-embed-text') + }) + + it('prefers a stated width over the server, and never asks when one is stated', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'ollama/nomic-embed-text' + mockEnv.EMBEDDING_OUTPUT_DIMS = '1024' + mockOllamaMetadata.mockResolvedValue({ id: 'nomic-embed-text:latest', dimensions: 768 }) + + await expect(getConfiguredKbEmbedding()).resolves.toEqual({ + model: 'ollama/nomic-embed-text', + dimensions: 1024, + }) + expect(mockOllamaMetadata).not.toHaveBeenCalled() + }) + + /** + * There is no width to fall back to: the adapter cannot ask Ollama for a + * different one, so the platform default would pin the base at a width the + * model does not emit and fail every document. Refusing is recoverable; + * a base created at an impossible width is not. + */ + it('refuses to create a base when the server cannot be asked', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'ollama/nomic-embed-text' + mockOllamaMetadata.mockRejectedValue(new Error('ECONNREFUSED')) + + await expect(getConfiguredKbEmbedding()).rejects.toThrow('Set EMBEDDING_OUTPUT_DIMS') + }) + + /** + * A model the server does not have is the operator's to fix, so it is a + * validation failure the route renders as 400 rather than a generic 500. + */ + it('classifies a missing model as caller-fixable', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'ollama/not-pulled' + mockOllamaMetadata.mockRejectedValue(new OllamaEmbeddingModelNotFoundError('not-pulled')) + + await expect(getConfiguredKbEmbedding()).rejects.toBeInstanceOf(OrchestrationError) + }) + + it('refuses to create a base when the model emits a width no column can store', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'ollama/odd-width' + mockOllamaMetadata.mockResolvedValue({ id: 'odd-width:latest', dimensions: 1152 }) + + await expect(getConfiguredKbEmbedding()).rejects.toThrow( + 'emits 1152-dimensional vectors, which knowledge bases cannot store' + ) + }) + + it('falls back when the width is not a number at all', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'text-embedding-3-large' + mockEnv.EMBEDDING_OUTPUT_DIMS = 'wide' + await expect(getConfiguredKbEmbedding()).resolves.toEqual({ + model: 'text-embedding-3-large', + dimensions: 1536, + }) + }) + + it('falls back when the width has no storage column, keeping the chosen model', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'text-embedding-3-large' + mockEnv.EMBEDDING_OUTPUT_DIMS = '1000' + await expect(getConfiguredKbEmbedding()).resolves.toEqual({ + model: 'text-embedding-3-large', + dimensions: 1536, + }) + }) + + it('falls back when the model cannot emit the configured width', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'gemini-embedding-001' + mockEnv.EMBEDDING_OUTPUT_DIMS = '1024' + await expect(getConfiguredKbEmbedding()).resolves.toEqual({ + model: 'gemini-embedding-001', + dimensions: 1536, + }) + }) + + it('falls back to the default model when the configured one cannot index a knowledge base', async () => { + mockEnv.KB_EMBEDDING_MODEL = 'text-embedding-ada-002' + mockEnv.EMBEDDING_OUTPUT_DIMS = '768' + await expect(getConfiguredKbEmbedding()).resolves.toEqual({ + model: 'text-embedding-3-small', + dimensions: 768, + }) + }) +}) diff --git a/apps/sim/lib/knowledge/embeddings.ts b/apps/sim/lib/knowledge/embeddings.ts index 07616df83b7..d46c3d5a206 100644 --- a/apps/sim/lib/knowledge/embeddings.ts +++ b/apps/sim/lib/knowledge/embeddings.ts @@ -6,14 +6,24 @@ import { } from '@/lib/billing/core/billing-attribution' import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing' -import { env } from '@/lib/core/config/env' +import { env, envNumber } from '@/lib/core/config/env' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { embedKnowledge } from '@/lib/embeddings' +import { isOllamaEmbeddingModel } from '@/lib/embeddings/catalog' +import { + getOllamaEmbeddingModelMetadata, + OllamaEmbeddingModelNotFoundError, + OllamaEmbeddingWidthUnknownError, +} from '@/lib/embeddings/ollama-model-catalog.server' import { assertKbEmbeddingModel, DEFAULT_EMBEDDING_MODEL, - EMBEDDING_DIMENSIONS, + defaultKbEmbeddingDimensions, getEmbeddingModelInfo, - SUPPORTED_EMBEDDING_MODELS, + isKbEmbeddingDimensions, + isKbEmbeddingModel, + KB_EMBEDDING_STORAGE_DIMENSIONS, + type KbEmbeddingDimensions, } from '@/lib/knowledge/embedding-models' import { projectKnowledgeModelInputs } from '@/lib/knowledge/model-input-provenance' import { estimateTokenCount } from '@/lib/tokenization' @@ -21,18 +31,27 @@ import { calculateCost } from '@/providers/utils' const logger = createLogger('EmbeddingUtils') -export { EMBEDDING_DIMENSIONS } from '@/lib/knowledge/embedding-models' - export type EmbeddingInputType = 'document' | 'query' +/** + * The model a knowledge base is indexed with and the vector width it stores. + * The two travel together everywhere: a width is only meaningful for the model + * that emits it, and a chunk written at the wrong width lands in the wrong + * pgvector column or none at all. + */ +export interface KbEmbeddingTarget { + model: string + dimensions: KbEmbeddingDimensions +} + /** * Returns the embedding model to use for new knowledge bases. * Sourced from the `KB_EMBEDDING_MODEL` env var; falls back to the default if - * unset or set to an unsupported model. + * unset or set to a model knowledge bases cannot use. */ -export function getConfiguredEmbeddingModel(): string { +function resolveConfiguredEmbeddingModel(): string { const configured = env.KB_EMBEDDING_MODEL - if (configured && SUPPORTED_EMBEDDING_MODELS[configured]) { + if (configured && isKbEmbeddingModel(configured)) { return configured } if (configured) { @@ -43,6 +62,99 @@ export function getConfiguredEmbeddingModel(): string { return DEFAULT_EMBEDDING_MODEL } +/** + * Vector width new knowledge bases are stored at, from `EMBEDDING_OUTPUT_DIMS`. + * + * Matching the width to what the configured model actually emits is the + * operator's job — Sim cannot verify it for a model on their own Ollama server, + * and for a catalogued model it can only check the widths the provider + * documents. Either way a value this deployment cannot store falls back rather + * than failing knowledge-base creation outright, because a base that exists at + * a working width is recoverable and one that could not be created is not. + */ +function resolveConfiguredEmbeddingDimensions(model: string): KbEmbeddingDimensions { + const raw = env.EMBEDDING_OUTPUT_DIMS + if (raw === undefined || String(raw).trim() === '') return defaultKbEmbeddingDimensions(model) + + const fallback = defaultKbEmbeddingDimensions(model) + /** + * Read through `envNumber` rather than trusted as the number its schema + * declares: `createEnv` runs with `skipValidation`, so the declared + * `z.coerce.number()` never executes and the value arrives as the raw string + * from the environment. Comparing that string against the storage widths + * matches nothing, which silently ignored every configured width. `0` is the + * sentinel for a value that is not a number at all; no storage width is 0. + */ + const configured = envNumber(raw, 0, { min: 1, integer: true }) + if (!isKbEmbeddingDimensions(configured)) { + logger.warn( + `EMBEDDING_OUTPUT_DIMS="${raw}" is not a storable vector width — falling back to ${fallback}. Supported: ${KB_EMBEDDING_STORAGE_DIMENSIONS.join(', ')}` + ) + return fallback + } + if (!getEmbeddingModelInfo(model).dimensions.includes(configured)) { + logger.warn( + `EMBEDDING_OUTPUT_DIMS="${raw}" is not a width ${model} can emit — falling back to ${fallback}` + ) + return fallback + } + return configured +} + +/** + * Model and vector width every knowledge base created on this deployment uses. + * + * Asynchronous for one case: an Ollama model whose width the deployment did not + * state. Sim can read that from the server the model is installed on, and doing + * so is much better than the platform default, which would silently create every + * base at 1,536 and fail each document against a 768-wide model. + */ +export async function getConfiguredKbEmbedding(): Promise { + const model = resolveConfiguredEmbeddingModel() + const configured = env.EMBEDDING_OUTPUT_DIMS + const stated = configured !== undefined && String(configured).trim() !== '' + + /** + * An Ollama model's width is a property of what the operator pulled, and the + * adapter cannot ask for a different one, so there is no width to fall back + * to: the platform default would pin every base to 1,536 and fail every + * document against a model that emits anything else. When it cannot be + * established the base is refused instead, which is recoverable — a base + * created at an impossible width is not. + */ + if (isOllamaEmbeddingModel(model) && !stated) { + let dimensions: number + try { + dimensions = (await getOllamaEmbeddingModelMetadata(model)).dimensions + } catch (error) { + /** + * A model the server does not have, or one whose width it will not report, + * is the operator's to fix and is surfaced as such. An unreachable server + * is a dependency failure and keeps its default classification — the + * orchestration vocabulary has no upstream-failure code, and the message + * carries the cause either way. + */ + const message = `Could not read the vector width of ${model} from the configured Ollama server (${getErrorMessage(error, 'Unknown error')}). Set EMBEDDING_OUTPUT_DIMS to the width it emits.` + if ( + error instanceof OllamaEmbeddingModelNotFoundError || + error instanceof OllamaEmbeddingWidthUnknownError + ) { + throw new OrchestrationError('validation', message) + } + throw new Error(message, { cause: error }) + } + if (!isKbEmbeddingDimensions(dimensions)) { + throw new OrchestrationError( + 'validation', + `${model} emits ${dimensions}-dimensional vectors, which knowledge bases cannot store. Choose a model emitting one of ${KB_EMBEDDING_STORAGE_DIMENSIONS.join(', ')}.` + ) + } + return { model, dimensions } + } + + return { model, dimensions: resolveConfiguredEmbeddingDimensions(model) } +} + export interface GenerateEmbeddingsResult { embeddings: number[][] totalTokens: number @@ -56,21 +168,21 @@ export interface GenerateEmbeddingsResult { /** * Generate embeddings for multiple texts with token-aware batching and parallel processing. * - * Every knowledge-base vector is pinned to {@link EMBEDDING_DIMENSIONS} so it - * matches the fixed width of the pgvector column. + * Every vector is pinned to the width its knowledge base was created at, so it + * matches the pgvector column the base stores into. */ export async function generateEmbeddings( texts: string[], - embeddingModel: string = DEFAULT_EMBEDDING_MODEL, + target: KbEmbeddingTarget, workspaceId?: string | null ): Promise { - assertKbEmbeddingModel(embeddingModel) + assertKbEmbeddingModel(target.model, target.dimensions) const result = await embedKnowledge(texts, { - model: embeddingModel, + model: target.model, workspaceId, taskType: 'document', - dimensions: EMBEDDING_DIMENSIONS, + dimensions: target.dimensions, projectInputs: projectKnowledgeModelInputs, }) @@ -86,16 +198,16 @@ export async function generateEmbeddings( export async function generateSearchEmbedding( query: string, - embeddingModel: string = DEFAULT_EMBEDDING_MODEL, + target: KbEmbeddingTarget, workspaceId?: string | null ): Promise<{ embedding: number[]; isBYOK: boolean }> { - assertKbEmbeddingModel(embeddingModel) + assertKbEmbeddingModel(target.model, target.dimensions) const result = await embedKnowledge([query], { - model: embeddingModel, + model: target.model, workspaceId, taskType: 'query', - dimensions: EMBEDDING_DIMENSIONS, + dimensions: target.dimensions, projectInputs: projectKnowledgeModelInputs, }) diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts index 74ed2b10951..4fb60593f2d 100644 --- a/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts @@ -30,8 +30,7 @@ vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { knowledgeBaseCreated: vi.fn(), knowledgeBaseDeleted: vi.fn() }, })) vi.mock('@/lib/knowledge/embeddings', () => ({ - EMBEDDING_DIMENSIONS: 1536, - getConfiguredEmbeddingModel: () => 'text-embedding-3-small', + getConfiguredKbEmbedding: () => ({ model: 'text-embedding-3-small', dimensions: 1536 }), })) vi.mock('@/lib/knowledge/service', () => ({ createKnowledgeBase: mockCreateKnowledgeBase, diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts index b2f6d873764..6a4a17baf0b 100644 --- a/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' -import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { getConfiguredKbEmbedding } from '@/lib/knowledge/embeddings' import { auditActorFields, classifyKnowledgeFailure, @@ -55,7 +55,7 @@ export async function performCreateKnowledgeBase( const { workspaceId, name, description, folderId, request, source } = params const requestId = params.requestId ?? generateRequestId() const chunkingConfig: ChunkingConfig = { ...DEFAULT_CHUNKING_CONFIG, ...params.chunkingConfig } - const embeddingModel = getConfiguredEmbeddingModel() + const { model: embeddingModel, dimensions: embeddingDimension } = await getConfiguredKbEmbedding() let created: KnowledgeBaseWithCounts try { @@ -67,7 +67,7 @@ export async function performCreateKnowledgeBase( folderId, userId: params.userId, embeddingModel, - embeddingDimension: EMBEDDING_DIMENSIONS, + embeddingDimension, chunkingConfig, }, requestId @@ -107,7 +107,7 @@ export async function performCreateKnowledgeBase( name: created.name, description: created.description, embeddingModel, - embeddingDimension: EMBEDDING_DIMENSIONS, + embeddingDimension, chunkingStrategy: chunkingConfig.strategy, chunkMaxSize: chunkingConfig.maxSize, chunkMinSize: chunkingConfig.minSize, diff --git a/apps/sim/lib/knowledge/search/queries.ts b/apps/sim/lib/knowledge/search/queries.ts index 57cece6f4c6..f5449ce625b 100644 --- a/apps/sim/lib/knowledge/search/queries.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -5,6 +5,7 @@ import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' import { type KnowledgeAccessScope, WORKSPACE_ACCESS_TOKENS } from '@/lib/knowledge/access/types' +import type { KbEmbeddingDimensions } from '@/lib/knowledge/embedding-models' import { applyRecencyBoost, RRF_K } from '@/lib/knowledge/search/recency' import { coerceTagFilterValue, @@ -12,6 +13,7 @@ import { uncompilableTagFilterError, } from '@/lib/knowledge/tags/utils' import type { StructuredFilter } from '@/lib/knowledge/types' +import { embeddingDistance } from '@/lib/knowledge/vector-columns' const logger = createLogger('KnowledgeSearchQueries') @@ -164,13 +166,25 @@ export interface SearchResult { sourceModifiedAt: Date | null } +/** + * A query embedding and the width it was produced at. The two travel together + * because the width selects both the pgvector column the comparison reads and + * the index form it has to be written in; a vector without it cannot be + * compared against anything. + */ +export interface KnowledgeQueryVector { + /** JSON array literal of the embedding, in pgvector's text input format. */ + vector: string + dimensions: KbEmbeddingDimensions +} + export interface SearchParams { knowledgeBaseIds: string[] topK: number /** What the caller may read; every leg applies it. Required so no leg can be written without it. */ access: KnowledgeAccessScope structuredFilters?: StructuredFilter[] - queryVector?: string + queryVector?: KnowledgeQueryVector distanceThreshold?: number } @@ -449,7 +463,7 @@ async function executeTagFilterQuery( async function executeVectorSearchOnIds( embeddingIds: string[], - queryVector: string, + queryVector: KnowledgeQueryVector, topK: number, distanceThreshold: number, access: KnowledgeAccessScope @@ -458,23 +472,20 @@ async function executeVectorSearchOnIds( return [] } + const distance = embeddingDistance(queryVector.dimensions, queryVector.vector) const rows = await withVectorScanSettings(access, topK, (executor) => executor - .select( - getSearchResultFields( - sql`${embedding.embedding} <=> ${queryVector}::vector`.as('distance') - ) - ) + .select(getSearchResultFields(distance.as('distance'))) .from(embedding) .innerJoin(document, eq(embedding.documentId, document.id)) .where( and( inArray(embedding.id, embeddingIds), ...getVisibilityConditions(access), - sql`${embedding.embedding} <=> ${queryVector}::vector < ${distanceThreshold}` + sql`${distance} < ${distanceThreshold}` ) ) - .orderBy(sql`${embedding.embedding} <=> ${queryVector}::vector`) + .orderBy(distance) .limit(topK) ) return rows.sort((a, b) => a.distance - b.distance) @@ -535,20 +546,17 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise`${embedding.embedding} <=> ${queryVector}::vector`.as('distance') + const distance = embeddingDistance(queryVector.dimensions, queryVector.vector) + const distanceExpr = distance.as('distance') const vectorLeg = (executor: SearchExecutor, kbScope: SQL | undefined, limit: number) => executor .select(getSearchResultFields(distanceExpr)) .from(embedding) .innerJoin(document, eq(embedding.documentId, document.id)) .where( - and( - kbScope, - ...getVisibilityConditions(access), - sql`${embedding.embedding} <=> ${queryVector}::vector < ${distanceThreshold}` - ) + and(kbScope, ...getVisibilityConditions(access), sql`${distance} < ${distanceThreshold}`) ) - .orderBy(sql`${embedding.embedding} <=> ${queryVector}::vector`) + .orderBy(distance) .limit(limit) /** @@ -579,7 +587,7 @@ export interface KeywordSearchParams { access: KnowledgeAccessScope query: string /** Query embedding, so keyword-only hits still carry a real cosine distance. */ - queryVector: string + queryVector: KnowledgeQueryVector structuredFilters?: StructuredFilter[] } @@ -600,8 +608,8 @@ export interface KeywordSearchParams { * fusion is combining rankings taken over differently-shaped pools. * * Ranking and hydration are two steps on purpose. Projecting the cosine - * distance in the ranking query makes Postgres detoast the 1536-dimension - * vector and compute a distance for *every* full-text match before the `LIMIT` + * distance in the ranking query makes Postgres detoast the chunk's vector and + * compute a distance for *every* full-text match before the `LIMIT` * applies — work that scales with how common the query term is rather than * with `topK` (measured at ~59x the buffer reads on a 20k-chunk base for a term * matching every row). Ranking therefore touches no vectors, and only the rows @@ -660,7 +668,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise const hydrated = await db .select( getSearchResultFields( - sql`${embedding.embedding} <=> ${queryVector}::vector`.as('distance') + embeddingDistance(queryVector.dimensions, queryVector.vector).as('distance') ) ) .from(embedding) @@ -796,7 +804,7 @@ export interface ExecuteKnowledgeSearchParams { boostRecency?: boolean query?: string /** Required whenever `query` is present. */ - queryVector?: string + queryVector?: KnowledgeQueryVector structuredFilters?: StructuredFilter[] } diff --git a/apps/sim/lib/knowledge/types.ts b/apps/sim/lib/knowledge/types.ts index 7a7d22666a4..f68268270cd 100644 --- a/apps/sim/lib/knowledge/types.ts +++ b/apps/sim/lib/knowledge/types.ts @@ -1,4 +1,5 @@ import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' +import type { KbEmbeddingDimensions } from '@/lib/knowledge/embedding-models' /** * Units: @@ -40,7 +41,7 @@ export interface CreateKnowledgeBaseData { workspaceId: string folderId?: string | null embeddingModel: string - embeddingDimension: 1536 + embeddingDimension: KbEmbeddingDimensions chunkingConfig: ChunkingConfig userId: string } diff --git a/apps/sim/lib/knowledge/vector-columns.test.ts b/apps/sim/lib/knowledge/vector-columns.test.ts new file mode 100644 index 00000000000..68898ad4c0a --- /dev/null +++ b/apps/sim/lib/knowledge/vector-columns.test.ts @@ -0,0 +1,54 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { KB_EMBEDDING_STORAGE_DIMENSIONS } from '@/lib/embeddings/catalog' +import { + embeddingDistance, + embeddingVectorColumn, + embeddingVectorValues, +} from '@/lib/knowledge/vector-columns' + +describe('embeddingVectorColumn', () => { + it('maps every storable width to a distinct column', () => { + const columns = KB_EMBEDDING_STORAGE_DIMENSIONS.map((width) => embeddingVectorColumn(width)) + expect(new Set(columns).size).toBe(KB_EMBEDDING_STORAGE_DIMENSIONS.length) + }) + + it('keeps 1536 on the original bare `embedding` column, where existing rows live', () => { + expect(embeddingVectorColumn(1536)).toBe('embedding.embedding') + }) +}) + +describe('embeddingVectorValues', () => { + const vector = [0.1, 0.2] + + it('writes the vector to the column its width belongs to', () => { + expect(embeddingVectorValues(768, vector)).toMatchObject({ embedding768: vector }) + }) + + it('nulls every other width, so a re-embed at a new width clears the old column', () => { + for (const width of KB_EMBEDDING_STORAGE_DIMENSIONS) { + const values = embeddingVectorValues(width, vector) + const populated = Object.entries(values).filter(([, value]) => value !== null) + expect(populated, `${width} populated ${populated.length} columns`).toHaveLength(1) + expect(Object.values(values).filter((value) => value === null)).toHaveLength( + KB_EMBEDDING_STORAGE_DIMENSIONS.length - 1 + ) + } + }) +}) + +describe('embeddingDistance', () => { + it('compares 3072 through the halfvec cast its expression index was built on', () => { + expect(embeddingDistance(3072, '[1,2]').toSQL().sql).toContain('::halfvec(3072)') + }) + + it('compares every indexable width against a plain vector', () => { + for (const width of KB_EMBEDDING_STORAGE_DIMENSIONS.filter((size) => size <= 2000)) { + const rendered = embeddingDistance(width, '[1,2]').toSQL().sql + expect(rendered, `${width} should not cast to halfvec`).not.toContain('halfvec') + expect(rendered).toContain('::vector') + } + }) +}) diff --git a/apps/sim/lib/knowledge/vector-columns.ts b/apps/sim/lib/knowledge/vector-columns.ts new file mode 100644 index 00000000000..3f6e084462c --- /dev/null +++ b/apps/sim/lib/knowledge/vector-columns.ts @@ -0,0 +1,71 @@ +/** + * Maps a knowledge base's stored vector width onto the one pgvector column that + * holds it. Every read and write of a chunk's vector goes through here, so the + * column choice, the distance expression, and the index each width is served by + * can never drift apart. + */ + +import { embedding } from '@sim/db/schema' +import { type SQL, sql } from 'drizzle-orm' +import type { KbEmbeddingDimensions } from '@/lib/knowledge/embedding-models' + +type VectorField = 'embedding' | 'embedding384' | 'embedding768' | 'embedding1024' | 'embedding3072' + +/** + * Column per stored width. `embedding` is the original 1536 column, kept under + * its bare name so rows written before the other widths existed stay put. + */ +const VECTOR_FIELD_BY_WIDTH = { + 384: 'embedding384', + 768: 'embedding768', + 1024: 'embedding1024', + 1536: 'embedding', + 3072: 'embedding3072', +} as const satisfies Record + +const VECTOR_FIELDS = Object.values(VECTOR_FIELD_BY_WIDTH) as readonly VectorField[] + +export function embeddingVectorColumn(dimensions: KbEmbeddingDimensions) { + return embedding[VECTOR_FIELD_BY_WIDTH[dimensions]] +} + +/** + * The vector slice of an `embedding` row: the column for this width carries the + * vector and every other width is explicitly NULL. + * + * Every width is named rather than just the one in use so an update that + * re-embeds a chunk at a different width clears the column it used to live in. + * Omitting the others satisfies an insert but leaves an update holding two + * vectors, which `embedding_width_check` rejects. + */ +export function embeddingVectorValues( + dimensions: KbEmbeddingDimensions, + vector: number[] +): Record { + const target = VECTOR_FIELD_BY_WIDTH[dimensions] + return Object.fromEntries( + VECTOR_FIELDS.map((field) => [field, field === target ? vector : null]) + ) as Record +} + +/** + * Cosine distance between a chunk's vector and the query vector, in the exact + * form the width's HNSW index was built on. + * + * The 3,072 column is compared through a `halfvec` cast because pgvector + * indexes `vector` only up to 2,000 dimensions, so its index is on that cast + * expression. Postgres matches an expression index by the expression, so a + * plain `<=>` against the column here would silently drop to a sequential scan + * — and the cast belongs here rather than at each call site precisely because + * getting it wrong is invisible in the results and only shows up as latency. + * `packages/db/schema.ts` records what the half-precision comparison costs. + */ +export function embeddingDistance( + dimensions: KbEmbeddingDimensions, + queryVector: string +): SQL { + if (dimensions === 3072) { + return sql`${embedding.embedding3072}::halfvec(3072) <=> ${queryVector}::halfvec(3072)` + } + return sql`${embeddingVectorColumn(dimensions)} <=> ${queryVector}::vector` +} diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.test.ts b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts index 7d1f44a1ae6..f543798bb50 100644 --- a/apps/sim/lib/mcp/application/execute-managed-tool.test.ts +++ b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts @@ -186,7 +186,7 @@ describe('executeManagedMcpToolUseCase', () => { expect(mocks.discoverTools).toHaveBeenCalledWith( context.mcpServerId, context.workspaceId, - {}, + { credentialId: context.credentialId, loadProvider: expect.any(Function) }, signal, { requireComplete: true } ) diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.ts b/apps/sim/lib/mcp/application/execute-managed-tool.ts index 8e2c43a61de..00ed0296fae 100644 --- a/apps/sim/lib/mcp/application/execute-managed-tool.ts +++ b/apps/sim/lib/mcp/application/execute-managed-tool.ts @@ -17,7 +17,6 @@ import { validateToolArguments, } from '@/lib/mcp/application/execute-tool' import { loadManagedMcpAuthProvider } from '@/lib/mcp/application/managed-auth-provider' -import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth' import { mcpService } from '@/lib/mcp/service' import type { McpTool, McpToolCall, McpToolSchema } from '@/lib/mcp/types' @@ -55,14 +54,15 @@ export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ async execute({ input, context }): Promise { input.signal?.throwIfAborted() const runtime = await loadManagedMcpRuntimeCredential(context.credentialId, context.workspaceId) - const tools = await withMcpOauthRefreshLock(runtime.credentialId, async () => - mcpService.discoverManagedMcpTools( - runtime.mcpServerId, - runtime.workspaceId, - await loadManagedMcpAuthProvider(runtime.credentialId, runtime.workspaceId), - input.signal, - { requireComplete: true } - ) + const tools = await mcpService.discoverManagedMcpTools( + runtime.mcpServerId, + runtime.workspaceId, + { + credentialId: runtime.credentialId, + loadProvider: () => loadManagedMcpAuthProvider(runtime.credentialId, runtime.workspaceId), + }, + input.signal, + { requireComplete: true } ) await saveManagedMcpToolSnapshot( runtime.credentialId, diff --git a/apps/sim/lib/mcp/client.test.ts b/apps/sim/lib/mcp/client.test.ts index 27d3dbdca4b..1d5ff39964d 100644 --- a/apps/sim/lib/mcp/client.test.ts +++ b/apps/sim/lib/mcp/client.test.ts @@ -71,8 +71,12 @@ vi.mock('@/lib/core/execution-limits', () => ({ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' -import { McpClient } from './client' -import type { McpClientOptions, McpServerConfig } from './types' +import { McpClient } from '@/lib/mcp/client' +import { + type McpClientOptions, + McpOauthAuthorizationRequiredError, + type McpServerConfig, +} from '@/lib/mcp/types' function createConfig(): McpServerConfig { return { @@ -94,6 +98,14 @@ describe('McpClient notification handler', () => { vi.mocked(getMaxExecutionTimeout).mockReturnValue(30_000) }) + it('preserves authorization-required errors raised by a locked credential reload', async () => { + const error = new McpOauthAuthorizationRequiredError('server-1', 'Test Server') + mockSdkConnect.mockRejectedValueOnce(error) + const client = new McpClient({ config: createConfig() }) + await expect(client.connect()).rejects.toBe(error) + expect(client.getStatus().lastError).toBeUndefined() + }) + it('fires onToolsChanged when a notification arrives while connected', async () => { const onToolsChanged = vi.fn() @@ -453,7 +465,7 @@ describe('McpClient notification handler', () => { expect(logged).not.toContain('test-session') }) - it('passes configured headers for OAuth transports as well as header auth transports', () => { + it('scopes configured headers to the MCP endpoint for OAuth transports', () => { const authProvider = {} as unknown as NonNullable new McpClient({ config: { @@ -467,10 +479,13 @@ describe('McpClient notification handler', () => { expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( new URL('https://test.example.com/mcp'), - { + expect.objectContaining({ authProvider, - requestInit: { headers: { 'X-Sim-Via': 'workflow' } }, - } + fetch: expect.any(Function), + }) + ) + expect(vi.mocked(StreamableHTTPClientTransport).mock.calls.at(-1)?.[1]).not.toHaveProperty( + 'requestInit' ) }) }) diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 6f0b2cd4828..296478e16cb 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -13,6 +13,10 @@ import { getErrorMessage } from '@sim/utils/errors' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' +import { + createCoordinatedMcpOauthFetch, + createMcpEndpointFetch, +} from '@/lib/mcp/oauth/coordinated-fetch' import { createGuardedMcpFetch, createPinnedPrivateMcpFetch } from '@/lib/mcp/pinned-fetch' import { type McpClientOptions, @@ -21,6 +25,7 @@ import { type McpConsentRequest, type McpConsentResponse, McpError, + McpOauthAuthorizationRequiredError, type McpSecurityPolicy, type McpServerConfig, type McpTool, @@ -47,7 +52,10 @@ function classifyConnectionOutcome( error: unknown, authType: McpServerConfig['authType'] ): ConnectionOutcome { - if (error instanceof McpOauthRedirectRequired) { + if ( + error instanceof McpOauthRedirectRequired || + error instanceof McpOauthAuthorizationRequiredError + ) { return 'authorization_required' } if (error instanceof UnauthorizedError) { @@ -100,8 +108,15 @@ export class McpClient { throw new McpError('URL required for Streamable HTTP transport') } - if (this.config.authType === 'oauth' && this.authProvider == null) { - throw new McpError('OAuth MCP server requires an authProvider') + if ( + this.config.authType === 'oauth' && + this.authProvider == null && + !options.oauthCredentials + ) { + throw new McpError('OAuth MCP server requires OAuth credentials') + } + if (options.oauthCredentials && this.authProvider) { + throw new McpError('OAuth MCP server must use one authentication strategy') } const useOauth = this.config.authType === 'oauth' // `resolvedIP` is null only when the hostname still carries an unresolved env-var @@ -115,10 +130,23 @@ export class McpClient { : createGuardedMcpFetch(this.config.url) : undefined this.closeGuardedTransport = guarded?.close + const oauthFetch = useOauth + ? createMcpEndpointFetch(guarded?.fetch ?? fetch, { + serverUrl: this.config.url, + headers: this.config.headers, + }) + : undefined + const transportFetch = + options.oauthCredentials && oauthFetch + ? createCoordinatedMcpOauthFetch(options.oauthCredentials, { + serverUrl: this.config.url, + fetch: oauthFetch, + }) + : (oauthFetch ?? guarded?.fetch) this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), { authProvider: useOauth ? this.authProvider : undefined, - requestInit: { headers: this.config.headers }, - ...(guarded ? { fetch: guarded.fetch } : {}), + ...(useOauth ? {} : { requestInit: { headers: this.config.headers } }), + ...(transportFetch ? { fetch: transportFetch } : {}), }) this.client = new Client( diff --git a/apps/sim/lib/mcp/connection-manager.test.ts b/apps/sim/lib/mcp/connection-manager.test.ts index 47c91f79b28..6d66ca07340 100644 --- a/apps/sim/lib/mcp/connection-manager.test.ts +++ b/apps/sim/lib/mcp/connection-manager.test.ts @@ -63,6 +63,7 @@ vi.mock('@/lib/mcp/oauth', () => ({ })) import { McpConnectionManager } from '@/lib/mcp/connection-manager' +import type { McpClientOptions } from '@/lib/mcp/types' beforeAll(() => { setEnvFlags({ isTest: false }) @@ -168,6 +169,11 @@ describe('McpConnectionManager', () => { userId: 'user-1', workspaceId: 'ws-1', }) + const options: McpClientOptions = MockMcpClientConstructor.mock.calls[0][0] + expect(options.authProvider).toBeUndefined() + expect(options.oauthCredentials?.credentialId).toBe('server-oauth') + await options.oauthCredentials?.loadProvider() + expect(mockGetOrCreateOauthRow).toHaveBeenCalledTimes(2) }) it('allows a new connect() after a previous one completes', async () => { diff --git a/apps/sim/lib/mcp/connection-manager.ts b/apps/sim/lib/mcp/connection-manager.ts index 78b8acc265f..a0e6d448bd2 100644 --- a/apps/sim/lib/mcp/connection-manager.ts +++ b/apps/sim/lib/mcp/connection-manager.ts @@ -16,12 +16,13 @@ import { isTest } from '@/lib/core/config/env-flags' import { McpClient } from '@/lib/mcp/client' import { getOrCreateOauthRow, loadPreregisteredClient, SimMcpOauthProvider } from '@/lib/mcp/oauth' import { mcpPubSub } from '@/lib/mcp/pubsub' -import type { - ManagedConnectionState, - McpClientOptions, - McpServerConfig, - McpToolsChangedCallback, - ToolsChangedEvent, +import { + type ManagedConnectionState, + type McpClientOptions, + McpOauthAuthorizationRequiredError, + type McpServerConfig, + type McpToolsChangedCallback, + type ToolsChangedEvent, } from '@/lib/mcp/types' const logger = createLogger('McpConnectionManager') @@ -137,7 +138,7 @@ export class McpConnectionManager { this.handleToolsChanged(key) } - let authProvider: McpClientOptions['authProvider'] + let oauthCredentials: McpClientOptions['oauthCredentials'] if (config.authType === 'oauth') { const row = await getOrCreateOauthRow({ mcpServerId: config.id, @@ -150,8 +151,25 @@ export class McpConnectionManager { ) return { supportsListChanged: false } } - const preregistered = await loadPreregisteredClient(config.id) - authProvider = new SimMcpOauthProvider({ row, preregistered }) + oauthCredentials = { + credentialId: config.id, + initialProvider: new SimMcpOauthProvider({ + row, + preregistered: await loadPreregisteredClient(config.id), + }), + loadProvider: async () => { + const current = await getOrCreateOauthRow({ + mcpServerId: config.id, + userId, + workspaceId, + }) + if (!current.tokens) { + throw new McpOauthAuthorizationRequiredError(config.id, config.name) + } + const preregistered = await loadPreregisteredClient(config.id) + return new SimMcpOauthProvider({ row: current, preregistered }) + }, + } } const client = new McpClient({ @@ -163,7 +181,7 @@ export class McpConnectionManager { }, onToolsChanged, resolvedIP: resolvedIP ?? undefined, - authProvider, + oauthCredentials, }) try { diff --git a/apps/sim/lib/mcp/oauth/coordinated-fetch.test.ts b/apps/sim/lib/mcp/oauth/coordinated-fetch.test.ts new file mode 100644 index 00000000000..65978ff0b54 --- /dev/null +++ b/apps/sim/lib/mcp/oauth/coordinated-fetch.test.ts @@ -0,0 +1,499 @@ +/** + * @vitest-environment node + */ +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' +import { encryptionMock, redisConfigMockFns, resetRedisConfigMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createCoordinatedMcpOauthFetch, + createMcpEndpointFetch, +} from '@/lib/mcp/oauth/coordinated-fetch' +import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth/storage' + +vi.mock('@/lib/core/security/encryption', () => encryptionMock) + +const SERVER = 'https://mcp.example.com/mcp' +const TOKEN_URL = 'https://auth.example.com/token' + +function deferred() { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +function createGrant() { + let persisted: OAuthTokens | undefined = { + access_token: 'access-0', + refresh_token: 'refresh-0', + token_type: 'Bearer', + scope: 'read', + } + let generation = 0 + const save = vi.fn(async (tokens: OAuthTokens | undefined) => { + persisted = tokens + }) + const redirect = vi.fn(async (_url: URL) => { + throw new Error('Reauthorization required') + }) + const createProvider = (): OAuthClientProvider => { + let snapshot = persisted && { ...persisted } + return { + redirectUrl: 'https://sim.example.com/callback', + clientMetadata: { redirect_uris: ['https://sim.example.com/callback'] }, + clientInformation: () => ({ client_id: 'test-client' }), + tokens: () => snapshot, + saveTokens: async (tokens) => { + await save(tokens) + snapshot = tokens + }, + invalidateCredentials: async (scope) => { + if (scope === 'all' || scope === 'tokens') { + await save(undefined) + snapshot = undefined + } + }, + redirectToAuthorization: redirect, + saveCodeVerifier: async () => {}, + codeVerifier: () => 'verifier', + discoveryState: () => ({ + authorizationServerUrl: new URL('https://auth.example.com'), + resourceMetadata: { resource: SERVER, authorization_servers: ['https://auth.example.com'] }, + authorizationServerMetadata: { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: TOKEN_URL, + response_types_supported: ['code'], + token_endpoint_auth_methods_supported: ['none'], + code_challenge_methods_supported: ['S256'], + }, + }), + } + } + const loadProvider = vi.fn(async () => createProvider()) + const tokenRequest = vi.fn(async (_url: string | URL, init?: RequestInit) => { + const form = new URLSearchParams(String(init?.body)) + if (form.get('refresh_token') !== `refresh-${generation}`) { + return Response.json({ error: 'invalid_grant' }, { status: 400 }) + } + generation++ + return Response.json({ + access_token: `access-${generation}`, + refresh_token: `refresh-${generation}`, + token_type: 'Bearer', + scope: form.get('scope') ?? 'read', + }) + }) + return { loadProvider, tokenRequest, save, redirect, createProvider } +} + +function rejection(status = 401, challenge = 'Bearer error="invalid_token"') { + return new Response(null, { status, headers: { 'www-authenticate': challenge } }) +} + +describe('coordinated MCP OAuth with the real SDK and refresh mutex', () => { + const clients: Client[] = [] + + beforeEach(() => { + resetRedisConfigMock() + vi.clearAllMocks() + redisConfigMockFns.mockAcquireLock.mockResolvedValue(true) + redisConfigMockFns.mockReleaseLock.mockResolvedValue(true) + redisConfigMockFns.mockExtendLock.mockResolvedValue(true) + }) + + afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())) + resetRedisConfigMock() + }) + + async function connect(grant: ReturnType, request: FetchLike) { + const client = new Client({ name: 'test', version: '1' }, { capabilities: {} }) + clients.push(client) + const transport = new StreamableHTTPClientTransport(new URL(SERVER), { + fetch: createCoordinatedMcpOauthFetch( + { + credentialId: 'shared-grant', + loadProvider: grant.loadProvider, + initialProvider: grant.createProvider(), + }, + { + serverUrl: SERVER, + fetch: async (url, init) => { + if (String(url) === TOKEN_URL) return grant.tokenRequest(url, init) + if (init?.method === 'GET') return new Response(null, { status: 405 }) + const rpc = JSON.parse(String(init?.body)) + if (rpc.method === 'initialize') { + return Response.json({ + jsonrpc: '2.0', + id: rpc.id, + result: { + protocolVersion: '2025-11-25', + capabilities: { tools: {} }, + serverInfo: { name: 'fixture', version: '1' }, + }, + }) + } + if (!('id' in rpc)) return new Response(null, { status: 202 }) + return request(url, init) + }, + } + ), + }) + await client.connect(transport) + return client + } + + it('allows two tool calls to run concurrently without acquiring the OAuth mutex', async () => { + const grant = createGrant() + const bothStarted = deferred() + const finish = deferred() + let started = 0 + const request: FetchLike = async (_url, init) => { + if (++started === 2) bothStarted.resolve() + await finish.promise + const rpc = JSON.parse(String(init?.body)) + return Response.json({ jsonrpc: '2.0', id: rpc.id, result: { content: [] } }) + } + const first = await connect(grant, request) + const second = await connect(grant, request) + const calls = Promise.all([first.callTool({ name: 'slow' }), second.callTool({ name: 'slow' })]) + try { + await bothStarted.promise + expect(redisConfigMockFns.mockAcquireLock).not.toHaveBeenCalled() + expect(grant.tokenRequest).not.toHaveBeenCalled() + } finally { + finish.resolve() + await calls + } + }) + + it.each([false, true])( + 'refreshes once for concurrent 401s (shared client: %s)', + async (shared) => { + const grant = createGrant() + const request: FetchLike = async (_url, init) => { + if (new Headers(init?.headers).get('authorization') !== 'Bearer access-1') + return rejection() + const rpc = JSON.parse(String(init?.body)) + return Response.json({ jsonrpc: '2.0', id: rpc.id, result: { content: [] } }) + } + const first = await connect(grant, request) + const second = shared ? first : await connect(grant, request) + const results = await Promise.all([ + first.callTool({ name: 'read' }), + second.callTool({ name: 'read' }), + ]) + expect(results).toHaveLength(2) + expect(grant.tokenRequest).toHaveBeenCalledTimes(1) + expect(grant.save).toHaveBeenCalledTimes(1) + expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledTimes(2) + } + ) + + it('reuses a concurrently refreshed token when the OAuth response omits optional scope', async () => { + const grant = createGrant() + grant.tokenRequest.mockResolvedValueOnce( + Response.json({ + access_token: 'access-1', + refresh_token: 'refresh-1', + token_type: 'Bearer', + }) + ) + const request: FetchLike = async (_url, init) => { + if (new Headers(init?.headers).get('authorization') !== 'Bearer access-1') { + return rejection(401, 'Bearer error="invalid_token", scope="read"') + } + const rpc = JSON.parse(String(init?.body)) + return Response.json({ jsonrpc: '2.0', id: rpc.id, result: { content: [] } }) + } + const first = await connect(grant, request) + const second = await connect(grant, request) + await Promise.all([first.callTool({ name: 'read' }), second.callTool({ name: 'read' })]) + expect(grant.tokenRequest).toHaveBeenCalledTimes(1) + }) + + function fetchFor(grant: ReturnType, request: FetchLike) { + return createCoordinatedMcpOauthFetch( + { + credentialId: 'shared-grant', + loadProvider: grant.loadProvider, + initialProvider: grant.createProvider(), + }, + { + serverUrl: SERVER, + fetch: (url, init) => + String(url) === TOKEN_URL ? grant.tokenRequest(url, init) : request(url, init), + } + ) + } + + it('holds the mutex through token persistence and reuses the committed token', async () => { + const grant = createGrant() + const saving = deferred() + const finishSave = deferred() + const persist = grant.save.getMockImplementation()! + grant.save.mockImplementationOnce(async (tokens) => { + saving.resolve() + await finishSave.promise + await persist(tokens) + }) + const rejected = deferred() + let requests = 0 + const request: FetchLike = async (_url, init) => { + if (new Headers(init?.headers).get('authorization') === 'Bearer access-1') { + return new Response(null, { status: 202 }) + } + if (++requests === 2) rejected.resolve() + return rejection() + } + const first = fetchFor(grant, request) + const second = fetchFor(grant, request) + const calls = Promise.all([first(SERVER), second(SERVER)]) + try { + await Promise.all([saving.promise, rejected.promise]) + expect(redisConfigMockFns.mockReleaseLock).not.toHaveBeenCalled() + expect(grant.loadProvider).toHaveBeenCalledTimes(1) + } finally { + finishSave.resolve() + } + expect((await calls).map((r) => r.status)).toEqual([202, 202]) + expect(grant.tokenRequest).toHaveBeenCalledTimes(1) + }) + + it('does not block another credential while one credential refreshes', async () => { + const slowGrant = createGrant() + const otherGrant = createGrant() + const refreshing = deferred() + const finish = deferred() + const exchange = slowGrant.tokenRequest.getMockImplementation()! + slowGrant.tokenRequest.mockImplementationOnce(async (url, init) => { + refreshing.resolve() + await finish.promise + return exchange(url, init) + }) + const request: FetchLike = async (_url, init) => + new Headers(init?.headers).get('authorization') === 'Bearer access-1' + ? new Response(null, { status: 202 }) + : rejection() + const slow = fetchFor(slowGrant, request)(SERVER) + try { + await refreshing.promise + const other = createCoordinatedMcpOauthFetch( + { + credentialId: 'other-grant', + loadProvider: otherGrant.loadProvider, + initialProvider: otherGrant.createProvider(), + }, + { + serverUrl: SERVER, + fetch: (url, init) => + String(url) === TOKEN_URL ? otherGrant.tokenRequest(url, init) : request(url, init), + } + ) + expect((await other(SERVER)).status).toBe(202) + expect(slowGrant.save).not.toHaveBeenCalled() + } finally { + finish.resolve() + await slow + } + }) + + it('returns streaming responses without buffering or replaying their bodies', async () => { + const grant = createGrant() + const response = new Response(new ReadableStream(), { + headers: { 'content-type': 'text/event-stream' }, + }) + const request = vi.fn().mockResolvedValue(response) + const received = await fetchFor(grant, request)(SERVER) + expect(received).toBe(response) + expect(received.bodyUsed).toBe(false) + expect(request).toHaveBeenCalledTimes(1) + expect(redisConfigMockFns.mockAcquireLock).not.toHaveBeenCalled() + await received.body?.cancel() + }) + + it('bounds retries even if the server keeps changing its scope challenge', async () => { + const grant = createGrant() + let scopes = 0 + const request = vi.fn(async () => + rejection(403, `Bearer error="insufficient_scope", scope="scope-${++scopes}"`) + ) + expect((await fetchFor(grant, request)(SERVER)).status).toBe(403) + expect(request).toHaveBeenCalledTimes(4) + expect(grant.tokenRequest).toHaveBeenCalledTimes(3) + }) + + it('coordinates authentication during initialization and GET stream reconnection too', async () => { + const grant = createGrant() + const request = vi.fn(async (_url, init) => + new Headers(init?.headers).get('authorization') === 'Bearer access-1' + ? new Response(null, { status: 202 }) + : rejection() + ) + const first = fetchFor(grant, request) + const second = fetchFor(grant, request) + const responses = await Promise.all([ + first(SERVER, { method: 'POST', body: '{"method":"initialize"}' }), + second(SERVER, { method: 'GET', headers: { 'last-event-id': 'event-1' } }), + ]) + expect(responses.map((r) => r.status)).toEqual([202, 202]) + expect(grant.tokenRequest).toHaveBeenCalledTimes(1) + const retry = request.mock.calls.find( + ([, init]) => + init?.method === 'GET' && + new Headers(init.headers).get('authorization') === 'Bearer access-1' + ) + expect(new Headers(retry?.[1]?.headers).get('last-event-id')).toBe('event-1') + }) + + it('does not replay an ordinary forbidden response or an uncertain network failure', async () => { + const grant = createGrant() + const request = vi.fn().mockResolvedValueOnce(new Response(null, { status: 403 })) + const guarded = fetchFor(grant, request) + expect((await guarded(SERVER)).status).toBe(403) + request.mockRejectedValueOnce(new Error('connection reset')) + await expect(guarded(SERVER)).rejects.toThrow('connection reset') + expect(request).toHaveBeenCalledTimes(2) + expect(grant.tokenRequest).not.toHaveBeenCalled() + expect(redisConfigMockFns.mockAcquireLock).not.toHaveBeenCalled() + }) + + it('stops after one authentication attempt if the server keeps rejecting the token', async () => { + const grant = createGrant() + const request = vi.fn(async () => rejection()) + expect((await fetchFor(grant, request)(SERVER)).status).toBe(401) + expect(request).toHaveBeenCalledTimes(2) + expect(grant.tokenRequest).toHaveBeenCalledTimes(1) + }) + + it('allows a scope challenge after recovering from an expired token', async () => { + const grant = createGrant() + const request = vi + .fn() + .mockResolvedValueOnce(rejection()) + .mockResolvedValueOnce( + rejection(403, 'Bearer error="insufficient_scope", scope="read write"') + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + expect((await fetchFor(grant, request)(SERVER)).status).toBe(202) + expect(request).toHaveBeenCalledTimes(3) + expect(grant.tokenRequest).toHaveBeenCalledTimes(2) + }) + + it('retains request headers and body when recovering from an insufficient-scope challenge', async () => { + const grant = createGrant() + const request = vi + .fn() + .mockResolvedValueOnce( + rejection(403, 'Bearer error="insufficient_scope", scope="read write"') + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + const body = '{"method":"tools/call","params":{"name":"write"}}' + const response = await fetchFor(grant, request)(SERVER, { + method: 'POST', + body, + headers: { 'mcp-session-id': 'session-1', 'x-sim-via': 'workflow-1' }, + }) + expect(response.status).toBe(202) + expect(request.mock.calls[1][1]?.body).toBe(body) + const headers = new Headers(request.mock.calls[1][1]?.headers) + expect(headers.get('mcp-session-id')).toBe('session-1') + expect(headers.get('x-sim-via')).toBe('workflow-1') + }) + + it('releases the lock and does not retry the tool if the versioned token save rejects', async () => { + const grant = createGrant() + grant.save.mockRejectedValueOnce(new Error('Credential grant changed')) + const request = vi.fn(async () => rejection()) + await expect(fetchFor(grant, request)(SERVER)).rejects.toThrow('Reauthorization required') + expect(request).toHaveBeenCalledTimes(1) + expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledTimes(1) + }) + + it('forwards the challenged scope when the SDK requires reauthorization', async () => { + const grant = createGrant() + grant.tokenRequest.mockResolvedValueOnce( + Response.json({ error: 'invalid_grant' }, { status: 400 }) + ) + const request = vi.fn(async () => + rejection(403, 'Bearer error="insufficient_scope", scope="read write"') + ) + await expect(fetchFor(grant, request)(SERVER)).rejects.toThrow('Reauthorization required') + expect(grant.redirect.mock.calls[0][0].searchParams.get('scope')).toBe('read write') + expect(grant.save).toHaveBeenCalledWith(undefined) + expect(request).toHaveBeenCalledTimes(1) + expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledTimes(1) + }) + + it('does not authenticate or retry after a grant is disabled before the locked reload', async () => { + const grant = createGrant() + grant.loadProvider.mockRejectedValueOnce(new Error('Grant disabled')) + const request = vi.fn(async () => rejection()) + await expect(fetchFor(grant, request)(SERVER)).rejects.toThrow('Grant disabled') + expect(grant.tokenRequest).not.toHaveBeenCalled() + expect(grant.save).not.toHaveBeenCalled() + expect(request).toHaveBeenCalledTimes(1) + expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledTimes(1) + }) + + it('does not attach the MCP bearer token to a different URL', async () => { + const grant = createGrant() + const request = vi.fn(async () => new Response(null, { status: 200 })) + await fetchFor(grant, request)('https://other.example.com/metadata') + expect(grant.loadProvider).not.toHaveBeenCalled() + expect(new Headers(request.mock.calls[0][1]?.headers).has('authorization')).toBe(false) + }) + + it('applies configured headers only to the MCP endpoint', async () => { + const request = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) + const scoped = createMcpEndpointFetch(request, { + serverUrl: SERVER, + headers: { 'x-mcp-credential': 'configured-value' }, + }) + + await scoped(SERVER, { headers: { accept: 'application/json' } }) + await scoped(TOKEN_URL, { headers: { 'content-type': 'application/x-www-form-urlencoded' } }) + + const mcpHeaders = new Headers(request.mock.calls[0][1]?.headers) + expect(mcpHeaders.get('x-mcp-credential')).toBe('configured-value') + expect(mcpHeaders.get('accept')).toBe('application/json') + const oauthHeaders = new Headers(request.mock.calls[1][1]?.headers) + expect(oauthHeaders.has('x-mcp-credential')).toBe(false) + expect(oauthHeaders.get('content-type')).toBe('application/x-www-form-urlencoded') + }) + + it('rejects promptly without refreshing when cancelled while waiting for the lock', async () => { + const entered = deferred() + const finish = deferred() + const holder = withMcpOauthRefreshLock('shared-grant', async () => { + entered.resolve() + await finish.promise + }) + await entered.promise + const rejected = deferred() + const grant = createGrant() + const request = vi.fn(async () => { + rejected.resolve() + return rejection() + }) + const abort = new AbortController() + const call = fetchFor(grant, request)(SERVER, { signal: abort.signal }) + const outcome = expect(call).rejects.toThrow('cancelled') + try { + await rejected.promise + abort.abort(new Error('cancelled')) + await outcome + expect(grant.tokenRequest).not.toHaveBeenCalled() + expect(request).toHaveBeenCalledTimes(1) + } finally { + finish.resolve() + await holder + } + }) +}) diff --git a/apps/sim/lib/mcp/oauth/coordinated-fetch.ts b/apps/sim/lib/mcp/oauth/coordinated-fetch.ts new file mode 100644 index 00000000000..62570405aa6 --- /dev/null +++ b/apps/sim/lib/mcp/oauth/coordinated-fetch.ts @@ -0,0 +1,109 @@ +import { + extractWWWAuthenticateParams, + type OAuthClientProvider, + UnauthorizedError, +} from '@modelcontextprotocol/sdk/client/auth.js' +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' +import { mcpAuthGuarded } from '@/lib/mcp/oauth/auth' +import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth/storage' + +export interface McpOauthCredentials { + /** Shared server ID for workspace OAuth, personal credential ID for managed OAuth. */ + credentialId: string + /** Reloads the persisted grant; called again under the refresh lock after a challenge. */ + loadProvider: () => Promise +} + +export interface McpOauthSession extends McpOauthCredentials { + /** Loaded before connecting so credential errors retain their application-level meaning. */ + initialProvider: OAuthClientProvider +} + +/** Applies configured headers only to the MCP endpoint, never to OAuth discovery or token URLs. */ +export function createMcpEndpointFetch( + fetchFn: FetchLike, + options: { serverUrl: string; headers?: HeadersInit } +): FetchLike { + if (!options.headers) return fetchFn + const serverUrl = new URL(options.serverUrl) + const configuredHeaders = new Headers(options.headers) + + return (input, init) => { + if (new URL(input).href !== serverUrl.href) return fetchFn(input, init) + const headers = new Headers(init?.headers) + configuredHeaders.forEach((value, key) => headers.set(key, value)) + return fetchFn(input, { ...init, headers }) + } +} + +/** + * Coordinates the SDK's public OAuth flow across clients without locking MCP requests. + * The transport must omit authProvider so it cannot refresh outside this boundary. + * Only explicit authentication rejections are replayed. Allow a concurrent token + * update, authentication, and a scope upgrade, with at most three replays total. + */ +export function createCoordinatedMcpOauthFetch( + { credentialId, loadProvider, initialProvider }: McpOauthSession, + options: { serverUrl: string; fetch: FetchLike } +): FetchLike { + const serverUrl = new URL(options.serverUrl) + let currentProvider = initialProvider + + return async (input, init) => { + if (new URL(input).href !== serverUrl.href) { + return options.fetch(input, init) + } + + init?.signal?.throwIfAborted() + let provider = currentProvider + const authenticatedChallenges = new Set() + + for (let attempt = 0; ; attempt++) { + init?.signal?.throwIfAborted() + const tokens = await provider.tokens() + const headers = new Headers(init?.headers) + if (tokens && !headers.has('authorization')) { + headers.set('authorization', `Bearer ${tokens.access_token}`) + } + const response = await options.fetch(input, { ...init, headers }) + if ((response.status !== 401 && response.status !== 403) || attempt === 3) { + return response + } + const challenge = extractWWWAuthenticateParams(response) + const needsAuth = + response.status === 401 || + (response.status === 403 && challenge.error === 'insufficient_scope') + if (!needsAuth) return response + const challengeKey = + response.status === 401 ? '401' : `403:${response.headers.get('www-authenticate')}` + if (authenticatedChallenges.has(challengeKey)) return response + + await response.body?.cancel() + await withMcpOauthRefreshLock( + credentialId, + async () => { + init?.signal?.throwIfAborted() + const current = await loadProvider() + const latestTokens = await current.tokens() + const refreshedElsewhere = + latestTokens && latestTokens.access_token !== tokens?.access_token + + init?.signal?.throwIfAborted() + if (!refreshedElsewhere) { + const result = await mcpAuthGuarded(current, { + serverUrl, + resourceMetadataUrl: challenge.resourceMetadataUrl, + scope: challenge.scope, + fetchFn: options.fetch, + }) + if (result !== 'AUTHORIZED') throw new UnauthorizedError() + authenticatedChallenges.add(challengeKey) + } + provider = current + currentProvider = current + }, + init?.signal ?? undefined + ) + } + } +} diff --git a/apps/sim/lib/mcp/oauth/storage.test.ts b/apps/sim/lib/mcp/oauth/storage.test.ts index ace0c16430f..e15254e1164 100644 --- a/apps/sim/lib/mcp/oauth/storage.test.ts +++ b/apps/sim/lib/mcp/oauth/storage.test.ts @@ -150,7 +150,21 @@ describe('withMcpOauthRefreshLock', () => { expect(fn).toHaveBeenCalledTimes(1) }) - it('falls open when Redis is unavailable on acquire', async () => { + it('stops waiting for a cross-process lock when the caller aborts', async () => { + mockAcquireLock.mockResolvedValue(false) + const fn = vi.fn(async () => 'should-not-run') + const controller = new AbortController() + const pending = withMcpOauthRefreshLock('row-abort', fn, controller.signal) + const assertion = expect(pending).rejects.toThrow('cancelled') + + await vi.waitFor(() => expect(mockAcquireLock).toHaveBeenCalled()) + controller.abort(new Error('cancelled')) + + await assertion + expect(fn).not.toHaveBeenCalled() + }) + + it('preserves an uncertain owner token when falling open after an acquire failure', async () => { mockAcquireLock.mockRejectedValueOnce(new Error('Redis connection refused')) const fn = vi.fn(async () => 'uncoordinated') @@ -161,6 +175,25 @@ describe('withMcpOauthRefreshLock', () => { expect(mockReleaseLock).not.toHaveBeenCalled() }) + it('cleans up an uncertain owner token before propagating cancellation', async () => { + const controller = new AbortController() + mockAcquireLock.mockImplementationOnce(async () => { + controller.abort(new Error('cancelled')) + throw new Error('Redis operation timed out') + }) + const fn = vi.fn(async () => 'should-not-run') + + await expect( + withMcpOauthRefreshLock('row-aborted-acquire', fn, controller.signal) + ).rejects.toThrow('cancelled') + + expect(mockReleaseLock).toHaveBeenCalledWith( + 'mcp:oauth:refresh:row-aborted-acquire', + expect.any(String) + ) + expect(fn).not.toHaveBeenCalled() + }) + it('releases the lock even when fn throws', async () => { mockAcquireLock.mockResolvedValue(true) const fn = vi.fn(async () => { diff --git a/apps/sim/lib/mcp/oauth/storage.ts b/apps/sim/lib/mcp/oauth/storage.ts index c2e54c2e951..3d5399e1b80 100644 --- a/apps/sim/lib/mcp/oauth/storage.ts +++ b/apps/sim/lib/mcp/oauth/storage.ts @@ -7,7 +7,7 @@ import { db } from '@sim/db' import { mcpServerOauth } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' +import { interruptibleSleep } from '@sim/utils/helpers' import { generateId, generateShortId } from '@sim/utils/id' import { and, eq, gt } from 'drizzle-orm' import { acquireLock, extendLock, releaseLock } from '@/lib/core/config/redis' @@ -275,7 +275,12 @@ const REFRESH_QUEUE_WAIT_TIMEOUT_MS = 90_000 const inflightChains = new Map>() -export async function withMcpOauthRefreshLock(rowId: string, fn: () => Promise): Promise { +export async function withMcpOauthRefreshLock( + rowId: string, + fn: () => Promise, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() const lockKey = `mcp:oauth:refresh:${rowId}` const prev = inflightChains.get(lockKey) ?? Promise.resolve() const prevSettled = prev.catch(() => undefined) @@ -285,7 +290,8 @@ export async function withMcpOauthRefreshLock(rowId: string, fn: () => Promis if (queueTimedOut) { throw new Error(`MCP OAuth refresh queue for ${rowId} abandoned after timeout`) } - return runWithRedisMutex(lockKey, rowId, fn) + signal?.throwIfAborted() + return runWithRedisMutex(lockKey, rowId, fn, signal) }) inflightChains.set(lockKey, next) const cleanup = () => { @@ -305,11 +311,25 @@ export async function withMcpOauthRefreshLock(rowId: string, fn: () => Promis }, REFRESH_QUEUE_WAIT_TIMEOUT_MS) queueTimer.unref?.() }) + let abortListener: (() => void) | undefined + const queueAbort = new Promise((_resolve, reject) => { + if (!signal) return + abortListener = () => { + try { + signal.throwIfAborted() + } catch (error) { + reject(error) + } + } + signal.addEventListener('abort', abortListener, { once: true }) + if (signal.aborted) abortListener() + }) try { - await Promise.race([prevSettled, queueDeadline]) + await Promise.race([prevSettled, queueDeadline, queueAbort]) } finally { clearTimeout(queueTimer) + if (signal && abortListener) signal.removeEventListener('abort', abortListener) } return next @@ -318,16 +338,27 @@ export async function withMcpOauthRefreshLock(rowId: string, fn: () => Promis async function runWithRedisMutex( lockKey: string, rowId: string, - fn: () => Promise + fn: () => Promise, + signal?: AbortSignal ): Promise { const ownerToken = generateShortId() const deadline = Date.now() + REFRESH_MAX_WAIT_MS while (true) { + signal?.throwIfAborted() let acquired = false try { acquired = await acquireLock(lockKey, ownerToken, REFRESH_LOCK_TTL_SEC) } catch (error) { + if (signal?.aborted) { + await releaseLock(lockKey, ownerToken).catch((releaseError) => { + logger.warn('Refresh lock cleanup after cancelled acquire failed (will expire via TTL)', { + rowId, + error: toError(releaseError).message, + }) + }) + signal.throwIfAborted() + } logger.warn('Redis unavailable, running OAuth flow uncoordinated', { rowId, error: toError(error).message, @@ -345,6 +376,7 @@ async function runWithRedisMutex( }) }, REFRESH_LOCK_EXTEND_INTERVAL_MS) try { + signal?.throwIfAborted() return await fn() } finally { clearInterval(watchdog) @@ -357,6 +389,7 @@ async function runWithRedisMutex( } } + signal?.throwIfAborted() if (Date.now() >= deadline) { // Lock still held by another process AND its watchdog is keeping it // alive — falling open would let us refresh concurrently and race the @@ -367,6 +400,6 @@ async function runWithRedisMutex( `MCP OAuth refresh lock for ${rowId} held longer than ${REFRESH_MAX_WAIT_MS}ms` ) } - await sleep(REFRESH_POLL_INTERVAL_MS) + await interruptibleSleep(REFRESH_POLL_INTERVAL_MS, signal) } } diff --git a/apps/sim/lib/mcp/service-pool.test.ts b/apps/sim/lib/mcp/service-pool.test.ts index 144890f0103..17125bbd3a6 100644 --- a/apps/sim/lib/mcp/service-pool.test.ts +++ b/apps/sim/lib/mcp/service-pool.test.ts @@ -122,6 +122,7 @@ vi.mock('@/lib/mcp/storage', () => ({ getMcpCacheType: () => 'memory', })) +import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth' import { mcpService } from '@/lib/mcp/service' describe('McpService connection reuse wiring', () => { @@ -141,6 +142,55 @@ describe('McpService connection reuse wiring', () => { resetDbChainMock() }) + it('propagates initial managed credential errors before constructing a connection', async () => { + dbChainMockFns.limit.mockResolvedValue([{ ...SERVER_ROW, authType: 'oauth' }]) + const error = new Error('Managed credential disabled') + await expect( + mcpService.executeManagedMcpTool({ + connectionId: 'disabled-grant', + serverId: SERVER_ROW.id, + workspaceId: WORKSPACE_ID, + toolCall: { name: 'slow', arguments: {} }, + loadAuthProvider: vi.fn().mockRejectedValue(error), + }) + ).rejects.toBe(error) + expect(MockMcpClient).not.toHaveBeenCalled() + }) + + it('keeps managed tool execution outside the refresh mutex and passes a reloadable grant', async () => { + dbChainMockFns.limit.mockResolvedValue([{ ...SERVER_ROW, authType: 'oauth' }]) + const initialProvider = {} + const loadAuthProvider = vi.fn().mockResolvedValue(initialProvider) + const signal = new AbortController().signal + await mcpService.executeManagedMcpTool({ + connectionId: 'personal-grant', + serverId: SERVER_ROW.id, + workspaceId: WORKSPACE_ID, + toolCall: { name: 'slow', arguments: {} }, + loadAuthProvider, + signal, + timeoutMs: 300_000, + }) + + expect(loadAuthProvider).toHaveBeenCalledTimes(1) + expect(withMcpOauthRefreshLock).not.toHaveBeenCalled() + expect(MockMcpClient).toHaveBeenCalledWith( + expect.objectContaining({ + oauthCredentials: { + credentialId: 'personal-grant', + loadProvider: loadAuthProvider, + initialProvider, + }, + }) + ) + expect(mockCallTool).toHaveBeenCalledWith( + { name: 'slow', arguments: {} }, + { signal, timeoutMs: 300_000 } + ) + expect(mockAcquire).not.toHaveBeenCalled() + expect(mockDisconnect).toHaveBeenCalledTimes(1) + }) + it('leases from the pool (keyed by server+workspace+user) and never disconnects on a hit', async () => { await mcpService.executeTool(USER_ID, 'server-1', { name: 'do', arguments: {} }, WORKSPACE_ID) diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 9aeb3620556..f2c27a5bf78 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -22,12 +22,8 @@ import { validateMcpDomain, validateMcpServerSsrf, } from '@/lib/mcp/domain-check' -import { - getOrCreateOauthRow, - loadPreregisteredClient, - SimMcpOauthProvider, - withMcpOauthRefreshLock, -} from '@/lib/mcp/oauth' +import { getOrCreateOauthRow, loadPreregisteredClient, SimMcpOauthProvider } from '@/lib/mcp/oauth' +import type { McpOauthCredentials } from '@/lib/mcp/oauth/coordinated-fetch' import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config' import { createMcpCacheAdapter, @@ -431,12 +427,7 @@ class McpService { } const workspaceId = config.workspaceId - // Load the row inside the refresh lock so concurrent callers observe tokens - // written by a predecessor refresh, rather than a stale snapshot. Without - // this, the second caller's provider would hold a rotated-out refresh token - // and the SDK would trip `invalid_grant`. The lock is keyed on serverId - // since the row is per-server. - return withMcpOauthRefreshLock(config.id, async () => { + const loadProvider = async () => { const row = await getOrCreateOauthRow({ mcpServerId: config.id, userId, @@ -446,22 +437,26 @@ class McpService { throw new McpOauthAuthorizationRequiredError(config.id, config.name) } const preregistered = await loadPreregisteredClient(config.id) - const authProvider = new SimMcpOauthProvider({ row, preregistered }) - const client = new McpClient({ - config, - securityPolicy, - authProvider, - resolvedIP: resolvedIP ?? undefined, - resolvedSecretTraceProvenance, - }) - await client.connect({ signal }) - return client + return new SimMcpOauthProvider({ row, preregistered }) + } + const client = new McpClient({ + config, + securityPolicy, + oauthCredentials: { + credentialId: config.id, + loadProvider, + initialProvider: await loadProvider(), + }, + resolvedIP: resolvedIP ?? undefined, + resolvedSecretTraceProvenance, }) + await client.connect({ signal }) + return client } private async createManagedOauthClient( config: McpServerConfig, - authProvider: OAuthClientProvider, + auth: OAuthClientProvider | McpOauthCredentials, signal?: AbortSignal ): Promise { if (config.authType !== 'oauth' || !config.url) { @@ -484,7 +479,9 @@ class McpService { maxToolExecutionsPerHour: 1000, allowedOrigins: [new URL(config.url).origin], }, - authProvider, + ...('loadProvider' in auth + ? { oauthCredentials: { ...auth, initialProvider: await auth.loadProvider() } } + : { authProvider: auth }), resolvedIP: resolvedIP ?? undefined, }) await client.connect({ signal }) @@ -494,7 +491,7 @@ class McpService { async discoverManagedMcpTools( serverId: string, workspaceId: string, - authProvider: OAuthClientProvider, + auth: OAuthClientProvider | McpOauthCredentials, signal?: AbortSignal, options: { requireComplete?: boolean } = {} ): Promise { @@ -502,7 +499,7 @@ class McpService { if (!config) throw new Error('Managed MCP server is unavailable') return this.withServerClient( { key: '', serverId, allowPool: false }, - () => this.createManagedOauthClient(config, authProvider, signal), + () => this.createManagedOauthClient(config, auth, signal), (client) => options.requireComplete ? client.listTools(signal, { requireComplete: true }) @@ -525,21 +522,19 @@ class McpService { const effectiveConfig = params.extraHeaders ? { ...config, headers: { ...config.headers, ...params.extraHeaders } } : config - return withMcpOauthRefreshLock(params.connectionId, () => - this.withServerClient( - { key: '', serverId: params.serverId, allowPool: false }, - async () => - this.createManagedOauthClient( - effectiveConfig, - await params.loadAuthProvider(), - params.signal - ), - (client) => - client.callTool(params.toolCall, { - signal: params.signal, - timeoutMs: params.timeoutMs, - }) - ) + return this.withServerClient( + { key: '', serverId: params.serverId, allowPool: false }, + () => + this.createManagedOauthClient( + effectiveConfig, + { credentialId: params.connectionId, loadProvider: params.loadAuthProvider }, + params.signal + ), + (client) => + client.callTool(params.toolCall, { + signal: params.signal, + timeoutMs: params.timeoutMs, + }) ) } diff --git a/apps/sim/lib/mcp/types.ts b/apps/sim/lib/mcp/types.ts index e4a557329d9..d2341ca8118 100644 --- a/apps/sim/lib/mcp/types.ts +++ b/apps/sim/lib/mcp/types.ts @@ -4,7 +4,7 @@ import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved- export type McpTransport = 'streamable-http' -/** `oauth` uses the SDK's authProvider; `headers` is a static map; `none` is unauthenticated. */ +/** `oauth` uses an OAuth grant; `headers` is a static map; `none` is unauthenticated. */ export type McpAuthType = 'none' | 'headers' | 'oauth' export interface McpServerStatusConfig { @@ -203,12 +203,13 @@ export interface McpClientOptions { */ resolvedIP?: string /** - * SDK-compatible OAuth client provider. When provided, the underlying - * StreamableHTTPClientTransport delegates token discovery, refresh, and - * 401 recovery to it. Should be supplied for `authType === 'oauth'` - * server configs. + * SDK provider for an enrollment whose grant has not been persisted yet. + * Persisted runtime grants must use oauthCredentials to coordinate refreshes. + * Supply exactly one of these for an OAuth server. */ authProvider?: import('@modelcontextprotocol/sdk/client/auth.js').OAuthClientProvider + /** Runtime OAuth grants coordinate refreshes across clients using persisted credentials. */ + oauthCredentials?: import('@/lib/mcp/oauth/coordinated-fetch').McpOauthSession /** Encrypted-only provenance for Secrets-tab references resolved into this connection. */ resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 } diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 337b56aa435..8f14f28e6de 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { account, credential } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ coalesceLocally: vi.fn(), @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ fatal: vi.fn(), }, refreshOAuthToken: vi.fn(), + decryptQuickBooksOAuthClientConfig: vi.fn(), withLeaderLock: vi.fn(), })) @@ -49,6 +50,10 @@ vi.mock('@/lib/oauth/oauth', () => ({ refreshOAuthToken: mocks.refreshOAuthToken, })) +vi.mock('@/lib/oauth/quickbooks-client-config', () => ({ + decryptQuickBooksOAuthClientConfig: mocks.decryptQuickBooksOAuthClientConfig, +})) + vi.mock('@/lib/oauth/slack', () => ({ extractSlackTeamId: (value: string | null | undefined) => value?.match(/^([TE][A-Z0-9]+)-/)?.[1] ?? null, @@ -155,6 +160,10 @@ describe('resolveCredentialTokenBundle selector privacy', () => { resetDbChainMock() }) + afterEach(() => { + vi.useRealTimers() + }) + it('HMACs OAuth and Slack refresh identities and suppresses raw identifiers and provider errors', async () => { for (const providerId of ['google', 'slack'] as const) { const observed = await observeRefresh(providerId, 'selector') @@ -199,4 +208,73 @@ describe('resolveCredentialTokenBundle selector privacy', () => { expect(slack.logs).toContain(RAW_SLACK_TEAM_ID) expect(slack.logs).toContain(RAW_PROVIDER_ERROR) }) + + it('refreshes QuickBooks with its own encrypted app config and rotates both expirations', async () => { + const now = new Date('2026-09-04T18:00:00.000Z') + vi.useFakeTimers() + vi.setSystemTime(now) + mocks.getRecentTerminalError.mockResolvedValue(null) + mocks.coalesceLocally.mockImplementation( + async (_key: string, producer: () => Promise) => producer() + ) + mocks.withLeaderLock.mockImplementation(async (options: { onLeader: () => Promise }) => + options.onLeader() + ) + mocks.decryptQuickBooksOAuthClientConfig.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', + }) + mocks.refreshOAuthToken.mockResolvedValue({ + ok: true, + accessToken: 'new-access-token', + expiresIn: 3600, + refreshToken: 'new-refresh-token', + refreshTokenExpiresIn: 8_726_400, + }) + queueTableRows(credential, [ + { + id: 'credential-1', + type: 'oauth', + accountId: 'account-1', + workspaceId: 'workspace-1', + providerId: null, + }, + ]) + queueTableRows(account, [ + { + id: 'account-1', + accountId: + 'quickbooks:v2:NkYPLLqX2cM-QABxg0vbv71mQS9s_aRP3v7ZKLvnJyo:sandbox:1234567890:dXNlci0x', + providerId: 'quickbooks', + userId: 'user-1', + accessToken: 'expired-access-token', + refreshToken: 'old-refresh-token', + accessTokenExpiresAt: new Date(now.getTime() - 1), + refreshTokenExpiresAt: new Date(now.getTime() + 1000), + oauthConfig: 'encrypted-client-config', + updatedAt: new Date(0), + }, + ]) + + await expect( + resolveCredentialTokenBundle('credential-1', 'user-1', 'request-1') + ).resolves.toEqual({ accessToken: 'new-access-token' }) + + expect(mocks.refreshOAuthToken).toHaveBeenCalledWith('quickbooks', 'old-refresh-token', { + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + accessTokenExpiresAt: new Date(now.getTime() + 3_600_000), + refreshTokenExpiresAt: new Date(now.getTime() + 8_726_400_000), + }) + ) + }) }) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 0962cf8cb56..97a9747c4e2 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -28,6 +28,7 @@ import { PROACTIVE_REFRESH_THRESHOLD_DAYS, } from '@/lib/oauth/microsoft' import { refreshOAuthToken } from '@/lib/oauth/oauth' +import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config' import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination' import { extractSlackTeamId, @@ -754,6 +755,7 @@ interface CoalescedRefreshOptions { refreshToken: string /** External provider account id (`account.accountId`), used to scope Slack refreshes per installation. */ providerAccountId?: string | null + oauthConfig?: string | null requestId?: string userId?: string privacyMode?: 'selector' @@ -778,6 +780,7 @@ async function performCoalescedRefresh({ providerId, refreshToken, providerAccountId, + oauthConfig, requestId, userId, privacyMode, @@ -850,7 +853,16 @@ async function performCoalescedRefresh({ refreshTokenToUse = freshest.refreshToken } - const result = await refreshOAuthToken(providerId, refreshTokenToUse) + let quickBooksClientConfig + if (providerId === 'quickbooks') { + if (!oauthConfig) { + throw new Error('QuickBooks OAuth client configuration is missing') + } + quickBooksClientConfig = await decryptQuickBooksOAuthClientConfig(oauthConfig) + } + const result = quickBooksClientConfig + ? await refreshOAuthToken(providerId, refreshTokenToUse, quickBooksClientConfig) + : await refreshOAuthToken(providerId, refreshTokenToUse) if (!result.ok) { logger.error('Failed to refresh token', { @@ -899,6 +911,11 @@ async function performCoalescedRefresh({ if (isMicrosoftProvider(providerId)) { updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() } + if (result.refreshTokenExpiresIn) { + updateData.refreshTokenExpiresAt = new Date( + Date.now() + result.refreshTokenExpiresIn * 1000 + ) + } await db.update(account).set(updateData).where(eq(account.id, accountId)) } @@ -965,6 +982,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise idToken: account.idToken, scope: account.scope, updatedAt: account.updatedAt, + oauthConfig: account.oauthConfig, }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) @@ -999,6 +1017,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise providerId, refreshToken: credential.refreshToken!, providerAccountId: credential.providerAccountId, + oauthConfig: credential.oauthConfig, userId, }) if (fresh) return fresh @@ -1104,6 +1123,7 @@ export async function resolveCredentialTokenBundle( providerId: credential.providerId, refreshToken: credential.refreshToken!, providerAccountId: credential.accountId, + oauthConfig: credential.oauthConfig, requestId, userId: credential.userId, privacyMode: options?.privacyMode, @@ -1212,6 +1232,7 @@ export async function refreshTokenIfNeeded( providerId: credential.providerId, refreshToken: credential.refreshToken!, providerAccountId: credential.accountId, + oauthConfig: credential.oauthConfig, requestId, userId: credential.userId, }) diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index 15a2d05d434..b4ee51d2a42 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -423,6 +423,15 @@ describe('OAuth Token Refresh', () => { providerId: 'bitbucket', endpoint: 'https://bitbucket.org/site/oauth2/access_token', }, + { + name: 'QuickBooks', + providerId: 'quickbooks', + endpoint: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + clientOverride: { + clientId: 'quickbooks_client_id', + clientSecret: 'quickbooks_client_secret', + }, + }, { name: 'X (Twitter)', providerId: 'x', endpoint: 'https://api.x.com/2/oauth2/token' }, { name: 'Confluence', @@ -453,14 +462,16 @@ describe('OAuth Token Refresh', () => { }, ] - basicAuthProviders.forEach(({ name, providerId, endpoint }) => { + basicAuthProviders.forEach(({ name, providerId, endpoint, clientOverride }) => { it.concurrent( `should send ${name} request with Basic Auth header and no credentials in body`, async () => { const mockFetch = createMockFetch(defaultOAuthResponse) const refreshToken = 'test_refresh_token' - await withMockFetch(mockFetch, () => refreshOAuthToken(providerId, refreshToken)) + await withMockFetch(mockFetch, () => + refreshOAuthToken(providerId, refreshToken, clientOverride) + ) expect(mockFetch).toHaveBeenCalledWith( endpoint, @@ -501,6 +512,58 @@ describe('OAuth Token Refresh', () => { } ) }) + + it('preserves Intuit refresh-token lifetime metadata', async () => { + const mockFetch = createMockFetch({ + ok: true, + json: { + access_token: 'new-access-token', + expires_in: 3600, + refresh_token: 'new-refresh-token', + x_refresh_token_expires_in: 8_726_400, + }, + }) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('quickbooks', 'old-refresh-token', { + clientId: 'quickbooks-client-id', + clientSecret: 'quickbooks-client-secret', + environment: 'sandbox', + }) + ) + + expect(result).toEqual({ + ok: true, + accessToken: 'new-access-token', + expiresIn: 3600, + refreshToken: 'new-refresh-token', + refreshTokenExpiresIn: 8_726_400, + }) + }) + + it('rejects a QuickBooks refresh response that omits its rotated refresh token', async () => { + const mockFetch = createMockFetch({ + ok: true, + json: { + access_token: 'new-access-token', + expires_in: 3600, + x_refresh_token_expires_in: 8_726_400, + }, + }) + + await expect( + withMockFetch(mockFetch, () => + refreshOAuthToken('quickbooks', 'old-refresh-token', { + clientId: 'quickbooks-client-id', + clientSecret: 'quickbooks-client-secret', + environment: 'sandbox', + }) + ) + ).resolves.toEqual({ + ok: false, + message: 'Invalid QuickBooks token refresh response', + }) + }) }) describe('Body Credential Providers', () => { diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 692a59510fe..eac8084354c 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -49,6 +49,7 @@ import { NotionIcon, OutlookIcon, PipedriveIcon, + QuickBooksIcon, RedditIcon, SalesforceIcon, ShopifyIcon, @@ -80,6 +81,8 @@ import { import { getDocusignOAuthUrl } from '@/lib/oauth/docusign' import { parseInstagramLongLivedToken } from '@/lib/oauth/instagram' import { MONDAY_OAUTH_TOKEN_URL, resolveMondayAccessTokenExpiresAt } from '@/lib/oauth/monday' +import type { QuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config' +import { QUICKBOOKS_TOKEN_URL } from '@/lib/oauth/quickbooks-constants' import { SALESFORCE_ADDITIONAL_PROVIDER_IDS, SALESFORCE_LOGIN_HOSTS, @@ -1205,6 +1208,57 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'pipedrive', }, + quickbooks: { + name: 'QuickBooks', + icon: QuickBooksIcon, + services: { + quickbooks: { + name: 'QuickBooks', + description: + 'Access company data and manage customers, vendors, and items in QuickBooks Online.', + providerId: 'quickbooks', + icon: QuickBooksIcon, + baseProviderIcon: QuickBooksIcon, + scopes: ['openid', 'profile', 'email', 'com.intuit.quickbooks.accounting'], + clientConfiguration: { + redirectPath: '/api/auth/oauth2/callback/quickbooks', + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Enter your Intuit app client ID', + secret: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Enter your Intuit app client secret', + secret: true, + }, + { + id: 'environment', + label: 'Environment', + placeholder: 'Select an Intuit environment', + secret: false, + options: [ + { value: 'sandbox', label: 'Sandbox' }, + { value: 'production', label: 'Production' }, + ], + hint: 'Use the environment that matches the credentials in your Intuit app.', + }, + { + id: 'webhookVerifierToken', + label: 'Webhook verifier token', + placeholder: 'Enter your Intuit app webhook verifier token', + secret: true, + hint: 'Used only to authenticate QuickBooks webhook triggers for this Intuit app.', + }, + ], + }, + }, + }, + defaultService: 'quickbooks', + }, hubspot: { name: 'HubSpot', icon: HubspotIcon, @@ -1491,7 +1545,13 @@ function getConfiguredClientCredentials +): ProviderAuthConfig { + if (clientOverride && provider !== 'quickbooks') { + throw new Error(`OAuth client override is not supported for provider ${provider}`) + } switch (provider) { case 'google': { const { clientId, clientSecret } = getConfiguredClientCredentials( @@ -1813,6 +1873,18 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: true, } } + case 'quickbooks': { + if (!clientOverride) { + throw new Error('QuickBooks OAuth client configuration is missing') + } + return { + tokenEndpoint: QUICKBOOKS_TOKEN_URL, + clientId: clientOverride.clientId, + clientSecret: clientOverride.clientSecret, + useBasicAuth: true, + supportsRefreshTokenRotation: true, + } + } case 'hubspot': { const { clientId, clientSecret } = getConfiguredClientCredentials( 'hubspot', @@ -2085,6 +2157,7 @@ export interface RefreshTokenSuccess { accessToken: string expiresIn: number refreshToken: string + refreshTokenExpiresIn?: number } export interface RefreshTokenFailure { @@ -2199,13 +2272,14 @@ async function refreshInstagramLongLivedToken( export async function refreshOAuthToken( providerId: string, - refreshToken: string + refreshToken: string, + clientOverride?: Pick ): Promise { const exactSecrets = [refreshToken] try { const provider = getBaseProviderForService(providerId) - const config = getProviderAuthConfig(provider) + const config = getProviderAuthConfig(provider, clientOverride) if (config.clientSecret) exactSecrets.push(config.clientSecret) if (config.refreshStrategy === 'instagram_long_lived') { @@ -2291,6 +2365,10 @@ export async function refreshOAuthToken( logger.warn('Monday token refresh response omitted its rotating refresh token') return { ok: false, message: 'Invalid Monday token refresh response' } } + if (provider === 'quickbooks' && !newRefreshToken) { + logger.warn('QuickBooks token refresh response omitted its rotating refresh token') + return { ok: false, message: 'Invalid QuickBooks token refresh response' } + } const rawExpiresIn = data.expires_in ?? data.expiresIn const parsedExpiresIn = @@ -2311,6 +2389,18 @@ export async function refreshOAuthToken( ) : (responseExpiresIn ?? 3600) + const rawRefreshTokenExpiresIn = data.x_refresh_token_expires_in + const parsedRefreshTokenExpiresIn = + typeof rawRefreshTokenExpiresIn === 'number' || typeof rawRefreshTokenExpiresIn === 'string' + ? Number(rawRefreshTokenExpiresIn) + : Number.NaN + const refreshTokenExpiresIn = + provider === 'quickbooks' && + Number.isSafeInteger(parsedRefreshTokenExpiresIn) && + parsedRefreshTokenExpiresIn > 0 + ? parsedRefreshTokenExpiresIn + : undefined + if (!accessToken) { // Log only the shape, never `data` itself - on a partial success it can // carry live tokens. @@ -2332,6 +2422,7 @@ export async function refreshOAuthToken( accessToken, expiresIn, refreshToken: newRefreshToken ?? refreshToken, + ...(refreshTokenExpiresIn ? { refreshTokenExpiresIn } : {}), } } catch (error) { const normalized = toError(error) diff --git a/apps/sim/lib/oauth/quickbooks-client-config.test.ts b/apps/sim/lib/oauth/quickbooks-client-config.test.ts new file mode 100644 index 00000000000..5480e569764 --- /dev/null +++ b/apps/sim/lib/oauth/quickbooks-client-config.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDecryptSecret, mockEncryptSecret } = vi.hoisted(() => ({ + mockDecryptSecret: vi.fn(), + mockEncryptSecret: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: mockDecryptSecret, + encryptSecret: mockEncryptSecret, +})) + +import { + decryptQuickBooksOAuthClientConfig, + encryptQuickBooksOAuthClientConfig, + normalizeQuickBooksOAuthClientConfig, + QuickBooksOAuthClientConfigurationError, +} from '@/lib/oauth/quickbooks-client-config' + +describe('QuickBooks OAuth client configuration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('normalizes and encrypts the complete app configuration as one secret', async () => { + mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + + await expect( + encryptQuickBooksOAuthClientConfig({ + clientId: ' client-id ', + clientSecret: ' client-secret ', + environment: 'sandbox', + webhookVerifierToken: ' verifier-token ', + }) + ).resolves.toBe('ciphertext') + expect(mockEncryptSecret).toHaveBeenCalledWith( + JSON.stringify({ + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', + }) + ) + }) + + it('decrypts and validates the stored configuration', async () => { + mockDecryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'production', + webhookVerifierToken: 'verifier-token', + }), + }) + + await expect(decryptQuickBooksOAuthClientConfig('ciphertext')).resolves.toEqual({ + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'production', + webhookVerifierToken: 'verifier-token', + }) + }) + + it('rejects incomplete, invalid, and malformed configurations', async () => { + expect(() => + normalizeQuickBooksOAuthClientConfig({ + clientId: '', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', + }) + ).toThrow('QuickBooks client ID must be between 1 and 255 characters') + + expect(() => + normalizeQuickBooksOAuthClientConfig({ + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: '', + }) + ).toThrow('QuickBooks webhook verifier token must be between 1 and 512 characters') + + mockDecryptSecret.mockResolvedValueOnce({ decrypted: '{' }) + await expect(decryptQuickBooksOAuthClientConfig('malformed')).rejects.toMatchObject({ + name: 'QuickBooksOAuthClientConfigurationError', + message: 'QuickBooks OAuth client configuration is invalid', + }) + }) + + it('does not reclassify encryption infrastructure failures as invalid configuration', async () => { + const deploymentError = new Error('encryption key is unavailable') + mockDecryptSecret.mockRejectedValue(deploymentError) + + await expect(decryptQuickBooksOAuthClientConfig('ciphertext')).rejects.toBe(deploymentError) + expect(deploymentError).not.toBeInstanceOf(QuickBooksOAuthClientConfigurationError) + }) +}) diff --git a/apps/sim/lib/oauth/quickbooks-client-config.ts b/apps/sim/lib/oauth/quickbooks-client-config.ts new file mode 100644 index 00000000000..635955acaa6 --- /dev/null +++ b/apps/sim/lib/oauth/quickbooks-client-config.ts @@ -0,0 +1,121 @@ +import { createHash } from 'node:crypto' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import type { QuickBooksEnvironment } from '@/tools/quickbooks/client' + +export interface QuickBooksOAuthClientConfig { + clientId: string + clientSecret: string + environment: QuickBooksEnvironment + webhookVerifierToken: string +} + +const MAX_CLIENT_ID_LENGTH = 255 +const MAX_CLIENT_SECRET_LENGTH = 512 +const MAX_WEBHOOK_VERIFIER_TOKEN_LENGTH = 512 + +export const QUICKBOOKS_WEBHOOK_APP_KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/ + +export class QuickBooksOAuthClientConfigurationError extends Error { + constructor(message: string) { + super(message) + this.name = 'QuickBooksOAuthClientConfigurationError' + } +} + +export function normalizeQuickBooksOAuthClientConfig( + value: QuickBooksOAuthClientConfig +): QuickBooksOAuthClientConfig { + const clientId = value.clientId.trim() + const clientSecret = value.clientSecret.trim() + if (!clientId || clientId.length > MAX_CLIENT_ID_LENGTH) { + throw new QuickBooksOAuthClientConfigurationError( + 'QuickBooks client ID must be between 1 and 255 characters' + ) + } + if (!clientSecret || clientSecret.length > MAX_CLIENT_SECRET_LENGTH) { + throw new QuickBooksOAuthClientConfigurationError( + 'QuickBooks client secret must be between 1 and 512 characters' + ) + } + if (value.environment !== 'sandbox' && value.environment !== 'production') { + throw new QuickBooksOAuthClientConfigurationError( + 'QuickBooks environment must be either sandbox or production' + ) + } + const webhookVerifierToken = value.webhookVerifierToken.trim() + if (!webhookVerifierToken || webhookVerifierToken.length > MAX_WEBHOOK_VERIFIER_TOKEN_LENGTH) { + throw new QuickBooksOAuthClientConfigurationError( + 'QuickBooks webhook verifier token must be between 1 and 512 characters' + ) + } + return { + clientId, + clientSecret, + environment: value.environment, + webhookVerifierToken, + } +} + +/** Stable, non-secret route key for one Intuit app in one environment. */ +export function deriveQuickBooksWebhookAppKey( + value: Pick +): string { + const clientId = value.clientId.trim() + if (!clientId || clientId.length > MAX_CLIENT_ID_LENGTH) { + throw new QuickBooksOAuthClientConfigurationError( + 'QuickBooks client ID must be between 1 and 255 characters' + ) + } + if (value.environment !== 'sandbox' && value.environment !== 'production') { + throw new QuickBooksOAuthClientConfigurationError( + 'QuickBooks environment must be either sandbox or production' + ) + } + return createHash('sha256') + .update(`${value.environment}\0${clientId}`, 'utf8') + .digest('base64url') +} + +export async function encryptQuickBooksOAuthClientConfig( + value: QuickBooksOAuthClientConfig +): Promise { + const normalized = normalizeQuickBooksOAuthClientConfig(value) + const { encrypted } = await encryptSecret(JSON.stringify(normalized)) + return encrypted +} + +export async function decryptQuickBooksOAuthClientConfig( + encryptedValue: string +): Promise { + const { decrypted } = await decryptSecret(encryptedValue) + let value: unknown + try { + value = JSON.parse(decrypted) + } catch { + throw new QuickBooksOAuthClientConfigurationError( + 'QuickBooks OAuth client configuration is invalid' + ) + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new QuickBooksOAuthClientConfigurationError( + 'QuickBooks OAuth client configuration is invalid' + ) + } + const record = value as Record + if ( + typeof record.clientId !== 'string' || + typeof record.clientSecret !== 'string' || + (record.environment !== 'sandbox' && record.environment !== 'production') || + typeof record.webhookVerifierToken !== 'string' + ) { + throw new QuickBooksOAuthClientConfigurationError( + 'QuickBooks OAuth client configuration is invalid' + ) + } + return normalizeQuickBooksOAuthClientConfig({ + clientId: record.clientId, + clientSecret: record.clientSecret, + environment: record.environment, + webhookVerifierToken: record.webhookVerifierToken, + }) +} diff --git a/apps/sim/lib/oauth/quickbooks-constants.ts b/apps/sim/lib/oauth/quickbooks-constants.ts new file mode 100644 index 00000000000..7859491f676 --- /dev/null +++ b/apps/sim/lib/oauth/quickbooks-constants.ts @@ -0,0 +1,2 @@ +export const QUICKBOOKS_AUTHORIZATION_URL = 'https://appcenter.intuit.com/connect/oauth2' +export const QUICKBOOKS_TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer' diff --git a/apps/sim/lib/oauth/quickbooks-state.test.ts b/apps/sim/lib/oauth/quickbooks-state.test.ts new file mode 100644 index 00000000000..40a841eef83 --- /dev/null +++ b/apps/sim/lib/oauth/quickbooks-state.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' +import { createQuickBooksOAuthState, parseQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state' + +const USER_ID = 'user-1' + +describe('QuickBooks OAuth state', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps overlapping drafts bound to their own return destinations', () => { + const first = createQuickBooksOAuthState({ + userId: USER_ID, + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + }) + const second = createQuickBooksOAuthState({ + userId: USER_ID, + draftId: 'draft-2', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + }) + + expect(parseQuickBooksOAuthState({ state: first, userId: USER_ID })).toEqual({ + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + }) + expect(parseQuickBooksOAuthState({ state: second, userId: USER_ID })).toEqual({ + draftId: 'draft-2', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + }) + }) + + it('rejects tampering, cross-user reuse, and expired state', () => { + const issuedAt = new Date('2026-09-04T18:00:00.000Z') + vi.spyOn(Date, 'now').mockReturnValue(issuedAt.getTime()) + const state = createQuickBooksOAuthState({ + userId: USER_ID, + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected', + }) + const [payload, signature] = state.split('.') + + expect(() => + parseQuickBooksOAuthState({ state: `${payload}x.${signature}`, userId: USER_ID }) + ).toThrow('QuickBooks OAuth state signature is invalid') + expect(() => parseQuickBooksOAuthState({ state, userId: 'user-2', now: issuedAt })).toThrow( + 'QuickBooks OAuth state belongs to a different user' + ) + expect( + parseQuickBooksOAuthState({ + state, + userId: USER_ID, + now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS), + }) + ).toEqual({ + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected', + }) + expect(() => + parseQuickBooksOAuthState({ + state, + userId: USER_ID, + now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS + 1), + }) + ).toThrow('QuickBooks OAuth state is expired') + }) +}) diff --git a/apps/sim/lib/oauth/quickbooks-state.ts b/apps/sim/lib/oauth/quickbooks-state.ts new file mode 100644 index 00000000000..e4edbf6ee5c --- /dev/null +++ b/apps/sim/lib/oauth/quickbooks-state.ts @@ -0,0 +1,84 @@ +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { generateId } from '@sim/utils/id' +import { env } from '@/lib/core/config/env' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' + +const QUICKBOOKS_OAUTH_STATE_VERSION = 1 + +interface QuickBooksOAuthStatePayload { + v: typeof QUICKBOOKS_OAUTH_STATE_VERSION + nonce: string + userId: string + draftId: string + returnUrl: string + issuedAt: number +} + +function isQuickBooksOAuthStatePayload(value: unknown): value is QuickBooksOAuthStatePayload { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const payload = value as Record + return ( + payload.v === QUICKBOOKS_OAUTH_STATE_VERSION && + typeof payload.nonce === 'string' && + payload.nonce.length > 0 && + typeof payload.userId === 'string' && + payload.userId.length > 0 && + typeof payload.draftId === 'string' && + payload.draftId.length > 0 && + typeof payload.returnUrl === 'string' && + payload.returnUrl.length > 0 && + typeof payload.issuedAt === 'number' && + Number.isSafeInteger(payload.issuedAt) + ) +} + +/** Creates a signed, user-bound state token without exposing OAuth client secrets. */ +export function createQuickBooksOAuthState(params: { + userId: string + draftId: string + returnUrl: string +}): string { + const payload: QuickBooksOAuthStatePayload = { + v: QUICKBOOKS_OAUTH_STATE_VERSION, + nonce: generateId(), + userId: params.userId, + draftId: params.draftId, + returnUrl: params.returnUrl, + issuedAt: Date.now(), + } + const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + return `${encoded}.${hmacSha256Hex(encoded, env.BETTER_AUTH_SECRET)}` +} + +/** Verifies state integrity, expiry, and ownership before returning its draft binding. */ +export function parseQuickBooksOAuthState(params: { state: string; userId: string; now?: Date }): { + draftId: string + returnUrl: string +} { + const [encoded, signature, extra] = params.state.split('.') + if (!encoded || !signature || extra !== undefined) { + throw new Error('QuickBooks OAuth state is malformed') + } + if (!safeCompare(signature, hmacSha256Hex(encoded, env.BETTER_AUTH_SECRET))) { + throw new Error('QuickBooks OAuth state signature is invalid') + } + + let decoded: unknown + try { + decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) + } catch { + throw new Error('QuickBooks OAuth state payload is invalid') + } + if (!isQuickBooksOAuthStatePayload(decoded)) { + throw new Error('QuickBooks OAuth state payload is invalid') + } + if (decoded.userId !== params.userId) { + throw new Error('QuickBooks OAuth state belongs to a different user') + } + const now = params.now?.getTime() ?? Date.now() + if (decoded.issuedAt > now || now - decoded.issuedAt > CREDENTIAL_DRAFT_TTL_MS) { + throw new Error('QuickBooks OAuth state is expired') + } + return { draftId: decoded.draftId, returnUrl: decoded.returnUrl } +} diff --git a/apps/sim/lib/oauth/quickbooks.test.ts b/apps/sim/lib/oauth/quickbooks.test.ts new file mode 100644 index 00000000000..c415201976e --- /dev/null +++ b/apps/sim/lib/oauth/quickbooks.test.ts @@ -0,0 +1,262 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() })) + +const CLIENT_CONFIG = { + clientId: 'quickbooks-client-id', + clientSecret: 'quickbooks-client-secret', + environment: 'sandbox' as const, + webhookVerifierToken: 'quickbooks-webhook-verifier-token', +} + +import { + createQuickBooksAccountId, + fetchQuickBooksConnectionProfile, + parseQuickBooksAccountId, + revokeQuickBooksToken, +} from '@/lib/oauth/quickbooks' +import { deriveQuickBooksWebhookAppKey } from '@/lib/oauth/quickbooks-client-config' + +describe('QuickBooks account identity', () => { + it('round-trips an opaque OpenID subject without narrowing its valid punctuation', () => { + const accountId = createQuickBooksAccountId('123456789', 'issuer:subject', CLIENT_CONFIG) + const appKey = deriveQuickBooksWebhookAppKey(CLIENT_CONFIG) + + expect(parseQuickBooksAccountId(accountId)).toEqual({ + appKey, + realmId: '123456789', + subject: 'issuer:subject', + environment: 'sandbox', + }) + }) +}) + +describe('fetchQuickBooksConnectionProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('binds the documented callback realm to the documented UserInfo identity', async () => { + mockFetch + .mockResolvedValueOnce( + Response.json({ + sub: 'intuit-user-1', + givenName: 'Ada', + familyName: 'Lovelace', + email: 'ada@example.com', + emailVerified: true, + }) + ) + .mockResolvedValueOnce( + Response.json({ CompanyInfo: { Id: '1', CompanyName: 'Analytical Engines' } }) + ) + + const profile = await fetchQuickBooksConnectionProfile( + 'access-token', + '123456789', + CLIENT_CONFIG + ) + + expect(profile).toMatchObject({ + realmId: '123456789', + subject: 'intuit-user-1', + environment: 'sandbox', + name: 'Ada Lovelace', + email: 'ada@example.com', + emailVerified: true, + }) + const appKey = deriveQuickBooksWebhookAppKey(CLIENT_CONFIG) + expect(profile.accountId).toBe(`quickbooks:v2:${appKey}:sandbox:123456789:aW50dWl0LXVzZXItMQ`) + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer access-token' }), + }) + ) + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + href: 'https://sandbox-quickbooks.api.intuit.com/v3/company/123456789/companyinfo/123456789?minorversion=75', + }), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer access-token' }), + }) + ) + }) + + it('fails closed when the callback company cannot be read with the issued token', async () => { + mockFetch + .mockResolvedValueOnce( + Response.json({ + sub: 'intuit-user-1', + givenName: 'Ada', + email: 'ada@example.com', + emailVerified: true, + }) + ) + .mockResolvedValueOnce( + Response.json({ Fault: { Error: [{ Message: 'AuthenticationFailed' }] } }, { status: 401 }) + ) + + await expect( + fetchQuickBooksConnectionProfile('access-token', '123456789', CLIENT_CONFIG) + ).rejects.toThrow('QuickBooks company validation failed with HTTP 401') + }) + + it('does not confuse the CompanyInfo entity ID with the OAuth realm ID', async () => { + mockFetch + .mockResolvedValueOnce( + Response.json({ + sub: 'issuer:subject', + givenName: 'Ada', + email: 'ada@example.com', + emailVerified: true, + }) + ) + .mockResolvedValueOnce( + Response.json({ CompanyInfo: { Id: '1', CompanyName: 'Analytical Engines' } }) + ) + + await expect( + fetchQuickBooksConnectionProfile('access-token', '123456789', CLIENT_CONFIG) + ).resolves.toMatchObject({ realmId: '123456789' }) + }) + + it('rejects an unverified Intuit email before reading company data', async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ + sub: 'intuit-user-1', + givenName: 'Ada', + email: 'ada@example.com', + emailVerified: false, + }) + ) + + await expect( + fetchQuickBooksConnectionProfile('access-token', '123456789', CLIENT_CONFIG) + ).rejects.toThrow('QuickBooks UserInfo did not return a verified email address') + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('does not treat a truthy string as a verified Intuit email', async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ + sub: 'intuit-user-1', + givenName: 'Ada', + email: 'ada@example.com', + emailVerified: 'false', + }) + ) + + await expect( + fetchQuickBooksConnectionProfile('access-token', '123456789', CLIENT_CONFIG) + ).rejects.toThrow('QuickBooks UserInfo did not return a verified email address') + expect(mockFetch).toHaveBeenCalledOnce() + }) +}) + +describe('revokeQuickBooksToken', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('sends the token to the Intuit revocation endpoint with client authentication', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })) + + await expect(revokeQuickBooksToken(' refresh-token ', CLIENT_CONFIG)).resolves.toBeUndefined() + + expect(mockFetch).toHaveBeenCalledOnce() + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe('https://developer.api.intuit.com/v2/oauth2/tokens/revoke') + expect(init).toMatchObject({ + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Basic ${Buffer.from( + 'quickbooks-client-id:quickbooks-client-secret' + ).toString('base64')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: 'refresh-token' }), + }) + expect(init.signal).toBeInstanceOf(AbortSignal) + }) + + it('rejects before sending when client credentials are missing', async () => { + await expect( + revokeQuickBooksToken('refresh-token', { ...CLIENT_CONFIG, clientSecret: '' }) + ).rejects.toThrow('QuickBooks client secret must be between 1 and 512 characters') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('sanitizes network and timeout failures', async () => { + mockFetch.mockRejectedValueOnce(new DOMException('request timed out', 'AbortError')) + + const result = revokeQuickBooksToken('sensitive-refresh-token', CLIENT_CONFIG) + await expect(result).rejects.toThrow('QuickBooks token revocation request failed') + await expect(result).rejects.not.toThrow('sensitive-refresh-token') + }) + + it('fails closed when Intuit rejects the revocation request', async () => { + mockFetch.mockResolvedValueOnce( + new Response('sensitive-refresh-token quickbooks-client-secret', { status: 400 }) + ) + + const result = revokeQuickBooksToken('sensitive-refresh-token', CLIENT_CONFIG) + await expect(result).rejects.toThrow('QuickBooks token revocation failed with HTTP 400') + await expect(result).rejects.not.toThrow('sensitive-refresh-token') + await expect(result).rejects.not.toThrow('quickbooks-client-secret') + }) + + it('marks permanent client failures as non-retryable', async () => { + mockFetch.mockResolvedValueOnce(Response.json({ error: 'invalid_client' }, { status: 400 })) + + await expect(revokeQuickBooksToken('refresh-token', CLIENT_CONFIG)).rejects.toMatchObject({ + name: 'QuickBooksTokenRevocationError', + status: 400, + code: 'invalid_client', + retryable: false, + }) + }) + + it('treats an invalid token response as an already-completed revocation', async () => { + mockFetch.mockResolvedValueOnce(Response.json({ error: 'invalid_token' }, { status: 400 })) + + await expect(revokeQuickBooksToken('refresh-token', CLIENT_CONFIG)).resolves.toBeUndefined() + }) + + it('sanitizes non-terminal non-success responses', async () => { + mockFetch.mockResolvedValueOnce( + new Response('sensitive-refresh-token quickbooks-client-secret', { status: 503 }) + ) + + const result = revokeQuickBooksToken('sensitive-refresh-token', CLIENT_CONFIG) + await expect(result).rejects.toThrow('QuickBooks token revocation failed with HTTP 503') + await expect(result).rejects.not.toThrow('sensitive-refresh-token') + await expect(result).rejects.not.toThrow('quickbooks-client-secret') + }) + + it('marks rate limits and server failures as retryable', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 503 })) + + await expect(revokeQuickBooksToken('refresh-token', CLIENT_CONFIG)).rejects.toMatchObject({ + name: 'QuickBooksTokenRevocationError', + status: 503, + retryable: true, + }) + }) +}) diff --git a/apps/sim/lib/oauth/quickbooks.ts b/apps/sim/lib/oauth/quickbooks.ts new file mode 100644 index 00000000000..2f4f9b6d840 --- /dev/null +++ b/apps/sim/lib/oauth/quickbooks.ts @@ -0,0 +1,313 @@ +import { + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { + deriveQuickBooksWebhookAppKey, + normalizeQuickBooksOAuthClientConfig, + QUICKBOOKS_WEBHOOK_APP_KEY_PATTERN, + type QuickBooksOAuthClientConfig, +} from '@/lib/oauth/quickbooks-client-config' +import { QUICKBOOKS_TOKEN_URL } from '@/lib/oauth/quickbooks-constants' +import { + buildQuickBooksHeaders, + fetchValidatedQuickBooksCompanyInfo, + getQuickBooksUserInfoUrl, + normalizeQuickBooksRealmId as normalizeRealmId, + QUICKBOOKS_MAX_USER_INFO_BYTES, + QUICKBOOKS_OAUTH_REQUEST_TIMEOUT_MS, + type QuickBooksEnvironment, +} from '@/tools/quickbooks/client' + +const QUICKBOOKS_ACCOUNT_PREFIX = 'quickbooks:v2:' +const QUICKBOOKS_REVOCATION_URL = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke' +const QUICKBOOKS_MAX_REVOCATION_ERROR_BYTES = 64 * 1024 +const QUICKBOOKS_MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024 + +export class QuickBooksTokenRevocationError extends Error { + readonly retryable: boolean + + constructor( + readonly status: number, + readonly code?: string + ) { + super(`QuickBooks token revocation failed with HTTP ${status}`) + this.name = 'QuickBooksTokenRevocationError' + this.retryable = status === 429 || status >= 500 + } +} + +function parseQuickBooksRevocationErrorCode(responseBody: string): string | undefined { + if (!responseBody) return undefined + try { + const value = JSON.parse(responseBody) as unknown + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const error = (value as Record).error + const code = + typeof error === 'string' + ? error + : error && typeof error === 'object' && !Array.isArray(error) + ? (error as Record).code + : undefined + if (typeof code !== 'string') return undefined + const normalized = code.trim().toLowerCase() + return /^[a-z0-9_.-]{1,100}$/.test(normalized) ? normalized : undefined + } catch { + return undefined + } +} + +export interface QuickBooksAccountIdentity { + appKey: string + realmId: string + subject: string + environment: QuickBooksEnvironment +} + +export interface QuickBooksConnectionProfile extends QuickBooksAccountIdentity { + accountId: string + name: string + email: string + emailVerified: boolean +} + +export function normalizeQuickBooksRealmId(realmId: string): string { + return normalizeRealmId(realmId) +} + +function normalizeSubject(subject: string): string { + const normalized = subject.trim() + if (!normalized) { + throw new Error('QuickBooks user identity is invalid. Reconnect the QuickBooks credential.') + } + return normalized +} + +export function createQuickBooksAccountId( + realmId: string, + subject: string, + clientConfig: Pick +): string { + const encodedSubject = Buffer.from(normalizeSubject(subject), 'utf8').toString('base64url') + const appKey = deriveQuickBooksWebhookAppKey(clientConfig) + return `${QUICKBOOKS_ACCOUNT_PREFIX}${appKey}:${clientConfig.environment}:${normalizeQuickBooksRealmId(realmId)}:${encodedSubject}` +} + +export function parseQuickBooksAccountId(accountId: string): QuickBooksAccountIdentity { + if (!accountId.startsWith(QUICKBOOKS_ACCOUNT_PREFIX)) { + throw new Error('QuickBooks company identity is missing. Reconnect the QuickBooks credential.') + } + + const value = accountId.slice(QUICKBOOKS_ACCOUNT_PREFIX.length) + const firstSeparatorIndex = value.indexOf(':') + const secondSeparatorIndex = value.indexOf(':', firstSeparatorIndex + 1) + const thirdSeparatorIndex = value.indexOf(':', secondSeparatorIndex + 1) + if ( + firstSeparatorIndex <= 0 || + secondSeparatorIndex <= firstSeparatorIndex + 1 || + thirdSeparatorIndex <= secondSeparatorIndex + 1 + ) { + throw new Error('QuickBooks company identity is invalid. Reconnect the QuickBooks credential.') + } + const appKey = value.slice(0, firstSeparatorIndex) + if (!QUICKBOOKS_WEBHOOK_APP_KEY_PATTERN.test(appKey)) { + throw new Error('QuickBooks app identity is invalid. Reconnect the QuickBooks credential.') + } + const environment = value.slice(firstSeparatorIndex + 1, secondSeparatorIndex) + if (environment !== 'sandbox' && environment !== 'production') { + throw new Error('QuickBooks environment is invalid. Reconnect the QuickBooks credential.') + } + const realmId = normalizeQuickBooksRealmId( + value.slice(secondSeparatorIndex + 1, thirdSeparatorIndex) + ) + const encodedSubject = value.slice(thirdSeparatorIndex + 1) + if (!encodedSubject) { + throw new Error('QuickBooks company identity is invalid. Reconnect the QuickBooks credential.') + } + + let subject: string + try { + subject = normalizeSubject(Buffer.from(encodedSubject, 'base64url').toString('utf8')) + if (Buffer.from(subject, 'utf8').toString('base64url') !== encodedSubject) { + throw new Error('Non-canonical QuickBooks user identity') + } + } catch { + throw new Error('QuickBooks user identity is invalid. Reconnect the QuickBooks credential.') + } + return { appKey, realmId, subject, environment } +} + +export async function revokeQuickBooksToken( + token: string, + clientConfig: QuickBooksOAuthClientConfig, + signal?: AbortSignal +): Promise { + const normalizedToken = token.trim() + if (!normalizedToken) { + throw new Error('QuickBooks token revocation requires a token') + } + const normalizedClientConfig = normalizeQuickBooksOAuthClientConfig(clientConfig) + + let response: Response + try { + response = await fetch(QUICKBOOKS_REVOCATION_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${normalizedClientConfig.clientId}:${normalizedClientConfig.clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: normalizedToken }), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(QUICKBOOKS_OAUTH_REQUEST_TIMEOUT_MS)]) + : AbortSignal.timeout(QUICKBOOKS_OAUTH_REQUEST_TIMEOUT_MS), + }) + } catch { + throw new Error('QuickBooks token revocation request failed') + } + + if (!response.ok) { + const responseBody = await readResponseTextWithLimit(response, { + maxBytes: QUICKBOOKS_MAX_REVOCATION_ERROR_BYTES, + label: 'QuickBooks token revocation error response', + }).catch(() => '') + const errorCode = parseQuickBooksRevocationErrorCode(responseBody) + if (errorCode === 'invalid_token') return + throw new QuickBooksTokenRevocationError(response.status, errorCode) + } +} + +export async function fetchQuickBooksConnectionProfile( + accessToken: string, + callbackRealmId: string, + clientConfig: Pick +): Promise { + const realmId = normalizeQuickBooksRealmId(callbackRealmId) + const response = await fetch(getQuickBooksUserInfoUrl(clientConfig.environment), { + headers: buildQuickBooksHeaders(accessToken), + signal: AbortSignal.timeout(QUICKBOOKS_OAUTH_REQUEST_TIMEOUT_MS), + }) + if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: QUICKBOOKS_MAX_USER_INFO_BYTES, + label: 'QuickBooks UserInfo error response', + }).catch(() => {}) + throw new Error(`QuickBooks UserInfo request failed with HTTP ${response.status}`) + } + + const profile = await readResponseJsonWithLimit<{ + sub?: string + name?: string + givenName?: string + familyName?: string + given_name?: string + family_name?: string + email?: string + emailVerified?: unknown + email_verified?: unknown + }>(response, { + maxBytes: QUICKBOOKS_MAX_USER_INFO_BYTES, + label: 'QuickBooks UserInfo response', + }) + + const subject = profile.sub?.trim() + const email = profile.email?.trim() + const givenName = (profile.givenName ?? profile.given_name)?.trim() + const familyName = (profile.familyName ?? profile.family_name)?.trim() + const name = profile.name?.trim() || [givenName, familyName].filter(Boolean).join(' ') + const emailVerified = (profile.emailVerified ?? profile.email_verified) === true + + if (!subject || !email || !name) { + throw new Error('QuickBooks UserInfo did not return the required user identity') + } + if (!emailVerified) { + throw new Error('QuickBooks UserInfo did not return a verified email address') + } + + await fetchValidatedQuickBooksCompanyInfo(accessToken, realmId, clientConfig.environment) + + return { + accountId: createQuickBooksAccountId(realmId, subject, clientConfig), + appKey: deriveQuickBooksWebhookAppKey(clientConfig), + realmId, + subject: normalizeSubject(subject), + environment: clientConfig.environment, + name, + email, + emailVerified, + } +} + +export interface QuickBooksTokenExchangeResult { + accessToken: string + refreshToken: string + accessTokenExpiresIn: number + refreshTokenExpiresIn: number + scope: string + idToken?: string +} + +export async function exchangeQuickBooksAuthorizationCode(params: { + code: string + redirectUri: string + clientConfig: QuickBooksOAuthClientConfig + signal?: AbortSignal +}): Promise { + const clientConfig = normalizeQuickBooksOAuthClientConfig(params.clientConfig) + const response = await fetch(QUICKBOOKS_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${clientConfig.clientId}:${clientConfig.clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: params.code, + redirect_uri: params.redirectUri, + }).toString(), + redirect: 'error', + signal: params.signal + ? AbortSignal.any([params.signal, AbortSignal.timeout(QUICKBOOKS_OAUTH_REQUEST_TIMEOUT_MS)]) + : AbortSignal.timeout(QUICKBOOKS_OAUTH_REQUEST_TIMEOUT_MS), + }) + + if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: QUICKBOOKS_MAX_TOKEN_RESPONSE_BYTES, + label: 'QuickBooks OAuth token error response', + }).catch(() => {}) + throw new Error(`QuickBooks token exchange failed with HTTP ${response.status}`) + } + + const data = await readResponseJsonWithLimit>(response, { + maxBytes: QUICKBOOKS_MAX_TOKEN_RESPONSE_BYTES, + label: 'QuickBooks OAuth token response', + }) + const accessToken = typeof data.access_token === 'string' ? data.access_token.trim() : '' + const refreshToken = typeof data.refresh_token === 'string' ? data.refresh_token.trim() : '' + const accessTokenExpiresIn = data.expires_in + const refreshTokenExpiresIn = data.x_refresh_token_expires_in + const scope = typeof data.scope === 'string' ? data.scope.trim() : '' + const idToken = typeof data.id_token === 'string' ? data.id_token.trim() : '' + if ( + !accessToken || + !refreshToken || + typeof accessTokenExpiresIn !== 'number' || + !Number.isSafeInteger(accessTokenExpiresIn) || + accessTokenExpiresIn <= 0 || + typeof refreshTokenExpiresIn !== 'number' || + !Number.isSafeInteger(refreshTokenExpiresIn) || + refreshTokenExpiresIn <= 0 + ) { + throw new Error('QuickBooks token exchange returned an invalid token response') + } + return { + accessToken, + refreshToken, + accessTokenExpiresIn, + refreshTokenExpiresIn, + scope, + ...(idToken ? { idToken } : {}), + } +} diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index e06ee3c9c8d..799c21748c8 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -244,6 +244,39 @@ describe('resolveCredentialToken', () => { expect(mockRefreshTokenIfNeeded).toHaveBeenCalled() }) + it('projects the realm and environment bound to the QuickBooks account identity', async () => { + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + credentialOwnerUserId: 'owner-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'account-1', + }) + mockGetCredential.mockResolvedValue({ + providerId: 'quickbooks', + accountId: + 'quickbooks:v2:NkYPLLqX2cM-QABxg0vbv71mQS9s_aRP3v7ZKLvnJyo:sandbox:1234567890:dXNlci0x', + }) + mockRefreshTokenIfNeeded.mockResolvedValue({ accessToken: 'fresh', refreshed: false }) + + await expect( + resolveCredentialToken(INTERNAL_AUTH, { + requestId: 'req-1', + resolvedCredential: null, + credentialId: 'cred-1', + }) + ).resolves.toEqual({ + ok: true, + token: { + accessToken: 'fresh', + credentialType: 'oauth', + idToken: undefined, + realmId: '1234567890', + quickBooksEnvironment: 'sandbox', + }, + }) + }) + it('reports a failed refresh as 401 without recording access', async () => { mockAuthorizeCredentialUseForAuth.mockResolvedValue({ ok: true, diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index dfd1f682b5b..f198d900626 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type DelegatedPrincipal, resolvePrincipalSubject } from '@sim/auth/principal' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { impersonateEmailSchema, type OAuthTokenResponse, @@ -23,6 +24,7 @@ import { extractMicrosoftDataverseEnvironmentUrl, MICROSOFT_DATAVERSE_PROVIDER_ID, } from '@/lib/oauth/microsoft-dataverse' +import { parseQuickBooksAccountId } from '@/lib/oauth/quickbooks' import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' @@ -65,6 +67,30 @@ export type ResolveCredentialTokenResult = | { ok: true; token: CredentialTokenPayload } | { ok: false; status: number; error: string; code?: string } +interface OAuthCredentialContext { + providerId: string + accountId?: string | null +} + +export function validateOAuthCredentialContext( + credential: OAuthCredentialContext +): { ok: true } | { ok: false; error: string } { + if (credential.providerId !== 'quickbooks') return { ok: true } + + try { + parseQuickBooksAccountId(credential.accountId ?? '') + return { ok: true } + } catch (error) { + return { + ok: false, + error: getErrorMessage( + error, + 'QuickBooks company identity is invalid. Reconnect the QuickBooks credential.' + ), + } + } +} + /** * Emits the semantic "credential used" trail for one resolved credential. * Both the audit row and the analytics event are fire-and-forget. @@ -109,7 +135,12 @@ export function recordCredentialAccess(params: { * local regex — these values are injected into tool calls that carry the token. */ function buildOAuthTokenPayload( - credential: { providerId: string; scope?: string | null; idToken?: string | null }, + credential: { + providerId: string + accountId?: string | null + scope?: string | null + idToken?: string | null + }, accessToken: string ): CredentialTokenPayload { const instanceUrl = isSalesforceOAuthProviderId(credential.providerId) @@ -123,12 +154,21 @@ function buildOAuthTokenPayload( apiDomain = extractZohoDeskBaseFromScope(credential.scope) } + const quickBooksIdentity = + credential.providerId === 'quickbooks' + ? parseQuickBooksAccountId(credential.accountId ?? '') + : undefined + return { accessToken, credentialType: 'oauth', idToken: credential.idToken || undefined, ...(instanceUrl && { instanceUrl }), ...(apiDomain && { apiDomain }), + ...(quickBooksIdentity && { + realmId: quickBooksIdentity.realmId, + quickBooksEnvironment: quickBooksIdentity.environment, + }), } } @@ -139,13 +179,23 @@ function buildOAuthTokenPayload( */ export async function completeOAuthCredentialToken(params: { requestId: string - credential: { providerId: string; scope?: string | null; idToken?: string | null } + credential: { + providerId: string + accountId?: string | null + scope?: string | null + idToken?: string | null + } resolvedCredentialId: string actorId?: string workspaceId: string | null auditRequest?: CredentialAuditRequest }): Promise { const { requestId, credential, resolvedCredentialId, actorId, workspaceId, auditRequest } = params + const contextValidation = validateOAuthCredentialContext(credential) + if (!contextValidation.ok) { + return { ok: false, status: 401, error: contextValidation.error } + } + try { const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolvedCredentialId) diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index d7b5b668f2a..3e1efe990ea 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -78,6 +78,7 @@ export type OAuthProvider = | 'asana' | 'attio' | 'pipedrive' + | 'quickbooks' | 'hubspot' | 'harmonic' | 'salesforce' @@ -138,6 +139,7 @@ export type OAuthService = | 'asana' | 'attio' | 'pipedrive' + | 'quickbooks' | 'hubspot' | 'harmonic' | 'salesforce' @@ -163,6 +165,15 @@ export interface OAuthProviderConfig { export type OAuthAuthType = 'oauth' | 'service_account' +export interface OAuthClientConfigurationField { + id: 'clientId' | 'clientSecret' | 'environment' | 'webhookVerifierToken' + label: string + placeholder: string + secret: boolean + options?: readonly { value: string; label: string }[] + hint?: string +} + export interface OAuthServiceConfig { name: string description: string @@ -198,6 +209,11 @@ export interface OAuthServiceConfig { * which does not hint that the environment was the problem. */ providerIdPickerHint?: string + /** Write-only OAuth app fields a user must supply before provider authorization starts. */ + clientConfiguration?: { + fields: readonly OAuthClientConfigurationField[] + redirectPath?: `/${string}` + } } /** @@ -211,6 +227,7 @@ export interface OAuthServiceMetadata { name: string description: string baseProvider: string + clientConfiguration?: OAuthServiceConfig['clientConfiguration'] authType: OAuthAuthType } diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index ab0e34a9273..3c357d6bb1b 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -15,6 +15,7 @@ import { getServiceConfigByServiceId, parseProvider, providerIdsForService, + usesCredentialConfiguredOAuthClient, } from './utils' describe('getAllOAuthServices', () => { @@ -80,6 +81,11 @@ describe('getAllOAuthServices', () => { expect(slackService).toBeDefined() expect(slackService?.name).toBe('Slack') expect(slackService?.baseProvider).toBe('slack') + + const quickbooksService = services.find((s) => s.providerId === 'quickbooks') + expect(quickbooksService).toBeDefined() + expect(quickbooksService?.name).toBe('QuickBooks') + expect(quickbooksService?.baseProvider).toBe('quickbooks') }) it.concurrent('should not include duplicate services', () => { @@ -175,6 +181,14 @@ describe('getServiceByProviderAndId', () => { }) }) +describe('usesCredentialConfiguredOAuthClient', () => { + it.concurrent('distinguishes user-supplied OAuth apps from deployment OAuth clients', () => { + expect(usesCredentialConfiguredOAuthClient('quickbooks')).toBe(true) + expect(usesCredentialConfiguredOAuthClient('slack')).toBe(false) + expect(usesCredentialConfiguredOAuthClient('unknown-provider')).toBe(false) + }) +}) + describe('getProviderIdFromServiceId', () => { it.concurrent('should return correct providerId for Gmail', () => { const providerId = getProviderIdFromServiceId('gmail') @@ -276,6 +290,14 @@ describe('getServiceConfigByProviderId', () => { expect(service?.name).toBe('Slack') }) + it.concurrent('should work for QuickBooks', () => { + const service = getServiceConfigByProviderId('quickbooks') + + expect(service).toBeDefined() + expect(service?.providerId).toBe('quickbooks') + expect(service?.name).toBe('QuickBooks') + }) + it.concurrent('should return service with scopes', () => { const service = getServiceConfigByProviderId('google-drive') @@ -370,6 +392,13 @@ describe('getCanonicalScopesForProvider', () => { expect(excelScopes).toContain('Files.Read') }) + it.concurrent('should return the exact canonical QuickBooks scopes', () => { + const expected = ['openid', 'profile', 'email', 'com.intuit.quickbooks.accounting'] + + expect(getCanonicalScopesForProvider('quickbooks')).toEqual(expected) + expect(getScopesForService('quickbooks')).toEqual(expected) + }) + it.concurrent('should handle providers with empty scopes array', () => { const scopes = getCanonicalScopesForProvider('notion') diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 78068eac613..aac1dd94700 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -125,6 +125,8 @@ export const SCOPE_DESCRIPTIONS: Record = { openid: 'Standard authentication', profile: 'Access profile information', email: 'Access email address', + 'com.intuit.quickbooks.accounting': + 'Access and manage accounting data in the connected QuickBooks Online company', // Notion scopes 'database.read': 'Read database', @@ -553,6 +555,7 @@ export function getAllOAuthServices(): OAuthServiceMetadata[] { name: service.name, description: service.description, baseProvider: baseProviderId, + clientConfiguration: service.clientConfiguration, authType: service.authType ?? 'oauth', }) } @@ -623,6 +626,10 @@ export function getServiceConfigByProviderId(providerId: string): OAuthServiceCo return null } +export function usesCredentialConfiguredOAuthClient(providerId: string): boolean { + return Boolean(getServiceConfigByProviderId(providerId)?.clientConfiguration) +} + export function getServiceAccountProviderForProviderId(providerId: string): string | undefined { const serviceConfig = getServiceConfigByProviderId(providerId) return serviceConfig?.serviceAccountProviderId diff --git a/apps/sim/lib/permission-groups/capability-assertions.ts b/apps/sim/lib/permission-groups/capability-assertions.ts index 9d5c51f5506..5ea30faf313 100644 --- a/apps/sim/lib/permission-groups/capability-assertions.ts +++ b/apps/sim/lib/permission-groups/capability-assertions.ts @@ -1,3 +1,4 @@ +import type { DbOrTx } from '@/lib/db/types' import { CAPABILITY_RULES, refuseCapability, @@ -5,7 +6,10 @@ import { } from '@/lib/permission-groups/capabilities' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' -import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' +import { + getEntitledOrganizationPermissionConfig, + getUserPermissionConfigForOrganization, +} from '@/lib/permission-groups/resolve.server' /** * Re-exported so a caller that gates inline reaches the refusal sentence and the @@ -90,3 +94,20 @@ export async function isOrganizationCapabilityWithheld( await getUserPermissionConfigForOrganization(organizationId) ) } + +/** + * {@link isOrganizationCapabilityWithheld} for an organization whose regime the + * caller has ALREADY established, reading the group on the given executor. That + * establishment is the caller's obligation, not an optional one — see + * {@link getEntitledOrganizationPermissionConfig}. + */ +export async function isEntitledOrganizationCapabilityWithheld( + organizationId: string, + capability: StaticPermissionGroupCapability, + executor: DbOrTx +): Promise { + return capabilityDeniedBy( + capability, + await getEntitledOrganizationPermissionConfig(organizationId, executor) + ) +} diff --git a/apps/sim/lib/permission-groups/locks.ts b/apps/sim/lib/permission-groups/locks.ts new file mode 100644 index 00000000000..a5016506d63 --- /dev/null +++ b/apps/sim/lib/permission-groups/locks.ts @@ -0,0 +1,62 @@ +import { sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' + +const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000 + +/** + * Serialize all permission-group membership, scope, and config writes for an + * organization via a transaction-scoped Postgres advisory lock. Callers acquire + * it at the top of the transaction that both checks (`findScopeConflicts`) and + * mutates, so a concurrent member add or scope change can't commit in the + * check-to-write window and leave a user governed by two groups on the same + * workspace. + * + * The invariant (one effective group per user per workspace) spans users and + * groups in ways a unique constraint can't express, and these are low-frequency + * admin writes, so a single org-scoped lock is simpler and more obviously + * correct than fine-grained per-user/per-group locks with acquire-ordering. + * + * Readers take it too, when the value they read decides whether a write in the + * same transaction may commit — workspace creation re-reads the default group's + * `workspace.create` capability under this lock, which is the only thing that + * makes the check-to-insert window closed rather than merely narrow. + * + * `pg_advisory_xact_lock` auto-releases at transaction end (safe on pooled + * connections), and `lock_timeout` bounds the wait (raising SQLSTATE 55P03) + * instead of hanging if a holder is stuck. The key string is the contention + * identity: any change to its format silently stops contending with in-flight + * holders. + * + * `lockTimeoutAlreadyBounded` skips the `set_config` round trip for a caller + * that has already bounded `lock_timeout` transaction-locally at the same + * 5000ms — every advisory lock in `lib/billing/organizations/membership.ts` + * does, and workspace creation takes those first. It stays a separate statement + * rather than being folded into the `pg_advisory_xact_lock` select: target-list + * evaluation order is unspecified, so the bound might not be in force when the + * lock is requested. + * + * LOCK ORDER: this is a LEAF lock. Every transaction that holds it — the five + * `organizations/[id]/permission-groups` route transactions, and the workspace + * creation transaction — acquires no further advisory lock afterwards. That is + * what makes it safe for workspace creation to take it *last*, after + * `organization-mutation`, `user-billing-identity`, and the membership lock: a + * deadlock needs a holder of this lock to wait on one of those, and no such + * holder exists. Keep it a leaf. + * + * Lives in `lib/` rather than beside the routes because `lib/workspaces/policy.ts` + * acquires it, and `lib/` must not import from `app/api/**`. + */ +export async function acquirePermissionGroupOrgLock( + tx: DbOrTx, + organizationId: string, + options?: { lockTimeoutAlreadyBounded?: boolean } +): Promise { + if (!options?.lockTimeoutAlreadyBounded) { + await tx.execute( + sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)` + ) + } + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`permission_group:${organizationId}`}, 0))` + ) +} diff --git a/apps/sim/lib/permission-groups/resolve.server.ts b/apps/sim/lib/permission-groups/resolve.server.ts index ec5beb74d0e..cc2bebb91f9 100644 --- a/apps/sim/lib/permission-groups/resolve.server.ts +++ b/apps/sim/lib/permission-groups/resolve.server.ts @@ -22,6 +22,7 @@ import { isAccessControlEnabled, isHosted, } from '@/lib/core/config/env-flags' +import type { DbOrTx } from '@/lib/db/types' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, @@ -90,11 +91,19 @@ function inactiveUserAccessControlContext(organizationId: string | null): UserAc } } -/** The organization's single default group (`isDefault`), or `null`. */ +/** + * The organization's single default group (`isDefault`), or `null`. + * + * The executor is required, not defaulted: a caller that must read the group + * under `acquirePermissionGroupOrgLock` has to read it on the transaction's own + * connection, and a default would let that caller silently check out a second + * pooled connection while advisory locks are held. + */ async function resolveDefaultGroup( - organizationId: string + organizationId: string, + executor: DbOrTx ): Promise { - const [defaultGroup] = await db + const [defaultGroup] = await executor .select({ id: permissionGroup.id, name: permissionGroup.name, @@ -181,7 +190,7 @@ export async function resolveWorkspaceGroup( } } - return resolveDefaultGroup(organizationId) + return resolveDefaultGroup(organizationId, db) } /** @@ -281,16 +290,50 @@ export async function getUserPermissionConfig( export async function getUserPermissionConfigForOrganization( organizationId: string ): Promise { - if (!isHosted && !isAccessControlEnabled) { + if (!(await isOrganizationPermissionRegimeActive(organizationId))) { return mergeEnvAllowlist(null) } + return getEntitledOrganizationPermissionConfig(organizationId, db) +} - /** `'throw'` for the same reason as in {@link resolveUserAccessControlContextForOrganization}. */ - const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId, 'throw') - if (!isEnterprise) { - return mergeEnvAllowlist(null) - } +/** + * Whether permission groups govern `organizationId` at all — the deployment + * enables Access Control, and the organization holds the Enterprise entitlement + * that turns the regime on. + * + * Split out of {@link getUserPermissionConfigForOrganization} so a caller that + * must re-read the *group* under `acquirePermissionGroupOrgLock` can settle this + * half BEFORE opening its transaction. The entitlement read cannot move into a + * transaction: {@link isOrganizationOnEnterprisePlan} is `cache()`d on its + * argument list, so it admits no executor, and giving it one would both miss the + * memo on every call and — because an unentitled organization resolves to + * `config: null`, meaning every capability ALLOWED — turn a read failure into a + * fail-open. The lock never serialized this half either way: it guards + * permission-group writes, not subscription changes. + * + * `'throw'` for the same reason as in + * {@link resolveUserAccessControlContextForOrganization}. + */ +export async function isOrganizationPermissionRegimeActive( + organizationId: string +): Promise { + if (!isHosted && !isAccessControlEnabled) return false + return isOrganizationOnEnterprisePlan(organizationId, 'throw') +} - const resolved = await resolveDefaultGroup(organizationId) +/** + * The organization-level permission config for an organization already known to + * be governed — the second half of {@link getUserPermissionConfigForOrganization}, + * callable on a transaction executor. + * + * Callers MUST have established {@link isOrganizationPermissionRegimeActive} + * first; this function does not re-check entitlement, and reading it as though + * it did would apply an unentitled organization's stale default group. + */ +export async function getEntitledOrganizationPermissionConfig( + organizationId: string, + executor: DbOrTx +): Promise { + const resolved = await resolveDefaultGroup(organizationId, executor) return mergeEnvAllowlist(resolved?.config ?? null) } diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts index df9322d33e3..599b78e1866 100644 --- a/apps/sim/lib/selectors/manifest.test.ts +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -9,9 +9,9 @@ describe('selector manifest', () => { const count = (classification: (typeof classifications)[number]) => classifications.filter((value) => value === classification).length - expect(Object.keys(selectorManifest)).toHaveLength(94) + expect(Object.keys(selectorManifest)).toHaveLength(95) expect(count('provider-server')).toBe(82) - expect(count('internal-server')).toBe(11) + expect(count('internal-server')).toBe(12) expect(count('local')).toBe(1) expect(classifications).not.toContain('provider-legacy') }) diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 172fcfa0ac5..51ad39a29dd 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -375,6 +375,7 @@ export const selectorManifest = { 'workspace.secretNames': internalSelector(), 'workspace.rawSecretNames': internalSelector(), 'workspace.sandboxes': internalSelector(['language'], { detail: true }), + 'providers.ollamaEmbeddingModels': internalSelector(), 'providers.openrouterEmbeddingModels': internalSelector(), 'workspace.triggerTypes': { classification: 'local', diff --git a/apps/sim/lib/selectors/server/internal.ts b/apps/sim/lib/selectors/server/internal.ts index 4a5241cdf27..0c0819ba760 100644 --- a/apps/sim/lib/selectors/server/internal.ts +++ b/apps/sim/lib/selectors/server/internal.ts @@ -1,6 +1,7 @@ import { listCredentialGroupSettings } from '@/lib/credential-groups/application/manage-groups' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { listInternalCredentials } from '@/lib/credentials/application/credential-crud' +import { fetchOllamaEmbeddingModelCatalog } from '@/lib/embeddings/ollama-model-catalog.server' import { fetchOpenRouterEmbeddingModelCatalog } from '@/lib/embeddings/openrouter-model-catalog.server' import { getEffectiveEnvironmentVariableNames } from '@/lib/environment/utils' import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes' @@ -296,6 +297,27 @@ export const internalSelectorAttachments = { ) }, }, + 'providers.ollamaEmbeddingModels': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + if (isProviderBlacklisted('ollama')) return listSelectorResult([]) + const models = await fetchOllamaEmbeddingModelCatalog(args.signal) + return listSelectorResult( + filterBlacklistedModels(models.map((model) => model.id)).map((id) => { + const dimensions = models.find((model) => model.id === id)?.dimensions + return { + id, + /** + * The width is in the label because matching it to + * `EMBEDDING_OUTPUT_DIMS` is the operator's job and Ollama is the one + * provider whose widths Sim cannot know ahead of time. + */ + label: dimensions === undefined ? id : `${id} (${dimensions})`, + } + }) + ) + }, + }, 'providers.openrouterEmbeddingModels': { destination: 'fixed', async execute(args: ExecuteServerSelectorArgs) { diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 77ce7cc1642..471842702c1 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -45,11 +45,13 @@ vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({ })) const { + mockGetQuickBooksWebhookCredential, mockGetSlackBotCredential, mockResolveOAuthAccountId, mockRefreshAccessTokenIfNeeded, mockFetchSlackTeamId, } = vi.hoisted(() => ({ + mockGetQuickBooksWebhookCredential: vi.fn(), mockGetSlackBotCredential: vi.fn(), mockResolveOAuthAccountId: vi.fn(), mockRefreshAccessTokenIfNeeded: vi.fn(), @@ -63,6 +65,10 @@ vi.mock('@/lib/oauth/credential-service', () => ({ vi.mock('@/lib/webhooks/providers/slack', () => ({ fetchSlackTeamId: mockFetchSlackTeamId, })) +vi.mock('@/lib/webhooks/quickbooks-credentials', () => ({ + buildQuickBooksWebhookRoutingKey: (appKey: string, realmId: string) => `${appKey}:${realmId}`, + getQuickBooksWebhookClientConfigByCredentialId: mockGetQuickBooksWebhookCredential, +})) import { buildProviderConfig, @@ -71,6 +77,8 @@ import { resolveWebhookConfigForBlock, } from '@/lib/webhooks/deploy' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' +import { getProviderHandler } from '@/lib/webhooks/providers' +import { quickBooksHandler } from '@/lib/webhooks/providers/quickbooks' import { getBlock } from '@/blocks' import { getTrigger } from '@/triggers' @@ -144,6 +152,9 @@ beforeEach(() => { vi.clearAllMocks() resetDbChainMock() setEnvFlags({ isSlackExtendedScopesEnabled: true }) + ;(getProviderHandler as unknown as Mock).mockImplementation((provider: string) => + provider === 'quickbooks' ? quickBooksHandler : {} + ) }) describe('buildProviderConfig canonical collapse', () => { @@ -656,6 +667,90 @@ describe('resolveWebhookConfigForBlock — TikTok routing', () => { }) }) +describe('resolveWebhookConfigForBlock — QuickBooks routing', () => { + const quickBooksTriggerDef = { + provider: 'quickbooks', + name: 'QuickBooks Invoice Events', + subBlocks: [ + { + id: 'triggerCredentials', + mode: 'trigger', + serviceId: 'quickbooks', + required: true, + }, + ], + } + + function resolveQuickBooks( + credentialReference: string, + workflow: Record = { workspaceId: 'ws-1' } + ) { + ;(getBlock as unknown as Mock).mockReturnValue({ category: 'tools' }) + ;(getTrigger as unknown as Mock).mockReturnValue(quickBooksTriggerDef) + const block = makeBlock('quickbooks', { + selectedTriggerId: 'quickbooks_invoice_events', + triggerCredentials: credentialReference, + }) + block.triggerMode = true + return resolveWebhookConfigForBlock({ + block, + blocks: {}, + workflow, + userId: 'deployer-1', + requestId: 'req-1', + }) + } + + it('routes a workspace-owned credential by its stored QuickBooks realm ID', async () => { + queueTableRows(credential, [{ id: 'cred-qb-1' }]) + mockGetQuickBooksWebhookCredential.mockResolvedValue({ + clientConfig: { webhookVerifierToken: 'verifier' }, + identity: { appKey: 'app-key', realmId: '9341456000000000' }, + }) + + const result = await resolveQuickBooks('cred-qb-1') + + expect(result?.success).toBe(true) + if (!result?.success) throw new Error('expected success') + expect(result.config.provider).toBe('quickbooks') + expect(result.config.routingKey).toBe('app-key:9341456000000000') + expect(result.config.triggerPath).toBeNull() + expect(result.config.providerConfig.credentialId).toBe('cred-qb-1') + expect(result.config.providerConfig.quickBooksWebhookAppKey).toBe('app-key') + }) + + it('rejects a QuickBooks credential outside the workflow workspace', async () => { + const result = await resolveQuickBooks('cred-foreign') + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.message).toContain('not available in this workspace') + expect(mockGetQuickBooksWebhookCredential).not.toHaveBeenCalled() + }) + + it('rejects a malformed stored QuickBooks company identity', async () => { + queueTableRows(credential, [{ id: 'cred-qb-1' }]) + mockGetQuickBooksWebhookCredential.mockResolvedValue(null) + + const result = await resolveQuickBooks('cred-qb-1') + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.message).toContain('Reconnect it and try again') + }) + + it('reports an unexpected credential lookup failure as a server error', async () => { + queueTableRows(credential, [{ id: 'cred-qb-1' }]) + mockGetQuickBooksWebhookCredential.mockRejectedValue(new Error('database unavailable')) + + const result = await resolveQuickBooks('cred-qb-1') + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error.status).toBe(500) + }) +}) + describe('cleanupInactiveDeploymentWebhooks', () => { const workflow = { id: 'workflow-1', userId: 'user-1', workspaceId: 'workspace-1' } const input = { diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 7543e0a0595..dbcedcdc7bd 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -21,6 +21,7 @@ import { projectDesiredWebhookProviderConfig, } from '@/lib/webhooks/provider-subscriptions' import { getProviderHandler } from '@/lib/webhooks/providers' +import { WebhookDeploymentConfigurationError } from '@/lib/webhooks/providers/errors' import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack' import { prepareStableWebhookRegistrations, @@ -679,6 +680,30 @@ export async function resolveWebhookConfigForBlock(input: { routingKey = openId } + const handler = getProviderHandler(triggerDef.provider) + if (handler?.prepareDeploymentConfig) { + try { + const prepared = await handler.prepareDeploymentConfig({ + credentialId, + providerConfig, + requestId: input.requestId, + triggerId, + }) + effectiveProvider = prepared.provider ?? effectiveProvider + Object.assign(providerConfig, prepared.providerConfigUpdates) + if (prepared.triggerPath !== undefined) effectivePath = prepared.triggerPath + if (prepared.routingKey !== undefined) routingKey = prepared.routingKey + } catch (error) { + return { + success: false, + error: { + message: getErrorMessage(error, `Could not prepare ${triggerDef.name || triggerId}.`), + status: error instanceof WebhookDeploymentConfigurationError ? 400 : 500, + }, + } + } + } + return { success: true, config: { diff --git a/apps/sim/lib/webhooks/providers/errors.ts b/apps/sim/lib/webhooks/providers/errors.ts new file mode 100644 index 00000000000..77fe674ed81 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/errors.ts @@ -0,0 +1,6 @@ +export class WebhookDeploymentConfigurationError extends Error { + constructor(message: string) { + super(message) + this.name = 'WebhookDeploymentConfigurationError' + } +} diff --git a/apps/sim/lib/webhooks/providers/quickbooks.test.ts b/apps/sim/lib/webhooks/providers/quickbooks.test.ts new file mode 100644 index 00000000000..28af6e2df1e --- /dev/null +++ b/apps/sim/lib/webhooks/providers/quickbooks.test.ts @@ -0,0 +1,113 @@ +import crypto from 'node:crypto' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { + quickBooksHandler, + verifyQuickBooksSignature, + verifyQuickBooksSignatureAgainstVerifierTokens, +} from '@/lib/webhooks/providers/quickbooks' +import { + isQuickBooksEventMatch, + quickBooksEventTypesSubBlockId, +} from '@/triggers/quickbooks/quickbooks' + +const event = { + specversion: '1.0', + id: 'event-1', + source: 'quickbooks-online', + type: 'qbo.invoice.updated.v1', + datacontenttype: 'application/json', + time: '2026-08-03T12:00:00Z', + intuitentityid: '123', + intuitaccountid: '456', + data: { changedFields: ['Balance'] }, +} + +describe('QuickBooks webhook provider', () => { + it('verifies the documented base64 HMAC over the raw body', () => { + const body = JSON.stringify([event]) + const signature = crypto.createHmac('sha256', 'verifier').update(body).digest('base64') + expect(verifyQuickBooksSignature(body, signature, 'verifier', 'request-1')).toBeNull() + expect(verifyQuickBooksSignature(body, 'invalid', 'verifier', 'request-2')?.status).toBe(401) + expect(verifyQuickBooksSignature(body, null, 'verifier', 'request-3')?.status).toBe(401) + expect( + verifyQuickBooksSignatureAgainstVerifierTokens( + body, + signature, + ['different-verifier', 'verifier'], + 'request-4' + ) + ).toBeNull() + }) + + it('matches only configured actions for the selected entity', () => { + expect( + isQuickBooksEventMatch('quickbooks_invoice_events', event.type, ['created', 'updated']) + ).toBe(true) + expect(isQuickBooksEventMatch('quickbooks_invoice_events', event.type, ['created'])).toBe(false) + expect(isQuickBooksEventMatch('quickbooks_bill_events', event.type, ['updated'])).toBe(false) + }) + + it('normalizes Intuit void events to the configured voided action', async () => { + for (const entity of ['invoice', 'payment']) { + const voidEvent = { ...event, type: `qbo.${entity}.void.v1` } + expect( + isQuickBooksEventMatch(`quickbooks_${entity}_events`, voidEvent.type, ['voided']) + ).toBe(true) + + const result = await quickBooksHandler.formatInput!({ + body: voidEvent, + webhook: {}, + workflow: { id: 'workflow-1', userId: 'user-1' }, + headers: {}, + requestId: `request-${entity}`, + }) + expect(result.input).toMatchObject({ + eventType: `qbo.${entity}.void.v1`, + entityType: entity, + action: 'voided', + }) + } + }) + + it('formats only the common verified event fields', async () => { + const result = await quickBooksHandler.formatInput!({ + body: event, + webhook: {}, + workflow: { id: 'workflow-1', userId: 'user-1' }, + headers: {}, + requestId: 'request-4', + }) + expect(result.input).toEqual({ + eventId: 'event-1', + eventType: 'qbo.invoice.updated.v1', + entityType: 'invoice', + action: 'updated', + entityId: '123', + realmId: '456', + eventTime: '2026-08-03T12:00:00Z', + specVersion: '1.0', + source: 'quickbooks-online', + contentType: 'application/json', + data: { changedFields: ['Balance'] }, + }) + expect(quickBooksHandler.extractIdempotencyId!(event)).toBe('event-1') + }) + + it('uses the provider-local ingress and durable queue modes', async () => { + expect(quickBooksHandler.ingressMode).toBe('provider') + expect(quickBooksHandler.executionMode).toBe('queue') + const matched = await quickBooksHandler.matchEvent!({ + body: event, + request: new NextRequest('http://localhost'), + requestId: 'request-5', + providerConfig: { + triggerId: 'quickbooks_invoice_events', + [quickBooksEventTypesSubBlockId('quickbooks_invoice_events')]: ['updated'], + }, + webhook: {}, + workflow: {}, + }) + expect(matched).toBe(true) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/quickbooks.ts b/apps/sim/lib/webhooks/providers/quickbooks.ts new file mode 100644 index 00000000000..66b978b6285 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/quickbooks.ts @@ -0,0 +1,158 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Base64 } from '@sim/security/hmac' +import { NextResponse } from 'next/server' +import { WebhookDeploymentConfigurationError } from '@/lib/webhooks/providers/errors' +import type { + AuthContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { + buildQuickBooksWebhookRoutingKey, + getQuickBooksWebhookClientConfigByCredentialId, +} from '@/lib/webhooks/quickbooks-credentials' + +const logger = createLogger('WebhookProvider:QuickBooks') + +export function verifyQuickBooksSignature( + rawBody: string, + signature: string | null, + verifierToken: string | undefined, + requestId: string +): NextResponse | null { + return verifyQuickBooksSignatureAgainstVerifierTokens( + rawBody, + signature, + verifierToken ? [verifierToken] : [], + requestId + ) +} + +export function verifyQuickBooksSignatureAgainstVerifierTokens( + rawBody: string, + signature: string | null, + verifierTokens: readonly string[], + requestId: string +): NextResponse | null { + const configuredTokens = Array.from( + new Set(verifierTokens.map((token) => token.trim()).filter(Boolean)) + ) + if (configuredTokens.length === 0) { + logger.warn(`[${requestId}] QuickBooks webhook verifier token is not configured`) + return new NextResponse('Unauthorized', { status: 401 }) + } + if (!signature) { + logger.warn(`[${requestId}] QuickBooks webhook is missing intuit-signature`) + return new NextResponse('Unauthorized', { status: 401 }) + } + + const receivedSignature = signature.trim() + let isValid = false + for (const verifierToken of configuredTokens) { + const expected = hmacSha256Base64(rawBody, verifierToken) + isValid = safeCompare(expected, receivedSignature) || isValid + } + if (!isValid) { + logger.warn(`[${requestId}] QuickBooks webhook signature verification failed`) + return new NextResponse('Unauthorized', { status: 401 }) + } + return null +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +export const quickBooksHandler: WebhookProviderHandler = { + ingressMode: 'provider', + executionMode: 'queue', + + async prepareDeploymentConfig({ credentialId }) { + if (!credentialId) { + throw new WebhookDeploymentConfigurationError('Select a QuickBooks account for the trigger.') + } + + const credentialContext = await getQuickBooksWebhookClientConfigByCredentialId(credentialId) + if (!credentialContext) { + throw new WebhookDeploymentConfigurationError( + 'Could not verify the connected QuickBooks company. Reconnect it and try again.' + ) + } + if (!credentialContext.clientConfig.webhookVerifierToken) { + throw new WebhookDeploymentConfigurationError( + 'This QuickBooks connection has no webhook verifier token. Reconnect it with the token from the Intuit app Webhooks settings.' + ) + } + + return { + providerConfigUpdates: { + quickBooksWebhookAppKey: credentialContext.identity.appKey, + }, + triggerPath: null, + routingKey: buildQuickBooksWebhookRoutingKey( + credentialContext.identity.appKey, + credentialContext.identity.realmId + ), + } + }, + + async verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const credentialId = + typeof providerConfig.credentialId === 'string' ? providerConfig.credentialId : '' + const credentialContext = credentialId + ? await getQuickBooksWebhookClientConfigByCredentialId(credentialId) + : null + return verifyQuickBooksSignature( + rawBody, + request.headers.get('intuit-signature'), + credentialContext?.clientConfig.webhookVerifierToken, + requestId + ) + }, + + async matchEvent({ body, providerConfig }: EventMatchContext) { + const event = asRecord(body) + const triggerId = typeof providerConfig.triggerId === 'string' ? providerConfig.triggerId : '' + const eventType = typeof event?.type === 'string' ? event.type : '' + const { isQuickBooksEventMatch, quickBooksEventTypesSubBlockId } = await import( + '@/triggers/quickbooks/quickbooks' + ) + return isQuickBooksEventMatch( + triggerId, + eventType, + providerConfig[quickBooksEventTypesSubBlockId(triggerId)] + ) + }, + + async formatInput({ body }: FormatInputContext): Promise { + const event = asRecord(body) ?? {} + const eventType = typeof event.type === 'string' ? event.type : '' + const { parseQuickBooksWebhookType } = await import('@/triggers/quickbooks/quickbooks') + const parsed = parseQuickBooksWebhookType(eventType) + + return { + input: { + eventId: typeof event.id === 'string' ? event.id : '', + eventType, + entityType: parsed?.entity ?? '', + action: parsed?.action ?? '', + entityId: typeof event.intuitentityid === 'string' ? event.intuitentityid : '', + realmId: typeof event.intuitaccountid === 'string' ? event.intuitaccountid : '', + eventTime: typeof event.time === 'string' ? event.time : '', + specVersion: typeof event.specversion === 'string' ? event.specversion : '', + source: typeof event.source === 'string' ? event.source : '', + contentType: typeof event.datacontenttype === 'string' ? event.datacontenttype : null, + data: event.data ?? null, + }, + } + }, + + extractIdempotencyId(body: unknown) { + const event = asRecord(body) + return typeof event?.id === 'string' ? event.id : null + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index a9531f5c4ea..b2549e2e905 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -40,6 +40,7 @@ import { mondayHandler } from '@/lib/webhooks/providers/monday' import { notionHandler } from '@/lib/webhooks/providers/notion' import { outlookHandler } from '@/lib/webhooks/providers/outlook' import { pagerdutyHandler } from '@/lib/webhooks/providers/pagerduty' +import { quickBooksHandler } from '@/lib/webhooks/providers/quickbooks' import { resendHandler } from '@/lib/webhooks/providers/resend' import { revenueCatHandler } from '@/lib/webhooks/providers/revenuecat' import { rootlyHandler } from '@/lib/webhooks/providers/rootly' @@ -112,6 +113,7 @@ const PROVIDER_HANDLERS: Record = { notion: notionHandler, outlook: outlookHandler, pagerduty: pagerdutyHandler, + quickbooks: quickBooksHandler, rss: rssHandler, salesforce: salesforceHandler, sendblue: sendblueHandler, diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts index bc239f3ce3e..dc9d8ffb273 100644 --- a/apps/sim/lib/webhooks/providers/types.ts +++ b/apps/sim/lib/webhooks/providers/types.ts @@ -96,6 +96,22 @@ export interface PollingConfigContext { persistProviderConfig?(providerConfig: Record): Promise } +/** Context for provider-owned routing/config resolution during deployment. */ +export interface DeploymentConfigContext { + credentialId?: string + providerConfig: Record + requestId: string + triggerId: string +} + +/** Provider-owned updates to the canonical webhook registration. */ +export interface DeploymentConfigResult { + provider?: string + providerConfigUpdates?: Record + triggerPath?: string | null + routingKey?: string | null +} + /** * Strategy interface for provider-specific webhook behavior. * Each provider implements only the methods it needs — all methods are optional. @@ -178,6 +194,9 @@ export interface WebhookProviderHandler { /** Post-process input to handle file uploads before execution. */ processInputFiles?(ctx: ProcessFilesContext): Promise + /** Resolve provider-specific routing and persisted config after generic credential validation. */ + prepareDeploymentConfig?(ctx: DeploymentConfigContext): Promise + /** Create an external webhook subscription (e.g., register with Telegram, Airtable, etc.). */ createSubscription?(ctx: SubscriptionContext): Promise diff --git a/apps/sim/lib/webhooks/quickbooks-credentials.test.ts b/apps/sim/lib/webhooks/quickbooks-credentials.test.ts new file mode 100644 index 00000000000..a28178e2f20 --- /dev/null +++ b/apps/sim/lib/webhooks/quickbooks-credentials.test.ts @@ -0,0 +1,148 @@ +/** @vitest-environment node */ + +import { account, credential } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() })) + +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: mockDecryptSecret, + encryptSecret: vi.fn(), +})) + +import { createQuickBooksAccountId } from '@/lib/oauth/quickbooks' +import { + deriveQuickBooksWebhookAppKey, + type QuickBooksOAuthClientConfig, +} from '@/lib/oauth/quickbooks-client-config' +import { + buildQuickBooksWebhookAccountIdPattern, + buildQuickBooksWebhookRoutingKey, + getQuickBooksWebhookClientConfigByCredentialId, + getQuickBooksWebhookVerifierTokensByAppKey, +} from '@/lib/webhooks/quickbooks-credentials' + +const CLIENT_CONFIG: QuickBooksOAuthClientConfig = { + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + webhookVerifierToken: 'verifier-token', +} +const APP_KEY = deriveQuickBooksWebhookAppKey(CLIENT_CONFIG) +const ACCOUNT_ID = createQuickBooksAccountId('1234567890', 'subject-1', CLIENT_CONFIG) + +describe('QuickBooks webhook credential lookup', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify(CLIENT_CONFIG) }) + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('loads and validates an app-scoped verifier token', async () => { + queueTableRows(account, [ + { + accountId: ACCOUNT_ID, + oauthConfig: 'encrypted-config', + }, + ]) + + await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).resolves.toEqual([ + 'verifier-token', + ]) + expect(mockDecryptSecret).toHaveBeenCalledWith('encrypted-config') + }) + + it('returns every distinct verifier token for accounts that share one Intuit app', async () => { + queueTableRows(account, [ + { accountId: ACCOUNT_ID, oauthConfig: 'first-config' }, + { + accountId: createQuickBooksAccountId('9876543210', 'subject-2', CLIENT_CONFIG), + oauthConfig: 'second-config', + }, + ]) + mockDecryptSecret + .mockResolvedValueOnce({ + decrypted: JSON.stringify({ ...CLIENT_CONFIG, webhookVerifierToken: 'first-verifier' }), + }) + .mockResolvedValueOnce({ + decrypted: JSON.stringify({ ...CLIENT_CONFIG, webhookVerifierToken: 'second-verifier' }), + }) + + await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).resolves.toEqual([ + 'first-verifier', + 'second-verifier', + ]) + }) + + it('fails closed instead of loading an unbounded number of app accounts', async () => { + queueTableRows( + account, + Array.from({ length: 1001 }, (_, index) => ({ + accountId: createQuickBooksAccountId(String(index + 1), `subject-${index}`, CLIENT_CONFIG), + oauthConfig: 'encrypted-config', + })) + ) + + await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).rejects.toThrow( + 'QuickBooks webhook app account limit exceeded' + ) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(1001) + expect(mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('binds a deployed credential to the same app and company identity', async () => { + queueTableRows(account, [ + { + accountId: ACCOUNT_ID, + oauthConfig: 'encrypted-config', + }, + ]) + + await expect(getQuickBooksWebhookClientConfigByCredentialId('credential-1')).resolves.toEqual({ + clientConfig: CLIENT_CONFIG, + identity: { + appKey: APP_KEY, + environment: 'sandbox', + realmId: '1234567890', + subject: 'subject-1', + }, + }) + expect(dbChainMockFns.from).toHaveBeenCalledWith(credential) + expect(dbChainMockFns.innerJoin).toHaveBeenCalledWith(account, expect.anything()) + expect(dbChainMockFns.where).toHaveBeenCalled() + }) + + it('fails closed when the encrypted config does not match the addressed app', async () => { + queueTableRows(account, [ + { + accountId: ACCOUNT_ID, + oauthConfig: 'encrypted-config', + }, + ]) + mockDecryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ ...CLIENT_CONFIG, clientId: 'different-app' }), + }) + + await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).resolves.toEqual([]) + }) + + it('escapes wildcard characters in the app-scoped account lookup', async () => { + const appKeyWithUnderscore = `${'_'.repeat(42)}A` + + expect(buildQuickBooksWebhookAccountIdPattern(appKeyWithUnderscore)).toBe( + `quickbooks:v2:${'\\_'.repeat(42)}A:%` + ) + }) + + it('constructs an app-and-company routing key and rejects malformed app keys', () => { + expect(buildQuickBooksWebhookRoutingKey(APP_KEY, ' 1234567890 ')).toBe(`${APP_KEY}:1234567890`) + expect(() => buildQuickBooksWebhookRoutingKey('invalid', '1234567890')).toThrow( + 'QuickBooks webhook app key is invalid' + ) + }) +}) diff --git a/apps/sim/lib/webhooks/quickbooks-credentials.ts b/apps/sim/lib/webhooks/quickbooks-credentials.ts new file mode 100644 index 00000000000..ed73bc31108 --- /dev/null +++ b/apps/sim/lib/webhooks/quickbooks-credentials.ts @@ -0,0 +1,136 @@ +import { db } from '@sim/db' +import { account, credential } from '@sim/db/schema' +import { and, eq, like } from 'drizzle-orm' +import { escapeLikePattern } from '@/lib/api/list-query' +import { + normalizeQuickBooksRealmId, + parseQuickBooksAccountId, + type QuickBooksAccountIdentity, +} from '@/lib/oauth/quickbooks' +import { + decryptQuickBooksOAuthClientConfig, + deriveQuickBooksWebhookAppKey, + QUICKBOOKS_WEBHOOK_APP_KEY_PATTERN, + type QuickBooksOAuthClientConfig, + QuickBooksOAuthClientConfigurationError, +} from '@/lib/oauth/quickbooks-client-config' + +const QUICKBOOKS_ACCOUNT_PREFIX = 'quickbooks:v2:' +const MAX_QUICKBOOKS_WEBHOOK_APP_ACCOUNTS = 1000 + +export interface QuickBooksWebhookCredentialContext { + clientConfig: QuickBooksOAuthClientConfig + identity: QuickBooksAccountIdentity +} + +function normalizeQuickBooksWebhookAppKey(appKey: string): string { + const normalized = appKey.trim() + if (!QUICKBOOKS_WEBHOOK_APP_KEY_PATTERN.test(normalized)) { + throw new Error('QuickBooks webhook app key is invalid') + } + return normalized +} + +export function buildQuickBooksWebhookAccountIdPattern(appKey: string): string { + const normalizedAppKey = normalizeQuickBooksWebhookAppKey(appKey) + return `${escapeLikePattern(QUICKBOOKS_ACCOUNT_PREFIX)}${escapeLikePattern(normalizedAppKey)}:%` +} + +async function decryptValidatedClientConfig( + accountId: string, + oauthConfig: string | null, + expectedAppKey: string +): Promise { + if (!oauthConfig) return null + let identity: QuickBooksAccountIdentity + try { + identity = parseQuickBooksAccountId(accountId) + } catch { + return null + } + if (identity.appKey !== expectedAppKey) return null + try { + const config = await decryptQuickBooksOAuthClientConfig(oauthConfig) + if (deriveQuickBooksWebhookAppKey(config) !== expectedAppKey) return null + return config + } catch (error) { + if (error instanceof QuickBooksOAuthClientConfigurationError) return null + throw error + } +} + +/** Loads every verifier token configured for the Intuit app addressed by its non-secret route key. */ +export async function getQuickBooksWebhookVerifierTokensByAppKey( + appKey: string +): Promise { + const normalizedAppKey = normalizeQuickBooksWebhookAppKey(appKey) + const rows = await db + .select({ + accountId: account.accountId, + oauthConfig: account.oauthConfig, + }) + .from(account) + .where( + and( + eq(account.providerId, 'quickbooks'), + like(account.accountId, buildQuickBooksWebhookAccountIdPattern(normalizedAppKey)) + ) + ) + .limit(MAX_QUICKBOOKS_WEBHOOK_APP_ACCOUNTS + 1) + + if (rows.length > MAX_QUICKBOOKS_WEBHOOK_APP_ACCOUNTS) { + throw new Error('QuickBooks webhook app account limit exceeded') + } + + const verifierTokens = new Set() + for (const row of rows) { + const config = await decryptValidatedClientConfig( + row.accountId, + row.oauthConfig, + normalizedAppKey + ) + if (config) verifierTokens.add(config.webhookVerifierToken) + } + return Array.from(verifierTokens) +} + +/** Loads the user-owned Intuit app configuration behind one QuickBooks OAuth credential. */ +export async function getQuickBooksWebhookClientConfigByCredentialId( + credentialId: string +): Promise { + const [row] = await db + .select({ + accountId: account.accountId, + oauthConfig: account.oauthConfig, + }) + .from(credential) + .innerJoin(account, eq(account.id, credential.accountId)) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'oauth'), + eq(credential.providerId, 'quickbooks'), + eq(account.providerId, 'quickbooks') + ) + ) + .limit(1) + + if (!row) return null + let identity: QuickBooksAccountIdentity + try { + identity = parseQuickBooksAccountId(row.accountId) + } catch { + return null + } + const clientConfig = await decryptValidatedClientConfig( + row.accountId, + row.oauthConfig, + identity.appKey + ) + return clientConfig ? { clientConfig, identity } : null +} + +/** Routes one Intuit app's event to only the matching connected company. */ +export function buildQuickBooksWebhookRoutingKey(appKey: string, realmId: string): string { + return `${normalizeQuickBooksWebhookAppKey(appKey)}:${normalizeQuickBooksRealmId(realmId)}` +} diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index 00d596acc31..8a5f82d2e14 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -286,6 +286,7 @@ import { collectUnresolvedReferences, preValidateCredentialInputs, validateInputsForBlock, + validateValueForSubBlockType, validateWorkflowSelectorIds, } from './validation' @@ -303,6 +304,40 @@ describe('validateInputsForBlock', () => { mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] }) }) + it('validates every value in a multi-select dropdown array', () => { + const config = { + id: 'eventTypes', + type: 'dropdown' as const, + multiSelect: true, + required: true, + options: [ + { id: 'created', label: 'Created' }, + { id: 'updated', label: 'Updated' }, + ], + } + expect( + validateValueForSubBlockType( + config, + ['created', 'updated'], + 'eventTypes', + 'quickbooks', + 'quickbooks-1' + ).valid + ).toBe(true) + expect( + validateValueForSubBlockType( + config, + ['created', 'deleted'], + 'eventTypes', + 'quickbooks', + 'quickbooks-1' + ).valid + ).toBe(false) + expect( + validateValueForSubBlockType(config, [], 'eventTypes', 'quickbooks', 'quickbooks-1').valid + ).toBe(false) + }) + it('accepts condition-input arrays with arbitrary item ids', () => { const result = validateInputsForBlock( 'condition', diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index aada8e73d19..0103b946ced 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -390,7 +390,10 @@ export function validateValueForSubBlockType( : subBlockConfig.options if (options && Array.isArray(options)) { const validIds = options.map((opt) => opt.id) - if (!validIds.includes(value)) { + const values = subBlockConfig.multiSelect && Array.isArray(value) ? value : [value] + const isEmptyRequiredMultiSelect = + subBlockConfig.multiSelect && subBlockConfig.required && values.length === 0 + if (isEmptyRequiredMultiSelect || values.some((item) => !validIds.includes(item))) { return { valid: false, error: { diff --git a/apps/sim/lib/workspace-files/queries.test.ts b/apps/sim/lib/workspace-files/queries.test.ts index 0f69aa9ce40..9e20e70a5ba 100644 --- a/apps/sim/lib/workspace-files/queries.test.ts +++ b/apps/sim/lib/workspace-files/queries.test.ts @@ -29,8 +29,8 @@ const STORED_FILE = { folderId: null, uploadedAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-02T00:00:00.000Z'), - /** Stored, but absent from `workspaceFileRecordSchema`. */ contentUpdatedAt: new Date('2026-01-03T00:00:00.000Z'), + serverOnlyField: 'not part of the public contract', } describe('listWorkspaceFilesWithShares', () => { @@ -48,7 +48,8 @@ describe('listWorkspaceFilesWithShares', () => { it('strips fields the response contract does not declare', async () => { const [file] = await listWorkspaceFilesWithShares('ws-1', 'active') - expect(file).not.toHaveProperty('contentUpdatedAt') + expect(file).not.toHaveProperty('serverOnlyField') + expect(file.contentUpdatedAt).toEqual(STORED_FILE.contentUpdatedAt) expect(file.id).toBe('file-1') expect(file.uploadedAt).toEqual(new Date('2026-01-01T00:00:00.000Z')) }) diff --git a/apps/sim/lib/workspace-files/queries.ts b/apps/sim/lib/workspace-files/queries.ts index 95c856903eb..ec402dd72e5 100644 --- a/apps/sim/lib/workspace-files/queries.ts +++ b/apps/sim/lib/workspace-files/queries.ts @@ -10,9 +10,8 @@ import { * `GET /api/workspaces/[id]/files` response contract so the workspace layout's server seed * caches exactly the shape that route returns. * - * Parsing through the route contract's response schema strips the server-only fields - * `requestJson` strips on the client (`contentUpdatedAt`), so a prefetched entry is identical - * to a client fetch rather than carrying a field that vanishes on the next refetch. + * The shared response schema keeps server-prefetched and client-fetched entries identical, + * including the content version used for conditional saves. * * Callers authorize the viewer against `workspaceId` first. * diff --git a/apps/sim/lib/workspaces/create.test.ts b/apps/sim/lib/workspaces/create.test.ts new file mode 100644 index 00000000000..5ab53d35172 --- /dev/null +++ b/apps/sim/lib/workspaces/create.test.ts @@ -0,0 +1,192 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + resetDbChainMock, + workflowsPersistenceUtilsMock, + workflowsPersistenceUtilsMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockResolveGoverningPermissionGroupOrganization, + mockLockWorkspaceCreationContext, + mockGetWorkspaceInvitePolicy, +} = vi.hoisted(() => ({ + mockResolveGoverningPermissionGroupOrganization: vi.fn(), + mockLockWorkspaceCreationContext: vi.fn(), + mockGetWorkspaceInvitePolicy: vi.fn(), +})) + +/** The starter workflow is not what these cases are about, and it reaches the block registry. */ +vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) + +vi.mock('@/lib/workflows/defaults', () => ({ + buildDefaultWorkflowArtifacts: () => ({ workflowState: {} }), +})) + +vi.mock('@/lib/workspaces/policy', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveGoverningPermissionGroupOrganization: mockResolveGoverningPermissionGroupOrganization, + lockWorkspaceCreationContext: mockLockWorkspaceCreationContext, + getWorkspaceInvitePolicy: mockGetWorkspaceInvitePolicy, + } +}) + +import type { DbOrTx } from '@/lib/db/types' +import { + createDefaultPersonalWorkspaceInTransaction, + createWorkspace, +} from '@/lib/workspaces/create' +import { WORKSPACE_MODE } from '@/lib/workspaces/policy' + +const params = { + userId: 'creator-1', + observedOrganizationId: 'org-1', + name: 'Test Workspace', + organizationId: 'org-1', + workspaceMode: WORKSPACE_MODE.ORGANIZATION, + billedAccountUserId: 'creator-1', +} + +describe('createWorkspace capability-gate placement', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetWorkspaceInvitePolicy.mockResolvedValue({}) + }) + + /** + * The ENTITLEMENT half must be settled before the transaction opens — see + * {@link resolveGoverningPermissionGroupOrganization}. + * + * Asserted as an explicit ordering rather than inferred from the absence of a + * tripwire warning: nothing else in the unit suite can catch a regression + * here, because `vitest.setup.ts` mocks `@sim/db` globally and the real pool + * instrumentation never runs. + */ + it('resolves the permission regime before opening the transaction', async () => { + mockResolveGoverningPermissionGroupOrganization.mockResolvedValue('org-1') + /** + * The callback is deliberately NOT invoked, so the transaction's own + * internals stay out of the assertion and cannot fail it for an unrelated + * reason. + */ + dbChainMockFns.transaction.mockResolvedValue({ + id: 'ws-1', + name: params.name, + organizationId: 'org-1', + workspaceMode: WORKSPACE_MODE.ORGANIZATION, + billedAccountUserId: 'creator-1', + ownerId: 'creator-1', + }) + + await createWorkspace(params) + + expect(mockResolveGoverningPermissionGroupOrganization).toHaveBeenCalledWith({ + organizationId: 'org-1', + observedOrganizationId: 'org-1', + }) + expect( + mockResolveGoverningPermissionGroupOrganization.mock.invocationCallOrder[0] + ).toBeLessThan(dbChainMockFns.transaction.mock.invocationCallOrder[0]) + }) + + /** + * The capability itself is enforced INSIDE the transaction, under the + * permission-group lock — so the governing organization has to reach + * `lockWorkspaceCreationContext`. Dropping it there would silently skip the + * gate for every governed organization. + */ + it('carries the governing organization into the locked creation context', async () => { + mockResolveGoverningPermissionGroupOrganization.mockResolvedValue('org-1') + mockLockWorkspaceCreationContext.mockResolvedValue({ billedAccountUserId: 'creator-1' }) + const tx = { insert: vi.fn(() => ({ values: vi.fn() })) } as unknown as DbOrTx + dbChainMockFns.transaction.mockImplementation( + (callback: (executor: DbOrTx) => Promise) => callback(tx) + ) + + await createWorkspace({ ...params, skipDefaultWorkflow: true }) + + expect(mockLockWorkspaceCreationContext).toHaveBeenCalledWith(tx, { + userId: 'creator-1', + organizationId: 'org-1', + observedOrganizationId: 'org-1', + governingPermissionGroupOrganizationId: 'org-1', + }) + }) + + /** + * The preflight policy resolved this value microseconds earlier in the same + * request, and React's `cache()` memo does not span the two calls, so a + * forwarded answer must be used as-is rather than re-read. + */ + it('reuses the governing organization the caller already resolved', async () => { + mockLockWorkspaceCreationContext.mockResolvedValue({ billedAccountUserId: 'creator-1' }) + const tx = { insert: vi.fn(() => ({ values: vi.fn() })) } as unknown as DbOrTx + dbChainMockFns.transaction.mockImplementation( + (callback: (executor: DbOrTx) => Promise) => callback(tx) + ) + + await createWorkspace({ + ...params, + skipDefaultWorkflow: true, + governingPermissionGroupOrganizationId: 'org-1', + }) + + expect(mockResolveGoverningPermissionGroupOrganization).not.toHaveBeenCalled() + expect(mockLockWorkspaceCreationContext).toHaveBeenCalledWith( + tx, + expect.objectContaining({ governingPermissionGroupOrganizationId: 'org-1' }) + ) + }) +}) + +describe('createDefaultPersonalWorkspaceInTransaction', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + /** + * Reached from `lib/billing/enterprise-owner-claim.ts` inside an EXTERNAL + * transaction. No organization governs it, so it must take no permission-group + * lock — which is also what keeps it from deadlocking against the locks that + * enclosing transaction already holds. + */ + it('creates an ungoverned personal workspace and resolves no regime', async () => { + mockLockWorkspaceCreationContext.mockResolvedValue({ billedAccountUserId: 'user-1' }) + const tx = { insert: vi.fn(() => ({ values: vi.fn() })) } as unknown as DbOrTx + + await createDefaultPersonalWorkspaceInTransaction(tx, { + userId: 'user-1', + userName: 'Ada Lovelace', + }) + + expect(mockResolveGoverningPermissionGroupOrganization).not.toHaveBeenCalled() + expect(mockLockWorkspaceCreationContext).toHaveBeenCalledWith(tx, { + userId: 'user-1', + organizationId: null, + observedOrganizationId: null, + governingPermissionGroupOrganizationId: null, + }) + }) + + /** The starter workflow is built before the locks, so it must still be written. */ + it('seeds the starter workflow it built before taking the locks', async () => { + mockLockWorkspaceCreationContext.mockResolvedValue({ billedAccountUserId: 'user-1' }) + const tx = { insert: vi.fn(() => ({ values: vi.fn() })) } as unknown as DbOrTx + + await createDefaultPersonalWorkspaceInTransaction(tx, { + userId: 'user-1', + userName: 'Ada Lovelace', + }) + + expect( + workflowsPersistenceUtilsMockFns.mockSaveWorkflowToNormalizedTables + ).toHaveBeenCalledWith(expect.any(String), {}, { workspaceId: null, subjectUserId: null }, tx) + }) +}) diff --git a/apps/sim/lib/workspaces/create.ts b/apps/sim/lib/workspaces/create.ts index 70d433c0e18..0c796b4e851 100644 --- a/apps/sim/lib/workspaces/create.ts +++ b/apps/sim/lib/workspaces/create.ts @@ -10,6 +10,7 @@ import { getRandomWorkspaceColor } from '@/lib/workspaces/colors' import { getWorkspaceInvitePolicy, lockWorkspaceCreationContext, + resolveGoverningPermissionGroupOrganization, resolveInviteFlags, WORKSPACE_MODE, } from '@/lib/workspaces/policy' @@ -26,6 +27,15 @@ export interface CreateWorkspaceParams { organizationId: string | null workspaceMode: WorkspaceMode billedAccountUserId: string + /** + * The governing organization already resolved by + * {@link getWorkspaceCreationPolicy}, forwarded so the entitlement read is not + * issued twice in one request. `undefined` means unresolved — a caller that + * did not go through the preflight omits it and {@link createWorkspace} + * resolves it — while `null` is the resolved answer "no organization governs + * this creation". + */ + governingPermissionGroupOrganizationId?: string | null } export interface CreatedWorkspace { @@ -52,12 +62,25 @@ export function emitWorkspaceCreatedPlatformEvent(params: { } catch {} } +/** {@link CreateWorkspaceParams} plus the pre-transaction entitlement answer. */ +export interface TransactionalCreateWorkspaceParams extends CreateWorkspaceParams { + /** + * The organization whose permission-group regime governs this creation, from + * {@link resolveGoverningPermissionGroupOrganization} (`null` for none). + * Required rather than optional: the `workspace.create` capability is enforced + * under that organization's permission-group lock inside this transaction, and + * a silently-omitted value would skip the gate rather than fail to compile. + */ + governingPermissionGroupOrganizationId: string | null +} + /** * Canonical transaction-enlisted workspace creation primitive. * * The caller supplies the creation-policy snapshot. This function revalidates - * that snapshot under the shared organization/user locks before inserting the - * workspace, owner permission, and optional starter workflow atomically. + * that snapshot — including the `workspace.create` capability, under the + * permission-group advisory lock — before inserting the workspace, owner + * permission, and optional starter workflow atomically. */ export async function createWorkspaceInTransaction( tx: DbOrTx, @@ -70,16 +93,20 @@ export async function createWorkspaceInTransaction( organizationId, workspaceMode, billedAccountUserId, - }: CreateWorkspaceParams + governingPermissionGroupOrganizationId, + }: TransactionalCreateWorkspaceParams ): Promise { const workspaceId = generateId() const workflowId = generateId() const now = new Date() const color = explicitColor || getRandomWorkspaceColor() + /** Built before the locks: it takes no arguments, so nothing makes it wait for them. */ + const defaultWorkflowArtifacts = skipDefaultWorkflow ? null : buildDefaultWorkflowArtifacts() const lockedCreationContext = await lockWorkspaceCreationContext(tx, { userId, organizationId, observedOrganizationId, + governingPermissionGroupOrganizationId, }) const committedBilledAccountUserId = workspaceMode === WORKSPACE_MODE.ORGANIZATION @@ -123,7 +150,7 @@ export async function createWorkspaceInTransaction( } await tx.insert(permissions).values(permissionRows) - if (!skipDefaultWorkflow) { + if (defaultWorkflowArtifacts) { await tx.insert(workflow).values({ id: workflowId, userId, @@ -138,10 +165,9 @@ export async function createWorkspaceInTransaction( runCount: 0, variables: {}, }) - const { workflowState } = buildDefaultWorkflowArtifacts() await saveWorkflowToNormalizedTables( workflowId, - workflowState, + defaultWorkflowArtifacts.workflowState, { /** Actorless: workspace creation seeds a platform-authored starter workflow. */ workspaceId: null, @@ -167,9 +193,27 @@ export async function createWorkspaceInTransaction( /** Creates a workspace through the canonical lock-and-insert transaction. */ export async function createWorkspace(params: CreateWorkspaceParams) { + /** + * Resolved before the transaction opens because the entitlement read it + * performs cannot run on a transaction executor — see + * {@link resolveGoverningPermissionGroupOrganization}. The capability itself is + * enforced inside the transaction, under the permission-group lock. A caller + * that already holds the creation policy forwards its answer, so the read is + * issued once per request rather than once per call. + */ + const governingPermissionGroupOrganizationId = + params.governingPermissionGroupOrganizationId !== undefined + ? params.governingPermissionGroupOrganizationId + : await resolveGoverningPermissionGroupOrganization({ + organizationId: params.organizationId, + observedOrganizationId: params.observedOrganizationId, + }) + let created: CreatedWorkspace try { - created = await db.transaction((tx) => createWorkspaceInTransaction(tx, params)) + created = await db.transaction((tx) => + createWorkspaceInTransaction(tx, { ...params, governingPermissionGroupOrganizationId }) + ) } catch (error) { logger.error('Failed to create workspace', { userId: params.userId, error }) throw error @@ -201,7 +245,15 @@ export async function createWorkspace(params: CreateWorkspaceParams) { } } -/** The same default personal workspace a first visit would create. */ +/** + * The same default personal workspace a first visit would create. + * + * Runs inside an EXTERNAL transaction (the enterprise owner claim). Both + * `organizationId` and `observedOrganizationId` are `null` by construction, so + * no organization governs this creation and the permission-group lock is never + * taken — which is why this path cannot deadlock against the locks the enclosing + * transaction already holds. + */ export async function createDefaultPersonalWorkspaceInTransaction( tx: DbOrTx, params: { userId: string; userName: string | null | undefined } @@ -214,5 +266,6 @@ export async function createDefaultPersonalWorkspaceInTransaction( organizationId: null, workspaceMode: WORKSPACE_MODE.PERSONAL, billedAccountUserId: params.userId, + governingPermissionGroupOrganizationId: null, }) } diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 95d60d2f643..a0b9fe2cd88 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -14,23 +14,35 @@ import type { DbOrTx } from '@/lib/db/types' const { mockAcquireOrganizationUserMutationLocks, + mockAcquirePermissionGroupOrgLock, mockGetUserOrganization, mockGetOrganizationSubscription, mockGetHighestPrioritySubscription, mockGetUserPermissionConfigForOrganization, mockGetUserPermissionConfig, + mockGetEntitledOrganizationPermissionConfig, + mockIsOrganizationPermissionRegimeActive, } = vi.hoisted(() => ({ mockAcquireOrganizationUserMutationLocks: vi.fn(), + mockAcquirePermissionGroupOrgLock: vi.fn(), mockGetUserOrganization: vi.fn(), mockGetOrganizationSubscription: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), mockGetUserPermissionConfigForOrganization: vi.fn(), mockGetUserPermissionConfig: vi.fn(), + mockGetEntitledOrganizationPermissionConfig: vi.fn(), + mockIsOrganizationPermissionRegimeActive: vi.fn(), })) vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfigForOrganization: mockGetUserPermissionConfigForOrganization, getUserPermissionConfig: mockGetUserPermissionConfig, + getEntitledOrganizationPermissionConfig: mockGetEntitledOrganizationPermissionConfig, + isOrganizationPermissionRegimeActive: mockIsOrganizationPermissionRegimeActive, +})) + +vi.mock('@/lib/permission-groups/locks', () => ({ + acquirePermissionGroupOrgLock: mockAcquirePermissionGroupOrgLock, })) vi.mock('@/lib/billing/organizations/membership', () => ({ @@ -51,6 +63,7 @@ import { getWorkspaceCreationPolicy, getWorkspaceInvitePolicy, lockWorkspaceCreationContext, + resolveGoverningPermissionGroupOrganization, WORKSPACE_MODE, WorkspaceCreationCapabilityWithheldError, WorkspaceCreationContextChangedError, @@ -77,6 +90,58 @@ describe('getOrganizationOwnerId', () => { }) }) +describe('resolveGoverningPermissionGroupOrganization', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('governs an organization-mode create by the destination organization', async () => { + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) + + await expect( + resolveGoverningPermissionGroupOrganization({ + organizationId: 'org-1', + observedOrganizationId: 'org-1', + }) + ).resolves.toBe('org-1') + expect(mockIsOrganizationPermissionRegimeActive).toHaveBeenCalledWith('org-1') + }) + + it('governs a personal create by the membership organization', async () => { + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) + + await expect( + resolveGoverningPermissionGroupOrganization({ + organizationId: null, + observedOrganizationId: 'org-1', + }) + ).resolves.toBe('org-1') + expect(mockIsOrganizationPermissionRegimeActive).toHaveBeenCalledWith('org-1') + }) + + it('leaves an unaffiliated creator alone, with no organization to read', async () => { + await expect( + resolveGoverningPermissionGroupOrganization({ + organizationId: null, + observedOrganizationId: null, + }) + ).resolves.toBeNull() + expect(mockIsOrganizationPermissionRegimeActive).not.toHaveBeenCalled() + }) + + it('reports an unentitled organization as ungoverned', async () => { + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(false) + + await expect( + resolveGoverningPermissionGroupOrganization({ + organizationId: 'org-1', + observedOrganizationId: 'org-1', + }) + ).resolves.toBeNull() + }) +}) + describe('lockWorkspaceCreationContext', () => { it('locks the destination organization and user before rejecting a stale org-mode policy', async () => { vi.clearAllMocks() @@ -89,6 +154,7 @@ describe('lockWorkspaceCreationContext', () => { userId: 'user-1', organizationId: 'org-1', observedOrganizationId: 'org-1', + governingPermissionGroupOrganizationId: null, }) ).rejects.toBeInstanceOf(WorkspaceCreationContextChangedError) @@ -125,6 +191,7 @@ describe('lockWorkspaceCreationContext', () => { userId: 'creator-1', organizationId: 'org-1', observedOrganizationId: 'org-1', + governingPermissionGroupOrganizationId: null, }) ).resolves.toEqual({ billedAccountUserId: 'new-owner' }) @@ -138,22 +205,16 @@ describe('lockWorkspaceCreationContext', () => { ) }) - /** - * The preflight in `getWorkspaceCreationPolicy` and the insert are separate - * requests. A group that withheld creation in between has to be caught under - * the lock, or the in-flight create lands a workspace that carries no - * `permissionGroupWorkspace` row to bring it back under the regime. - */ - it('rejects when the group withheld workspace creation after the preflight', async () => { + it('rejects when the paid org entitlement disappeared before insertion', async () => { vi.clearAllMocks() resetDbChainMock() - setEnvFlags({ isBillingEnabled: false }) + setEnvFlags({ isBillingEnabled: true }) mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) - mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'admin' }) - mockGetUserPermissionConfigForOrganization.mockResolvedValue({ - disableWorkspaceCreation: true, + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'owner', }) - queueTableRows(member, [{ userId: 'owner-1' }]) + mockGetOrganizationSubscription.mockResolvedValue(null) const tx = dbChainMock.db as unknown as DbOrTx await expect( @@ -161,62 +222,101 @@ describe('lockWorkspaceCreationContext', () => { userId: 'creator-1', organizationId: 'org-1', observedOrganizationId: 'org-1', + governingPermissionGroupOrganizationId: null, }) - ).rejects.toBeInstanceOf(WorkspaceCreationCapabilityWithheldError) - expect(mockGetUserPermissionConfigForOrganization).toHaveBeenCalledWith('org-1') + ).rejects.toBeInstanceOf(WorkspaceCreationContextChangedError) }) /** - * A personal workspace is precisely the escape from a scoped group, so the - * re-check reads the caller's membership organization even when the workspace - * being inserted carries none — the same organization the preflight used. + * The capability is re-read on the TRANSACTION executor, under + * `permission_group:` — the same advisory lock every permission-group + * mutation takes — so an admin's revocation cannot commit in the + * check-to-insert window. Asserted as an ordering and an executor identity, + * because neither can be inferred from the refusal alone. */ - it('rejects a personal workspace when the membership organization withheld creation', async () => { + it('re-reads the capability under the permission-group lock, on the transaction', async () => { vi.clearAllMocks() resetDbChainMock() + setEnvFlags({ isBillingEnabled: false }) mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) - mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'member' }) - mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + mockAcquirePermissionGroupOrgLock.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'admin' }) + mockGetEntitledOrganizationPermissionConfig.mockResolvedValue({ disableWorkspaceCreation: true, }) - const tx = dbChainMock.db as unknown as DbOrTx + const tx = {} as DbOrTx await expect( lockWorkspaceCreationContext(tx, { userId: 'creator-1', organizationId: null, observedOrganizationId: 'org-1', + governingPermissionGroupOrganizationId: 'org-1', }) ).rejects.toBeInstanceOf(WorkspaceCreationCapabilityWithheldError) + + expect(mockAcquirePermissionGroupOrgLock).toHaveBeenCalledWith(tx, 'org-1', { + lockTimeoutAlreadyBounded: true, + }) + expect(mockGetEntitledOrganizationPermissionConfig).toHaveBeenCalledWith('org-1', tx) + expect(mockAcquirePermissionGroupOrgLock.mock.invocationCallOrder[0]).toBeLessThan( + mockGetEntitledOrganizationPermissionConfig.mock.invocationCallOrder[0] + ) }) - it('leaves an unaffiliated creator alone, with no group to read', async () => { + /** + * The permission-group lock is taken only after live membership has been + * confirmed, so a caller who turns out not to belong to the organization never + * serializes against its admins. + */ + it('takes the permission-group lock last, and only after the membership check', async () => { vi.clearAllMocks() resetDbChainMock() + setEnvFlags({ isBillingEnabled: false }) mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) - mockGetUserOrganization.mockResolvedValue(null) - const tx = dbChainMock.db as unknown as DbOrTx + mockAcquirePermissionGroupOrgLock.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'admin' }) + mockGetEntitledOrganizationPermissionConfig.mockResolvedValue(null) + const tx = {} as DbOrTx await expect( lockWorkspaceCreationContext(tx, { userId: 'creator-1', organizationId: null, - observedOrganizationId: null, + observedOrganizationId: 'org-1', + governingPermissionGroupOrganizationId: 'org-1', }) ).resolves.toEqual({ billedAccountUserId: 'creator-1' }) - expect(mockGetUserPermissionConfigForOrganization).not.toHaveBeenCalled() + + expect(mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0]).toBeLessThan( + mockAcquirePermissionGroupOrgLock.mock.invocationCallOrder[0] + ) + expect(mockGetUserOrganization.mock.invocationCallOrder[0]).toBeLessThan( + mockAcquirePermissionGroupOrgLock.mock.invocationCallOrder[0] + ) }) - it('rejects when the paid org entitlement disappeared before insertion', async () => { + /** + * The organization's own revalidation — the `FOR UPDATE` subscription re-read, + * which can block for the full `lock_timeout`, and the owner lookup — runs + * BEFORE the permission-group lock, so an org-wide key every permission-group + * admin write contends on is never held across a blocking row-lock wait. + */ + it('revalidates the organization before taking the permission-group lock', async () => { vi.clearAllMocks() resetDbChainMock() setEnvFlags({ isBillingEnabled: true }) mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) - mockGetUserOrganization.mockResolvedValue({ - organizationId: 'org-1', - role: 'owner', + mockAcquirePermissionGroupOrgLock.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'admin' }) + mockGetOrganizationSubscription.mockResolvedValue({ + id: 'sub-1', + referenceId: 'org-1', + plan: 'enterprise', + status: 'active', }) - mockGetOrganizationSubscription.mockResolvedValue(null) + mockGetEntitledOrganizationPermissionConfig.mockResolvedValue(null) + queueTableRows(member, [{ userId: 'new-owner' }]) const tx = dbChainMock.db as unknown as DbOrTx await expect( @@ -224,8 +324,59 @@ describe('lockWorkspaceCreationContext', () => { userId: 'creator-1', organizationId: 'org-1', observedOrganizationId: 'org-1', + governingPermissionGroupOrganizationId: 'org-1', + }) + ).resolves.toEqual({ billedAccountUserId: 'new-owner' }) + + expect(mockGetOrganizationSubscription.mock.invocationCallOrder[0]).toBeLessThan( + mockAcquirePermissionGroupOrgLock.mock.invocationCallOrder[0] + ) + }) + + /** A membership that diverged from the snapshot refuses before any extra lock. */ + it('never takes the permission-group lock when membership already diverged', async () => { + vi.clearAllMocks() + resetDbChainMock() + mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue(null) + const tx = {} as DbOrTx + + await expect( + lockWorkspaceCreationContext(tx, { + userId: 'creator-1', + organizationId: null, + observedOrganizationId: 'org-1', + governingPermissionGroupOrganizationId: 'org-1', }) ).rejects.toBeInstanceOf(WorkspaceCreationContextChangedError) + expect(mockAcquirePermissionGroupOrgLock).not.toHaveBeenCalled() + }) + + /** + * `null` covers both ungoverned shapes: no organization at all, and an + * organization whose regime does not cover it (not on an Enterprise plan, or + * Access Control off). Reading its default group anyway would apply a stale + * config the regime no longer honours, and taking the lock anyway would + * serialize every personal create in a non-enterprise organization on one + * org-wide key for nothing. + */ + it('takes no permission-group lock when no organization governs the create', async () => { + vi.clearAllMocks() + resetDbChainMock() + mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'member' }) + const tx = {} as DbOrTx + + await expect( + lockWorkspaceCreationContext(tx, { + userId: 'creator-1', + organizationId: null, + observedOrganizationId: 'org-1', + governingPermissionGroupOrganizationId: null, + }) + ).resolves.toEqual({ billedAccountUserId: 'creator-1' }) + expect(mockAcquirePermissionGroupOrgLock).not.toHaveBeenCalled() + expect(mockGetEntitledOrganizationPermissionConfig).not.toHaveBeenCalled() }) }) @@ -237,7 +388,7 @@ describe('getWorkspaceCreationPolicy', () => { mockGetUserOrganization.mockResolvedValue(null) mockGetOrganizationSubscription.mockResolvedValue(null) mockGetHighestPrioritySubscription.mockResolvedValue(null) - mockGetUserPermissionConfigForOrganization.mockResolvedValue(null) + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(false) }) it('blocks a member whose permission group disables workspace creation', async () => { @@ -246,7 +397,8 @@ describe('getWorkspaceCreationPolicy', () => { role: 'member', memberId: 'member-1', }) - mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) + mockGetEntitledOrganizationPermissionConfig.mockResolvedValue({ disableWorkspaceCreation: true, }) queueTableRows(member, [{ role: 'member' }]) @@ -256,7 +408,13 @@ describe('getWorkspaceCreationPolicy', () => { expect(result.canCreate).toBe(false) expect(result.status).toBe(403) expect(result.blockedReasonCode).toBe('permission-group-denied') - expect(mockGetUserPermissionConfigForOrganization).toHaveBeenCalledWith('org-1') + expect(mockGetEntitledOrganizationPermissionConfig).toHaveBeenCalledWith( + 'org-1', + dbChainMock.db + ) + // Carried on the policy so creation reuses it instead of re-reading the + // entitlement: React's `cache()` memo does not span the two calls. + expect(result.governingPermissionGroupOrganizationId).toBe('org-1') }) it('governs the personal workspace a scoped-group member would otherwise escape into', async () => { @@ -265,7 +423,8 @@ describe('getWorkspaceCreationPolicy', () => { role: 'member', memberId: 'member-1', }) - mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) + mockGetEntitledOrganizationPermissionConfig.mockResolvedValue({ disableWorkspaceCreation: true, }) queueTableRows(member, [{ role: 'member' }]) @@ -290,7 +449,8 @@ describe('getWorkspaceCreationPolicy', () => { role: 'member', memberId: 'member-1', }) - mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) + mockGetEntitledOrganizationPermissionConfig.mockResolvedValue({ disableWorkspaceCreation: true, }) queueTableRows(member, [{ role: 'member' }]) @@ -298,7 +458,10 @@ describe('getWorkspaceCreationPolicy', () => { const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) expect(result.blockedReasonCode).toBe('permission-group-denied') - expect(mockGetUserPermissionConfigForOrganization).toHaveBeenCalledWith('org-1') + expect(mockGetEntitledOrganizationPermissionConfig).toHaveBeenCalledWith( + 'org-1', + dbChainMock.db + ) expect(mockGetUserPermissionConfig).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 1b18a336358..520f0e55e77 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -16,8 +16,10 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import type { DbOrTx } from '@/lib/db/types' import { capabilityRefusal, - isOrganizationCapabilityWithheld, + isEntitledOrganizationCapabilityWithheld, } from '@/lib/permission-groups/capability-assertions' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server' import { CONTACT_OWNER_TO_UPGRADE_REASON, UPGRADE_TO_INVITE_REASON, @@ -95,6 +97,16 @@ export interface WorkspaceCreationPolicy { * any membership as a mid-create join. */ observedOrganizationId: string | null + /** + * The organization whose permission-group regime governed this decision, from + * {@link resolveGoverningPermissionGroupOrganization} (`null` for none). + * + * Carried on the policy so creation can reuse it instead of resolving the + * identical value a second time: React's `cache()` memo does not span these + * two calls (an App Route installs no cache dispatcher), so the entitlement + * read would otherwise be issued twice per request. + */ + governingPermissionGroupOrganizationId: string | null /** Discriminant for blocked states the workspace mode cannot distinguish. */ blockedReasonCode?: 'organization-subscription-inactive' | 'permission-group-denied' } @@ -122,10 +134,74 @@ export class WorkspaceCreationCapabilityWithheldError extends WorkspaceCreationC } /** - * Serializes the final creation-policy check with membership/ownership - * mutations and row-locks the paid entitlement used by organization mode. - * Returns the live billing owner. The caller must invoke this in the same - * transaction as the workspace insert. + * The organization whose permission-group regime governs this creation, or + * `null` when none does — resolved BEFORE the transaction opens and passed into + * {@link lockWorkspaceCreationContext}. + * + * Falls back to `observedOrganizationId` so a personal workspace stays governed + * by the caller's own organization — see {@link getWorkspaceCreationPolicy} for + * why exempting it would defeat the gate. {@link lockWorkspaceCreationContext} + * refuses to commit unless live membership still equals that value, so a verdict + * reached here can never be applied to a different organization. + * + * Only the entitlement half of the decision is answered here, because it cannot + * be answered anywhere else — see {@link isOrganizationPermissionRegimeActive} + * for why that read admits no executor. Running it on the transaction executor + * would check out a second pooled connection while three advisory locks are + * held — what `packages/db/tx-tripwire.ts` fires on. + * + * Nothing is lost by settling it early. `permission_group:` serializes + * permission-group writes, not subscription changes, so holding it across this + * read never excluded anything. A concurrent entitlement LAPSE resolves to + * applying the group's config for one more request, which refuses rather than + * permits; a concurrent GRANT resolves to skipping the group for one more + * request, which is the same answer the route's own preflight gave microseconds + * earlier. + * + * The `forUpdate` subscription re-read below accepts Team *or* Enterprise; the + * permission-group regime is Enterprise-only, so it cannot stand in for this. + * + * The mutable half — the default group's `workspace.create` capability — is NOT + * decided here. It is re-read inside the transaction under the permission-group + * lock, which is what actually closes the revocation window. + */ +export async function resolveGoverningPermissionGroupOrganization(params: { + organizationId: string | null + observedOrganizationId: string | null +}): Promise { + const organizationId = params.organizationId ?? params.observedOrganizationId + if (!organizationId) return null + return (await isOrganizationPermissionRegimeActive(organizationId)) ? organizationId : null +} + +/** + * Serializes the membership/ownership context with the workspace insert and + * row-locks the paid entitlement used by organization mode. Returns the live + * billing owner. The caller must invoke this in the same transaction as the + * insert. + * + * permission-group-enforced: workspace.create — the capability is re-read here, + * under `permission_group:`, the same advisory lock every permission-group + * mutation takes. That is the whole point of doing it inside the transaction: + * reading it anywhere else leaves a window in which an admin's revocation + * commits between the check and the insert. The caller supplies + * `governingPermissionGroupOrganizationId` because the entitlement half of that + * decision cannot run on a transaction executor — see + * {@link resolveGoverningPermissionGroupOrganization}. + * + * LOCK ORDER: `organization-mutation:` → `user-billing-identity:` → + * `:` → `permission_group:` (a leaf lock — see + * `lib/permission-groups/locks.ts`). The permission-group lock is taken LAST, + * and only AFTER live membership has been confirmed, so a caller who turns out + * not to belong to the organization never serializes against its admins. + * + * It is also taken after the organization's own revalidation — the `FOR UPDATE` + * subscription re-read and the owner lookup — so an org-wide key that every + * permission-group admin write contends on is not held across a blocking row + * lock that can wait out the full `lock_timeout`. Only the capability read and + * the caller's inserts need its protection. The refusal order shifts with it: + * an organization that BOTH lapsed and withholds the capability now reports the + * lapse, which is the condition the admin must fix first anyway. */ export async function lockWorkspaceCreationContext( tx: DbOrTx, @@ -133,10 +209,12 @@ export async function lockWorkspaceCreationContext( userId, organizationId, observedOrganizationId, + governingPermissionGroupOrganizationId, }: { userId: string organizationId: string | null observedOrganizationId: string | null + governingPermissionGroupOrganizationId: string | null } ): Promise<{ billedAccountUserId: string }> { await acquireOrganizationUserMutationLocks(tx, { @@ -151,47 +229,47 @@ export async function lockWorkspaceCreationContext( throw new WorkspaceCreationContextChangedError() } - /** - * permission-group-enforced: workspace.create — re-read under the lock because - * the preflight in `getWorkspaceCreationPolicy` and the insert are separate - * requests: a group that withheld creation in between would otherwise still - * let the in-flight create land, and a new workspace carries no - * `permissionGroupWorkspace` row to bring it back under the regime afterwards. - * Governed by the same organization the preflight used — the explicit one, or - * the caller's membership when the workspace would be personal — so a personal - * workspace stays as governed here as it is there. - */ - const governingOrganizationId = organizationId ?? currentMembership?.organizationId ?? null - if ( - governingOrganizationId && - (await isOrganizationCapabilityWithheld(governingOrganizationId, 'workspace.create')) - ) { - throw new WorkspaceCreationCapabilityWithheldError() - } + let billedAccountUserId = userId + if (organizationId) { + if (isBillingEnabled) { + if (!currentMembership || !isOrgAdminRole(currentMembership.role)) { + throw new WorkspaceCreationContextChangedError() + } + const currentSubscription = await getOrganizationSubscription(organizationId, { + executor: tx, + onError: 'throw', + forUpdate: true, + }) + if ( + !currentSubscription || + !hasUsableSubscriptionStatus(currentSubscription.status) || + (!isTeam(currentSubscription.plan) && !isEnterprise(currentSubscription.plan)) + ) { + throw new WorkspaceCreationContextChangedError() + } + } - if (!organizationId) return { billedAccountUserId: userId } + const currentOwnerId = await getOrganizationOwnerId(organizationId, tx) + if (!currentOwnerId) throw new WorkspaceCreationContextChangedError() + billedAccountUserId = currentOwnerId + } - if (isBillingEnabled) { - if (!currentMembership || !isOrgAdminRole(currentMembership.role)) { - throw new WorkspaceCreationContextChangedError() - } - const currentSubscription = await getOrganizationSubscription(organizationId, { - executor: tx, - onError: 'throw', - forUpdate: true, + if (governingPermissionGroupOrganizationId) { + await acquirePermissionGroupOrgLock(tx, governingPermissionGroupOrganizationId, { + lockTimeoutAlreadyBounded: true, }) if ( - !currentSubscription || - !hasUsableSubscriptionStatus(currentSubscription.status) || - (!isTeam(currentSubscription.plan) && !isEnterprise(currentSubscription.plan)) + await isEntitledOrganizationCapabilityWithheld( + governingPermissionGroupOrganizationId, + 'workspace.create', + tx + ) ) { - throw new WorkspaceCreationContextChangedError() + throw new WorkspaceCreationCapabilityWithheldError() } } - const currentOwnerId = await getOrganizationOwnerId(organizationId, tx) - if (!currentOwnerId) throw new WorkspaceCreationContextChangedError() - return { billedAccountUserId: currentOwnerId } + return { billedAccountUserId } } interface GetWorkspaceCreationPolicyParams { @@ -387,8 +465,15 @@ export async function getWorkspaceCreationPolicy({ .limit(1) )[0]?.role - const governingOrganizationId = organizationId ?? membership?.organizationId ?? null - if (governingOrganizationId) { + /** + * Resolved once here and returned on the policy, so the creation call that + * follows in the same request does not re-issue the entitlement read. + */ + const governingPermissionGroupOrganizationId = await resolveGoverningPermissionGroupOrganization({ + organizationId, + observedOrganizationId: membership?.organizationId ?? null, + }) + if (governingPermissionGroupOrganizationId) { /** * A new workspace carries no `permissionGroupWorkspace` row, so a member of * a scoped group would land in a workspace that group does not target — the @@ -401,7 +486,13 @@ export async function getWorkspaceCreationPolicy({ * so exempting it would leave the gate answering only the case it is not for. */ // permission-group-enforced: workspace.create — no workspace exists yet, so the workspace-scoped funnel has nothing to resolve a group against - if (await isOrganizationCapabilityWithheld(governingOrganizationId, 'workspace.create')) { + if ( + await isEntitledOrganizationCapabilityWithheld( + governingPermissionGroupOrganizationId, + 'workspace.create', + db + ) + ) { return { canCreate: false, workspaceMode: @@ -417,6 +508,7 @@ export async function getWorkspaceCreationPolicy({ 'Your permission group does not allow creating workspaces. Ask an organization admin to change it.', status: 403, observedOrganizationId: membership?.organizationId ?? null, + governingPermissionGroupOrganizationId, blockedReasonCode: 'permission-group-denied', } } @@ -435,6 +527,7 @@ export async function getWorkspaceCreationPolicy({ reason: 'Only organization owners and admins can create organization workspaces.', status: 403, observedOrganizationId: membership?.organizationId ?? null, + governingPermissionGroupOrganizationId, } } @@ -465,6 +558,7 @@ export async function getWorkspaceCreationPolicy({ reason: null, status: 200, observedOrganizationId: membership?.organizationId ?? null, + governingPermissionGroupOrganizationId, } } @@ -480,6 +574,7 @@ export async function getWorkspaceCreationPolicy({ reason: null, status: 200, observedOrganizationId: membership?.organizationId ?? null, + governingPermissionGroupOrganizationId, } } @@ -504,6 +599,7 @@ export async function getWorkspaceCreationPolicy({ reason: 'Only organization owners and admins can create organization workspaces.', status: 403, observedOrganizationId: membership?.organizationId ?? null, + governingPermissionGroupOrganizationId, } } @@ -517,6 +613,7 @@ export async function getWorkspaceCreationPolicy({ reason: null, status: 200, observedOrganizationId: membership?.organizationId ?? null, + governingPermissionGroupOrganizationId, } } @@ -541,6 +638,7 @@ export async function getWorkspaceCreationPolicy({ "Your organization's subscription is inactive. Ask an organization owner to reactivate it before creating workspaces.", status: 403, observedOrganizationId: membership?.organizationId ?? null, + governingPermissionGroupOrganizationId, blockedReasonCode: 'organization-subscription-inactive', } } @@ -572,6 +670,7 @@ export async function getWorkspaceCreationPolicy({ reason: `This plan supports up to ${maxWorkspaces} personal workspace${maxWorkspaces === 1 ? '' : 's'}.`, status: 403, observedOrganizationId: membership?.organizationId ?? null, + governingPermissionGroupOrganizationId, } } @@ -585,6 +684,7 @@ export async function getWorkspaceCreationPolicy({ reason: null, status: 200, observedOrganizationId: membership?.organizationId ?? null, + governingPermissionGroupOrganizationId, } } diff --git a/apps/sim/package.json b/apps/sim/package.json index b7d20522da1..89753ba2f90 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -4,7 +4,7 @@ "private": true, "license": "Apache-2.0", "engines": { - "bun": ">=1.3.14", + "bun": ">=1.4.1", "node": ">=22.19.0" }, "scripts": { @@ -43,6 +43,7 @@ "@aws-sdk/client-athena": "3.1117.0", "@aws-sdk/client-bedrock-runtime": "3.1117.0", "@aws-sdk/client-cloudformation": "3.1117.0", + "@aws-sdk/client-cloudtrail": "3.1117.0", "@aws-sdk/client-cloudwatch": "3.1117.0", "@aws-sdk/client-cloudwatch-logs": "3.1117.0", "@aws-sdk/client-codepipeline": "3.1117.0", @@ -56,6 +57,7 @@ "@aws-sdk/client-secrets-manager": "3.1117.0", "@aws-sdk/client-sesv2": "3.1117.0", "@aws-sdk/client-sqs": "3.1117.0", + "@aws-sdk/client-ssm": "3.1117.0", "@aws-sdk/client-sso-admin": "3.1117.0", "@aws-sdk/client-sts": "3.1117.0", "@aws-sdk/client-textract": "3.1117.0", @@ -195,7 +197,7 @@ "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", - "marked": "15.0.12", + "marked": "18.0.11", "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", diff --git a/apps/sim/providers/cost-policy.test.ts b/apps/sim/providers/cost-policy.test.ts index 48ced0fffcd..2198f0a2269 100644 --- a/apps/sim/providers/cost-policy.test.ts +++ b/apps/sim/providers/cost-policy.test.ts @@ -123,6 +123,31 @@ describe('priceModelUsage', () => { expect(tripled.total).toBeCloseTo(single.total * 3, 8) }) + it('applies the highest matching input-size tier to every token bucket', () => { + const cost = priceModelUsage( + 'gpt-5.6-terra', + { + input: 100_000, + output: 100_000, + cacheRead: 100_000, + cacheWrites: [{ tokens: 72_001, inputRateMultiplier: 1.25 }], + }, + LIST_PRICE_POLICY + ) + + expect(cost).toMatchObject({ input: 0.800005, output: 1.8, total: 2.600005 }) + }) + + it('preserves zero-cost behavior for unregistered dynamic models', () => { + const cost = priceModelUsage( + 'dynamic-provider/model', + { input: 300_000, output: 100_000 }, + LIST_PRICE_POLICY + ) + + expect(cost).toMatchObject({ input: 0, output: 0, total: 0 }) + }) + it('charges nothing when the policy is not billable', () => { const cost = priceModelUsage( PRICED_MODEL, diff --git a/apps/sim/providers/cost-policy.ts b/apps/sim/providers/cost-policy.ts index b116a90c472..7bd801db8ee 100644 --- a/apps/sim/providers/cost-policy.ts +++ b/apps/sim/providers/cost-policy.ts @@ -1,5 +1,6 @@ import { getCostMultiplier } from '@/lib/core/config/env-flags' import type { NormalizedBlockOutput } from '@/executor/types' +import { getModelPricing, resolveModelTokenPricing } from '@/providers/pricing' import type { ModelPricing } from '@/providers/types' import { calculateCost, shouldBillModelUsage } from '@/providers/utils' @@ -161,15 +162,42 @@ export function priceModelUsage( return notBilledCost() } + const cacheRead = usage.cacheRead ?? 0 + const cacheWrites = (usage.cacheWrites ?? []).filter((write) => write.tokens > 0) + const pricing = getModelPricing(model) + + if (pricing) { + const totalInputTokens = + usage.input + cacheRead + cacheWrites.reduce((total, write) => total + write.tokens, 0) + const tokenPricing = resolveModelTokenPricing(pricing, totalInputTokens) + const inputRate = tokenPricing.input / 1_000_000 + const cachedInputRate = (tokenPricing.cachedInput ?? tokenPricing.input) / 1_000_000 + + const uncachedInputCost = usage.input * inputRate + const cacheReadCost = cacheRead * cachedInputRate + const cacheWriteCost = cacheWrites.reduce( + (total, write) => total + write.tokens * inputRate * write.inputRateMultiplier, + 0 + ) + const input = roundCost( + (uncachedInputCost + cacheReadCost + cacheWriteCost) * policy.multiplier + ) + const output = roundCost(usage.output * (tokenPricing.output / 1_000_000) * policy.multiplier) + + return { + input, + output, + total: roundCost(input + output), + pricing, + } + } + const multiplier = policy.multiplier const base = calculateCost(model, usage.input, usage.output, false, multiplier, multiplier) - - const cacheRead = usage.cacheRead ?? 0 const read = cacheRead > 0 ? calculateCost(model, cacheRead, 0, true, multiplier, 0) : undefined let writeInputCost = 0 - for (const write of usage.cacheWrites ?? []) { - if (write.tokens <= 0) continue + for (const write of cacheWrites) { writeInputCost += calculateCost( model, write.tokens, diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index 6cfd6c77c15..dbf32ffdae7 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -17,6 +17,50 @@ import { } from '@/providers/models' import { supportsPromptCaching } from '@/providers/utils' +describe('OpenAI provider definition', () => { + const openai = PROVIDER_DEFINITIONS.openai + + it('registers GPT-6 Astra as the sole recommended model with verified pricing tiers', () => { + const astra = openai.models.find((model) => model.id === 'gpt-6-astra') + + expect(astra).toMatchObject({ + pricing: { + input: 10, + cachedInput: 1, + output: 50, + tiers: [ + { + aboveInputTokens: 272000, + input: 20, + cachedInput: 2, + output: 75, + }, + ], + }, + contextWindow: 1050000, + recommended: true, + }) + expect(openai.models.filter((model) => model.recommended).map((model) => model.id)).toEqual([ + 'gpt-6-astra', + ]) + }) + + it('is included in getHostedModels since Sim provides the OpenAI key server-side', () => { + expect(getHostedModels()).toContain('gpt-6-astra') + }) +}) + +describe('catalog featured model metadata', () => { + it('defines at most one active featured model per provider', () => { + for (const provider of Object.values(PROVIDER_DEFINITIONS)) { + const featuredModels = provider.models.filter((model) => model.featured) + + expect(featuredModels.length).toBeLessThanOrEqual(1) + expect(featuredModels.every((model) => model.sunset === undefined)).toBe(true) + } + }) +}) + describe('Anthropic thinking stream visibility', () => { it('classifies visible Claude thinking as summarized rather than raw', () => { for (const providerId of ['anthropic', 'azure-anthropic'] as const) { @@ -29,6 +73,34 @@ describe('Anthropic thinking stream visibility', () => { }) }) +describe('Anthropic provider definition', () => { + const anthropic = PROVIDER_DEFINITIONS.anthropic + + it('matches Anthropic lifecycle classifications', () => { + expect( + anthropic.models.filter((model) => model.sunset?.status === 'legacy').map((model) => model.id) + ).toEqual([ + 'claude-fable-5', + 'claude-opus-4-8', + 'claude-opus-4-7', + 'claude-opus-4-6', + 'claude-sonnet-4-6', + 'claude-opus-4-5', + 'claude-sonnet-4-5', + ]) + expect( + anthropic.models + .filter((model) => model.sunset?.status === 'deprecated') + .map((model) => model.id) + ).toEqual([ + 'claude-opus-4-1', + 'claude-opus-4-0', + 'claude-sonnet-4-0', + 'claude-3-haiku-20240307', + ]) + }) +}) + describe('Meta thinking stream visibility', () => { it('classifies private Muse reasoning as not streamed', () => { expect(getThinkingStreamVisibility('muse-spark-1.1')).toBe('none') @@ -59,9 +131,10 @@ describe('prompt caching capability', () => { expect(supportsPromptCaching('gpt-5.5')).toBe(false) }) - it('reports the vendor minimum prefix, raised for Haiku', () => { + it('reports each vendor minimum prefix, including retired Haiku 3', () => { expect(getPromptCachingMinimumTokens('claude-sonnet-5')).toBe(1024) expect(getPromptCachingMinimumTokens('claude-haiku-4-5')).toBe(4096) + expect(getPromptCachingMinimumTokens('claude-3-haiku-20240307')).toBe(2048) expect(getPromptCachingMinimumTokens('azure-anthropic/claude-haiku-4-5')).toBe(4096) expect(getPromptCachingMinimumTokens('gpt-5.5')).toBeNull() }) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index cf8658f5afb..84e22f4a81c 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -93,6 +93,8 @@ interface ModelDefinition { contextWindow?: number /** ISO date string (YYYY-MM-DD) when the model was first publicly released */ releaseDate?: string + /** Promotes this model on public catalog surfaces, independently of workflow recommendations. */ + featured?: boolean recommended?: boolean speedOptimized?: boolean /** @@ -326,6 +328,33 @@ export const PROVIDER_DEFINITIONS: Record = { toolUsageControl: true, }, models: [ + { + id: 'gpt-6-astra', + pricing: { + input: 10.0, + cachedInput: 1.0, + output: 50.0, + tiers: [ + { + aboveInputTokens: 272000, + input: 20.0, + cachedInput: 2.0, + output: 75.0, + }, + ], + updatedAt: '2026-09-04', + }, + capabilities: { + reasoningEffort: { + values: ['low', 'medium', 'high', 'xhigh', 'max'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-09-03', + featured: true, + recommended: true, + }, // GPT-4.1 family { id: 'gpt-4.1', @@ -333,7 +362,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 2.0, cachedInput: 0.5, output: 8.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -348,7 +377,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 0.4, cachedInput: 0.1, output: 1.6, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -363,7 +392,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 0.1, cachedInput: 0.025, output: 0.4, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -377,10 +406,18 @@ export const PROVIDER_DEFINITIONS: Record = { { id: 'gpt-5.6-sol', pricing: { - input: 5.0, - cachedInput: 0.5, - output: 30.0, - updatedAt: '2026-07-09', + input: 4.0, + cachedInput: 0.4, + output: 20.0, + tiers: [ + { + aboveInputTokens: 272000, + input: 8.0, + cachedInput: 0.8, + output: 30.0, + }, + ], + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -393,19 +430,26 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1050000, releaseDate: '2026-07-09', - recommended: true, }, { id: 'gpt-5.6-terra', pricing: { - input: 2.5, - cachedInput: 0.25, - output: 15.0, - updatedAt: '2026-07-09', + input: 2.0, + cachedInput: 0.2, + output: 12.0, + tiers: [ + { + aboveInputTokens: 272000, + input: 4.0, + cachedInput: 0.4, + output: 18.0, + }, + ], + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { - values: ['none', 'low', 'medium', 'high', 'xhigh'], + values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], }, verbosity: { values: ['low', 'medium', 'high'], @@ -418,14 +462,22 @@ export const PROVIDER_DEFINITIONS: Record = { { id: 'gpt-5.6-luna', pricing: { - input: 1.0, - cachedInput: 0.1, - output: 6.0, - updatedAt: '2026-07-09', + input: 0.2, + cachedInput: 0.02, + output: 1.2, + tiers: [ + { + aboveInputTokens: 272000, + input: 0.4, + cachedInput: 0.04, + output: 1.8, + }, + ], + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { - values: ['none', 'low', 'medium', 'high', 'xhigh'], + values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], }, verbosity: { values: ['low', 'medium', 'high'], @@ -442,10 +494,16 @@ export const PROVIDER_DEFINITIONS: Record = { pricing: { input: 30.0, output: 180.0, - updatedAt: '2026-06-11', + tiers: [ + { + aboveInputTokens: 272000, + input: 60.0, + output: 270.0, + }, + ], + updatedAt: '2026-09-04', }, capabilities: { - nativeStructuredOutputs: true, reasoningEffort: { values: ['medium', 'high', 'xhigh'], }, @@ -460,10 +518,17 @@ export const PROVIDER_DEFINITIONS: Record = { input: 5.0, cachedInput: 0.5, output: 30.0, - updatedAt: '2026-06-11', + tiers: [ + { + aboveInputTokens: 272000, + input: 10.0, + cachedInput: 1.0, + output: 45.0, + }, + ], + updatedAt: '2026-09-04', }, capabilities: { - nativeStructuredOutputs: true, reasoningEffort: { values: ['none', 'low', 'medium', 'high', 'xhigh'], }, @@ -481,7 +546,14 @@ export const PROVIDER_DEFINITIONS: Record = { pricing: { input: 30.0, output: 180.0, - updatedAt: '2026-06-11', + tiers: [ + { + aboveInputTokens: 272000, + input: 60.0, + output: 270.0, + }, + ], + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -498,7 +570,15 @@ export const PROVIDER_DEFINITIONS: Record = { input: 2.5, cachedInput: 0.25, output: 15.0, - updatedAt: '2026-06-11', + tiers: [ + { + aboveInputTokens: 272000, + input: 5.0, + cachedInput: 0.5, + output: 22.5, + }, + ], + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -518,7 +598,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 0.75, cachedInput: 0.075, output: 4.5, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -538,7 +618,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 0.2, cachedInput: 0.02, output: 1.25, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -559,7 +639,7 @@ export const PROVIDER_DEFINITIONS: Record = { pricing: { input: 21.0, output: 168.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -576,7 +656,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 1.75, cachedInput: 0.175, output: 14.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -597,7 +677,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 1.25, cachedInput: 0.125, output: 10.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -617,7 +697,7 @@ export const PROVIDER_DEFINITIONS: Record = { pricing: { input: 15.0, output: 120.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -634,7 +714,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 1.25, cachedInput: 0.125, output: 10.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -654,7 +734,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 0.25, cachedInput: 0.025, output: 2.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -674,7 +754,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 0.05, cachedInput: 0.005, output: 0.4, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -694,7 +774,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 1.25, cachedInput: 0.125, output: 10.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -702,7 +782,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 128000, releaseDate: '2025-08-07', - sunset: { status: 'legacy' }, + sunset: { status: 'deprecated' }, }, // o-series reasoning models { @@ -711,7 +791,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 1.1, cachedInput: 0.275, output: 4.4, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -728,7 +808,7 @@ export const PROVIDER_DEFINITIONS: Record = { pricing: { input: 20.0, output: 80.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { maxOutputTokens: 100000, @@ -742,7 +822,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 2, cachedInput: 0.5, output: 8, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -760,7 +840,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 1.1, cachedInput: 0.55, output: 4.4, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -778,7 +858,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 15.0, cachedInput: 7.5, output: 60, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { reasoningEffort: { @@ -797,7 +877,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 2.5, cachedInput: 1.25, output: 10.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -831,7 +911,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 10.0, cachedInput: 0.25, output: 50.0, - updatedAt: '2026-09-01', + updatedAt: '2026-09-04', }, capabilities: { nativeStructuredOutputs: true, @@ -845,6 +925,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1000000, releaseDate: '2026-09-01', + featured: true, }, { id: 'claude-fable-5', @@ -852,7 +933,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 10.0, cachedInput: 1.0, output: 50.0, - updatedAt: '2026-07-01', + updatedAt: '2026-09-04', }, capabilities: { nativeStructuredOutputs: true, @@ -866,6 +947,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1000000, releaseDate: '2026-06-09', + sunset: { status: 'legacy' }, }, { id: 'claude-sonnet-5', @@ -873,7 +955,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 2.0, cachedInput: 0.2, output: 10.0, - updatedAt: '2026-06-30', + updatedAt: '2026-09-04', }, capabilities: { nativeStructuredOutputs: true, @@ -894,7 +976,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 5.0, cachedInput: 0.5, output: 25.0, - updatedAt: '2026-07-24', + updatedAt: '2026-09-04', }, capabilities: { nativeStructuredOutputs: true, @@ -916,7 +998,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 5.0, cachedInput: 0.5, output: 25.0, - updatedAt: '2026-05-28', + updatedAt: '2026-09-04', }, capabilities: { nativeStructuredOutputs: true, @@ -929,6 +1011,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1000000, releaseDate: '2026-05-28', + sunset: { status: 'legacy' }, }, { id: 'claude-opus-4-7', @@ -936,7 +1019,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 5.0, cachedInput: 0.5, output: 25.0, - updatedAt: '2026-04-16', + updatedAt: '2026-09-04', }, capabilities: { nativeStructuredOutputs: true, @@ -950,6 +1033,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1000000, releaseDate: '2026-04-16', + sunset: { status: 'legacy' }, }, { id: 'claude-opus-4-6', @@ -957,7 +1041,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 5.0, cachedInput: 0.5, output: 25.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 1 }, @@ -972,6 +1056,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1000000, releaseDate: '2026-02-05', + sunset: { status: 'legacy' }, }, { id: 'claude-sonnet-4-6', @@ -979,7 +1064,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 3.0, cachedInput: 0.3, output: 15.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 1 }, @@ -993,6 +1078,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1000000, releaseDate: '2026-02-17', + sunset: { status: 'legacy' }, }, { id: 'claude-opus-4-5', @@ -1000,7 +1086,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 5.0, cachedInput: 0.5, output: 25.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 1 }, @@ -1015,6 +1101,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 200000, releaseDate: '2025-11-24', + sunset: { status: 'legacy' }, }, { id: 'claude-opus-4-1', @@ -1022,7 +1109,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 15.0, cachedInput: 1.5, output: 75.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 1 }, @@ -1035,7 +1122,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 200000, releaseDate: '2025-08-05', - sunset: { status: 'legacy' }, + sunset: { status: 'deprecated' }, }, { id: 'claude-opus-4-0', @@ -1043,7 +1130,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 15.0, cachedInput: 1.5, output: 75.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 1 }, @@ -1064,7 +1151,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 3.0, cachedInput: 0.3, output: 15.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 1 }, @@ -1078,6 +1165,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 200000, releaseDate: '2025-09-29', + sunset: { status: 'legacy' }, }, { id: 'claude-sonnet-4-0', @@ -1085,7 +1173,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 3.0, cachedInput: 0.3, output: 15.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 1 }, @@ -1106,7 +1194,7 @@ export const PROVIDER_DEFINITIONS: Record = { input: 1.0, cachedInput: 0.1, output: 5.0, - updatedAt: '2026-06-11', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 1 }, @@ -1129,11 +1217,12 @@ export const PROVIDER_DEFINITIONS: Record = { input: 0.25, cachedInput: 0.03, output: 1.25, - updatedAt: '2026-04-01', + updatedAt: '2026-09-04', }, capabilities: { temperature: { min: 0, max: 1 }, maxOutputTokens: 4096, + promptCaching: { minimumCacheableTokens: 2048 }, }, contextWindow: 200000, releaseDate: '2024-03-13', @@ -1612,6 +1701,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1048576, releaseDate: '2026-09-02', + featured: true, recommended: true, }, { diff --git a/apps/sim/providers/openai/usage.test.ts b/apps/sim/providers/openai/usage.test.ts index 6d546a7db5f..5fcb590c64d 100644 --- a/apps/sim/providers/openai/usage.test.ts +++ b/apps/sim/providers/openai/usage.test.ts @@ -13,7 +13,7 @@ import { calculateCost } from '@/providers/utils' /** input $2.50/M, cachedInput $1.25/M, output $10.00/M. */ const MODEL = 'gpt-4o' -/** input $2.50/M, cachedInput $0.25/M, output $15.00/M — bills cache writes. */ +/** Short context: $2/M input, $0.20/M cached, $12/M output; long context: $4/$0.40/$18. */ const CACHE_WRITE_MODEL = 'gpt-5.6-terra' /** @@ -97,11 +97,10 @@ describe('OpenAI usage aggregation', () => { }) ) - /** 1M written at $2.50/M x 1.25. */ expect(buildOpenAIUsageCost(CACHE_WRITE_MODEL, usage)).toMatchObject({ - input: 3.125, + input: 5, output: 0, - total: 3.125, + total: 5, }) }) @@ -124,11 +123,10 @@ describe('OpenAI usage aggregation', () => { cacheRead: 600_000, cacheWrite: 200_000, }) - /** 0.5 uncached + 0.15 cached + 0.625 written input, 1.5 output. */ expect(buildOpenAIUsageCost(CACHE_WRITE_MODEL, usage)).toMatchObject({ - input: 1.275, - output: 1.5, - total: 2.775, + input: 2.04, + output: 1.8, + total: 3.84, }) }) @@ -154,6 +152,18 @@ describe('OpenAI usage aggregation', () => { }) }) + it('resolves input-size pricing independently for each tool-loop turn', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 200_000, completionTokens: 10_000 })) + addOpenAIUsage(usage, responsesUsage({ promptTokens: 200_000, completionTokens: 10_000 })) + + expect(buildOpenAIUsageCost(CACHE_WRITE_MODEL, usage)).toMatchObject({ + input: 0.8, + output: 0.24, + total: 1.04, + }) + }) + it('ignores turns that reported no usage', () => { const usage = createOpenAIUsageAccumulator() addOpenAIUsage(usage, responsesUsage({ promptTokens: 1_000, completionTokens: 100 })) diff --git a/apps/sim/providers/openai/usage.ts b/apps/sim/providers/openai/usage.ts index 3d9cdb362e7..515fc8a8191 100644 --- a/apps/sim/providers/openai/usage.ts +++ b/apps/sim/providers/openai/usage.ts @@ -1,9 +1,9 @@ import type { BlockTokens } from '@/executor/types' -import { LIST_PRICE_POLICY, type ModelUsage, priceModelUsage } from '@/providers/cost-policy' +import { LIST_PRICE_POLICY, priceModelUsage } from '@/providers/cost-policy' import { - OPENAI_CACHE_WRITE_MULTIPLIER, type ResponsesUsageTokens, splitOpenAIUsage, + toOpenAIModelUsage, } from '@/providers/openai/utils' import type { ModelPricing } from '@/providers/types' @@ -19,6 +19,8 @@ export interface OpenAIUsageAccumulator { total: number cacheRead: number cacheWrite: number + /** Per-request usage retained so input-size pricing tiers apply to each provider request. */ + turns: ResponsesUsageTokens[] } interface OpenAIUsageCost { @@ -43,6 +45,7 @@ export function createOpenAIUsageAccumulator(): OpenAIUsageAccumulator { total: 0, cacheRead: 0, cacheWrite: 0, + turns: [], } } @@ -61,6 +64,7 @@ export function addOpenAIUsage( if (!usage) return const split = splitOpenAIUsage(usage) + accumulator.turns.push(usage) accumulator.input += split.input accumulator.output += split.output @@ -86,26 +90,8 @@ export function buildOpenAIUsageTokens( } /** - * Builds the normalized usage for one OpenAI request. - * - * `input` is already the uncached remainder because {@link addOpenAIUsage} - * subtracted the cache buckets per turn — unlike Anthropic, whose - * `input_tokens` arrives exclusive of them. - */ -export function buildOpenAIModelUsage(accumulator: OpenAIUsageAccumulator): ModelUsage { - return { - input: accumulator.input, - output: accumulator.output, - cacheRead: accumulator.cacheRead, - cacheWrites: [ - { tokens: accumulator.cacheWrite, inputRateMultiplier: OPENAI_CACHE_WRITE_MULTIPLIER }, - ], - } -} - -/** - * Prices one OpenAI request, cache reads and writes included, through the - * shared pricing function. + * Prices every OpenAI request in an execution, cache reads and writes included, + * through the shared pricing function. * * Always at list price. Billability and the margin are applied once, centrally, * by `executeProviderRequest` — a provider applying them here would double-count @@ -116,13 +102,24 @@ export function buildOpenAIUsageCost( accumulator: OpenAIUsageAccumulator, toolCost = 0 ): OpenAIUsageCost { - const cost = priceModelUsage(model, buildOpenAIModelUsage(accumulator), LIST_PRICE_POLICY) + const emptyCost = priceModelUsage(model, { input: 0, output: 0 }, LIST_PRICE_POLICY) + let input = 0 + let output = 0 + + for (const turn of accumulator.turns) { + const turnCost = priceModelUsage(model, toOpenAIModelUsage(turn), LIST_PRICE_POLICY) + input += turnCost.input + output += turnCost.output + } + + input = roundedCost(input) + output = roundedCost(output) return { - input: cost.input, - output: cost.output, - total: roundedCost(cost.total + toolCost), + input, + output, + total: roundedCost(input + output + toolCost), ...(toolCost > 0 ? { toolCost } : {}), - pricing: cost.pricing, + pricing: emptyCost.pricing, } } diff --git a/apps/sim/providers/pricing.test.ts b/apps/sim/providers/pricing.test.ts new file mode 100644 index 00000000000..20e6819f36f --- /dev/null +++ b/apps/sim/providers/pricing.test.ts @@ -0,0 +1,29 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { resolveModelTokenPricing } from '@/providers/pricing' +import type { ModelPricing } from '@/providers/types' + +describe('resolveModelTokenPricing', () => { + const pricing: ModelPricing = { + input: 1, + output: 2, + tiers: [ + { aboveInputTokens: 100, input: 3, output: 4 }, + { aboveInputTokens: 1000, input: 5, output: 6 }, + ], + updatedAt: '2026-09-04', + } + + it('uses base pricing through the first threshold', () => { + expect(resolveModelTokenPricing(pricing, 100)).toMatchObject({ input: 1, output: 2 }) + }) + + it('uses the highest matching threshold regardless of declaration order', () => { + const reversed = { ...pricing, tiers: [...(pricing.tiers ?? [])].reverse() } + + expect(resolveModelTokenPricing(reversed, 101)).toMatchObject({ input: 3, output: 4 }) + expect(resolveModelTokenPricing(reversed, 1001)).toMatchObject({ input: 5, output: 6 }) + }) +}) diff --git a/apps/sim/providers/pricing.ts b/apps/sim/providers/pricing.ts new file mode 100644 index 00000000000..2c335651a23 --- /dev/null +++ b/apps/sim/providers/pricing.ts @@ -0,0 +1,28 @@ +import { + getEmbeddingModelPricing, + getModelPricing as getModelPricingFromDefinitions, +} from '@/providers/models' +import type { ModelPricing, ModelTokenPricing } from '@/providers/types' + +/** Returns registered model pricing without applying the display-only fallback. */ +export function getModelPricing(modelId: string): ModelPricing | null { + return getEmbeddingModelPricing(modelId) ?? getModelPricingFromDefinitions(modelId) +} + +/** Resolves the token rates that apply to the full request input size. */ +export function resolveModelTokenPricing( + pricing: ModelPricing, + inputTokens: number +): ModelTokenPricing { + let resolved: ModelTokenPricing = pricing + let resolvedThreshold = Number.NEGATIVE_INFINITY + + for (const tier of pricing.tiers ?? []) { + if (inputTokens > tier.aboveInputTokens && tier.aboveInputTokens > resolvedThreshold) { + resolved = tier + resolvedThreshold = tier.aboveInputTokens + } + } + + return resolved +} diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 4bebf5102ca..d8793926360 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -29,10 +29,20 @@ export type ProviderId = | 'litellm' | 'bedrock' -export interface ModelPricing { +export interface ModelTokenPricing { input: number // Per 1M tokens cachedInput?: number // Per 1M tokens (if supported) output: number // Per 1M tokens +} + +export interface ModelPricingTier extends ModelTokenPricing { + /** Tier applies to the full request when total input tokens exceed this value. */ + aboveInputTokens: number +} + +export interface ModelPricing extends ModelTokenPricing { + /** Additional input-size tiers; the highest matching threshold wins. */ + tiers?: ModelPricingTier[] updatedAt: string // Last updated date } diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 72cc7080427..14fbe4d68d1 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -820,6 +820,14 @@ describe('Cost Calculation', () => { expect(cachedCost.output).toBe(regularCost.output) }) + it('should select pricing tiers from the full request input size', () => { + const shortContext = calculateCost('gpt-5.6-terra', 272_000, 100_000) + const longContext = calculateCost('gpt-5.6-terra', 272_001, 100_000) + + expect(shortContext).toMatchObject({ input: 0.544, output: 1.2, total: 1.744 }) + expect(longContext).toMatchObject({ input: 1.088004, output: 1.8, total: 2.888004 }) + }) + it('should return default pricing for unknown models', () => { const result = calculateCost('unknown-model', 1000, 500, false) @@ -869,6 +877,7 @@ describe('getHostedModels', () => { it('should return OpenAI, Anthropic, Google, and xAI models as hosted', () => { const hostedModels = getHostedModels() + expect(hostedModels).toContain('gpt-6-astra') expect(hostedModels).toContain('gpt-4o') expect(hostedModels).toContain('o1') @@ -896,6 +905,7 @@ describe('getHostedModels', () => { describe('shouldBillModelUsage', () => { it('should return true for exact matches of hosted models', () => { + expect(shouldBillModelUsage('gpt-6-astra')).toBe(true) expect(shouldBillModelUsage('gpt-4o')).toBe(true) expect(shouldBillModelUsage('o1')).toBe(true) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index d58f9222f32..ba9d19a2728 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -26,11 +26,9 @@ import type { SubBlockConfig } from '@/blocks/types' import { isCustomTool } from '@/executor/constants' import { getComputerUseModels, - getEmbeddingModelPricing, getHostedModels as getHostedModelsFromDefinitions, getMaxOutputTokensForModel as getMaxOutputTokensForModelFromDefinitions, getMaxTemperature as getMaxTempFromDefinitions, - getModelPricing as getModelPricingFromDefinitions, getModelsWithDeepResearch, getModelsWithoutMemory, getModelsWithPromptCaching, @@ -51,13 +49,17 @@ import { supportsToolUsageControl as supportsToolUsageControlFromDefinitions, updateOllamaModels as updateOllamaModelsInDefinitions, } from '@/providers/models' +import { + getModelPricing as getRegisteredModelPricing, + resolveModelTokenPricing, +} from '@/providers/pricing' import { collectToolResourceBindings, registerProviderToolBindings } from '@/providers/tool-binding' import { getProviderToolInputProvenance, getProviderToolModelInputRegistry, registerPreparedProviderToolInputProvenance, } from '@/providers/tool-input-provenance' -import type { ProviderId, ProviderToolConfig } from '@/providers/types' +import type { ModelPricing, ProviderId, ProviderToolConfig } from '@/providers/types' import { useProvidersStore } from '@/stores/providers/store' import { mergeToolParameters } from '@/tools/merge-params' import { buildToolParamShapes, decodeToolParams } from '@/tools/param-shape' @@ -1009,11 +1011,7 @@ export function calculateCost( inputMultiplier?: number, outputMultiplier?: number ) { - let pricing = getEmbeddingModelPricing(model) - - if (!pricing) { - pricing = getModelPricingFromDefinitions(model) - } + const pricing = getRegisteredModelPricing(model) if (!pricing) { const defaultPricing = { @@ -1030,13 +1028,14 @@ export function calculateCost( } } + const tokenPricing = resolveModelTokenPricing(pricing, promptTokens) const inputCost = promptTokens * - (useCachedInput && pricing.cachedInput - ? pricing.cachedInput / 1_000_000 - : pricing.input / 1_000_000) + (useCachedInput && tokenPricing.cachedInput + ? tokenPricing.cachedInput / 1_000_000 + : tokenPricing.input / 1_000_000) - const outputCost = completionTokens * (pricing.output / 1_000_000) + const outputCost = completionTokens * (tokenPricing.output / 1_000_000) const finalInputCost = inputCost * (inputMultiplier ?? 1) const finalOutputCost = outputCost * (outputMultiplier ?? 1) const finalTotalCost = finalInputCost + finalOutputCost @@ -1116,13 +1115,8 @@ export function sumToolCosts(toolResults?: Record[]): number { return total } -export function getModelPricing(modelId: string): any { - const embeddingPricing = getEmbeddingModelPricing(modelId) - if (embeddingPricing) { - return embeddingPricing - } - - return getModelPricingFromDefinitions(modelId) +export function getModelPricing(modelId: string): ModelPricing | null { + return getRegisteredModelPricing(modelId) } /** diff --git a/apps/sim/public/library/6-best-ai-observability-tools-for-production-agents-in-2026/cover.jpg b/apps/sim/public/library/6-best-ai-observability-tools-for-production-agents-in-2026/cover.jpg new file mode 100644 index 00000000000..68bdc569a8b Binary files /dev/null and b/apps/sim/public/library/6-best-ai-observability-tools-for-production-agents-in-2026/cover.jpg differ diff --git a/apps/sim/public/static/readme-desktop.png b/apps/sim/public/static/readme-desktop.png new file mode 100644 index 00000000000..f8f67f55fa0 Binary files /dev/null and b/apps/sim/public/static/readme-desktop.png differ diff --git a/apps/sim/scripts/pi-sandbox-packages.ts b/apps/sim/scripts/pi-sandbox-packages.ts index a2296e3fc20..dfc102591f0 100644 --- a/apps/sim/scripts/pi-sandbox-packages.ts +++ b/apps/sim/scripts/pi-sandbox-packages.ts @@ -14,7 +14,7 @@ */ /** Bun version mirrored from the root packageManager field. */ -export const PI_BUN_VERSION = '1.3.14' +export const PI_BUN_VERSION = '1.4.1' /** Exact Pi version mirrored from the app dependencies and lockfile. */ export const PI_PACKAGE_VERSION = '0.80.10' diff --git a/apps/sim/stores/file-viewer/store.test.ts b/apps/sim/stores/file-viewer/store.test.ts new file mode 100644 index 00000000000..fa322292149 --- /dev/null +++ b/apps/sim/stores/file-viewer/store.test.ts @@ -0,0 +1,28 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useFileViewerStore } from '@/stores/file-viewer/store' +import { resetRegisteredUserData } from '@/stores/user-data-reset-registry' + +describe('file viewer session state', () => { + beforeEach(() => useFileViewerStore.getState().reset()) + + it('notifies subscribers only for newly recognized pages', () => { + const listener = vi.fn() + const unsubscribe = useFileViewerStore.subscribe(listener) + const { rememberPage } = useFileViewerStore.getState() + const previous = useFileViewerStore.getState().pageFileIds + rememberPage('page-a') + rememberPage('page-a') + expect(listener).toHaveBeenCalledOnce() + expect(previous.size).toBe(0) + expect(useFileViewerStore.getState().pageFileIds.has('page-a')).toBe(true) + expect(useFileViewerStore.getState().pageFileIds.has('page-b')).toBe(false) + unsubscribe() + }) + + it('clears recognition when the session is reset', () => { + useFileViewerStore.getState().rememberPage('page-a') + resetRegisteredUserData() + expect(useFileViewerStore.getState().pageFileIds.size).toBe(0) + }) +}) diff --git a/apps/sim/stores/file-viewer/store.ts b/apps/sim/stores/file-viewer/store.ts new file mode 100644 index 00000000000..03b8773c8a2 --- /dev/null +++ b/apps/sim/stores/file-viewer/store.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' +import { registerUserDataReset } from '@/stores/user-data-reset-registry' + +interface FileViewerState { + /** Session-only recognition survives partial page streams and viewer remounts. */ + pageFileIds: ReadonlySet + rememberPage: (fileId: string) => void + reset: () => void +} + +export const useFileViewerStore = create()( + devtools( + (set) => ({ + pageFileIds: new Set(), + rememberPage: (fileId) => + set((state) => { + if (state.pageFileIds.has(fileId)) return state + return { pageFileIds: new Set([...state.pageFileIds, fileId]) } + }), + reset: () => set({ pageFileIds: new Set() }), + }), + { name: 'file-viewer-store' } + ) +) + +registerUserDataReset('file-viewer', () => useFileViewerStore.getState().reset()) diff --git a/apps/sim/tools/cloudtrail/cancel_query.ts b/apps/sim/tools/cloudtrail/cancel_query.ts new file mode 100644 index 00000000000..6c769ac8306 --- /dev/null +++ b/apps/sim/tools/cloudtrail/cancel_query.ts @@ -0,0 +1,93 @@ +import type { + CloudTrailCancelQueryParams, + CloudTrailCancelQueryResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const cancelQueryTool: InternalToolConfig< + CloudTrailCancelQueryParams, + CloudTrailCancelQueryResponse +> = { + id: 'cloudtrail_cancel_query', + name: 'CloudTrail Cancel Query', + description: 'Cancel a running CloudTrail Lake query', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + queryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the query returned by Start Query', + }, + eventDataStoreOwnerAccountId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Account ID of the event data store owner, for a shared event data store', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + queryId: params.queryId, + ...(params.eventDataStoreOwnerAccountId && { + eventDataStoreOwnerAccountId: params.eventDataStoreOwnerAccountId, + }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to cancel CloudTrail Lake query') + } + return { + success: true, + output: { + queryId: data.output.queryId, + queryStatus: data.output.queryStatus ?? null, + eventDataStoreOwnerAccountId: data.output.eventDataStoreOwnerAccountId ?? null, + }, + } + }, + + outputs: { + queryId: { + type: 'string', + description: 'ID of the cancelled query', + }, + queryStatus: { + type: 'string', + description: + 'Status AWS reported for the query after the cancellation request. Cancellation is asynchronous, so this is typically RUNNING or CANCELLED — poll Describe Lake Query for the terminal status', + optional: true, + }, + eventDataStoreOwnerAccountId: { + type: 'string', + description: 'Account ID of the event data store owner, when the query was cross-account', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/contract-validation.test.ts b/apps/sim/tools/cloudtrail/contract-validation.test.ts new file mode 100644 index 00000000000..207cb51ecba --- /dev/null +++ b/apps/sim/tools/cloudtrail/contract-validation.test.ts @@ -0,0 +1,191 @@ +/** + * @vitest-environment node + * + * Boundary rules the CloudTrail contracts must enforce before a request reaches AWS: + * positional query parameters may not be silently reshaped, `refreshId` is only meaningful + * alongside `queryAlias`, and a bare trail name is capped at 128 characters even though the + * same field accepts a 256-character ARN. + */ +import { describe, expect, it } from 'vitest' +import type { z } from 'zod' +import { awsCloudtrailDescribeQueryContract } from '@/lib/api/contracts/tools/aws/cloudtrail-describe-query' +import { awsCloudtrailDescribeTrailsContract } from '@/lib/api/contracts/tools/aws/cloudtrail-describe-trails' +import { awsCloudtrailGetEventDataStoreContract } from '@/lib/api/contracts/tools/aws/cloudtrail-get-event-data-store' +import { awsCloudtrailGetInsightSelectorsContract } from '@/lib/api/contracts/tools/aws/cloudtrail-get-insight-selectors' +import { awsCloudtrailGetTrailStatusContract } from '@/lib/api/contracts/tools/aws/cloudtrail-get-trail-status' +import { awsCloudtrailListTrailsContract } from '@/lib/api/contracts/tools/aws/cloudtrail-list-trails' +import { awsCloudtrailStartQueryContract } from '@/lib/api/contracts/tools/aws/cloudtrail-start-query' +import { getEventDataStoreTool } from '@/tools/cloudtrail/get_event_data_store' +import { listTrailsTool } from '@/tools/cloudtrail/list_trails' +import type { OutputProperty } from '@/tools/types' + +const CONNECTION = { + region: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'secret', +} + +const QUERY_ID = 'abcdef01-2345-6789-abcd-ef0123456789' +const LONGEST_VALID_NAME = 'a'.repeat(128) +const TOO_LONG_NAME = 'a'.repeat(129) +const LONG_INVALID_NAME = `${'a'.repeat(128)}!` +const LONG_TRAIL_ARN = `arn:aws:cloudtrail:us-east-1:123456789012:trail/${'a'.repeat(100)}` + +describe('cloudtrail start query contract', () => { + it('rejects an empty positional query parameter', () => { + const result = awsCloudtrailStartQueryContract.body.safeParse({ + ...CONNECTION, + queryAlias: 'top-errors', + queryParameters: ['us-east-1', '', '2026-01-01'], + }) + + expect(result.success).toBe(false) + }) + + it('accepts a fully populated positional parameter list', () => { + const result = awsCloudtrailStartQueryContract.body.safeParse({ + ...CONNECTION, + queryAlias: 'top-errors', + queryParameters: ['us-east-1', '2026-01-01'], + }) + + expect(result.success).toBe(true) + }) +}) + +describe('cloudtrail describe query contract', () => { + it('rejects refreshId when the query is addressed by queryId', () => { + const result = awsCloudtrailDescribeQueryContract.body.safeParse({ + ...CONNECTION, + queryId: QUERY_ID, + refreshId: '1234567890', + }) + + expect(result.success).toBe(false) + }) + + it('accepts refreshId alongside queryAlias', () => { + const result = awsCloudtrailDescribeQueryContract.body.safeParse({ + ...CONNECTION, + queryAlias: 'top-errors', + refreshId: '1234567890', + }) + + expect(result.success).toBe(true) + }) + + it('accepts queryId on its own', () => { + const result = awsCloudtrailDescribeQueryContract.body.safeParse({ + ...CONNECTION, + queryId: QUERY_ID, + }) + + expect(result.success).toBe(true) + }) +}) + +describe('cloudtrail trail name bounds', () => { + it('rejects a bare trail name longer than 128 characters on get trail status', () => { + const result = awsCloudtrailGetTrailStatusContract.body.safeParse({ + ...CONNECTION, + name: TOO_LONG_NAME, + }) + + expect(result.success).toBe(false) + }) + + it('accepts a 128-character bare trail name on get trail status', () => { + const result = awsCloudtrailGetTrailStatusContract.body.safeParse({ + ...CONNECTION, + name: LONGEST_VALID_NAME, + }) + + expect(result.success).toBe(true) + }) + + it('rejects a long malformed trail name', () => { + const result = awsCloudtrailGetTrailStatusContract.body.safeParse({ + ...CONNECTION, + name: LONG_INVALID_NAME, + }) + + expect(result.success).toBe(false) + }) + + it('accepts a trail ARN longer than 128 characters on get trail status', () => { + const result = awsCloudtrailGetTrailStatusContract.body.safeParse({ + ...CONNECTION, + name: LONG_TRAIL_ARN, + }) + + expect(result.success).toBe(true) + }) + + it('rejects a bare trail name longer than 128 characters on get insight selectors', () => { + const result = awsCloudtrailGetInsightSelectorsContract.body.safeParse({ + ...CONNECTION, + trailName: TOO_LONG_NAME, + }) + + expect(result.success).toBe(false) + }) + + it('accepts a trail ARN longer than 128 characters on get insight selectors', () => { + const result = awsCloudtrailGetInsightSelectorsContract.body.safeParse({ + ...CONNECTION, + trailName: LONG_TRAIL_ARN, + }) + + expect(result.success).toBe(true) + }) + + it('rejects a bare trail name longer than 128 characters inside trailNameList', () => { + const result = awsCloudtrailDescribeTrailsContract.body.safeParse({ + ...CONNECTION, + trailNameList: ['audit-trail', TOO_LONG_NAME], + }) + + expect(result.success).toBe(false) + }) + + it('accepts every trail an account can reach in one describe request', () => { + const result = awsCloudtrailDescribeTrailsContract.body.safeParse({ + ...CONNECTION, + trailNameList: Array.from({ length: 200 }, (_, index) => `audit-trail-${index}`), + }) + + expect(result.success).toBe(true) + }) +}) + +/** Top-level output keys whose contract response schema accepts `null`. */ +function nullableResponseKeys(outputSchema: z.ZodObject): string[] { + return Object.entries(outputSchema.shape) + .filter(([, schema]) => schema.safeParse(null).success) + .map(([key]) => key) + .sort() +} + +/** Top-level output keys the tool's published catalog metadata marks nullable. */ +function nullableCatalogKeys(outputs: Record): string[] { + return Object.entries(outputs) + .filter(([, property]) => property.nullable === true) + .map(([key]) => key) + .sort() +} + +describe('cloudtrail output metadata nullability', () => { + it('matches the list trails response contract', () => { + const outputSchema = awsCloudtrailListTrailsContract.response.schema.shape.output + + expect(nullableCatalogKeys(listTrailsTool.outputs)).toEqual(nullableResponseKeys(outputSchema)) + }) + + it('matches the get event data store response contract', () => { + const outputSchema = awsCloudtrailGetEventDataStoreContract.response.schema.shape.output + + expect(nullableCatalogKeys(getEventDataStoreTool.outputs)).toEqual( + nullableResponseKeys(outputSchema) + ) + }) +}) diff --git a/apps/sim/tools/cloudtrail/describe_query.ts b/apps/sim/tools/cloudtrail/describe_query.ts new file mode 100644 index 00000000000..06f69608462 --- /dev/null +++ b/apps/sim/tools/cloudtrail/describe_query.ts @@ -0,0 +1,168 @@ +import type { + CloudTrailDescribeQueryParams, + CloudTrailDescribeQueryResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const describeQueryTool: InternalToolConfig< + CloudTrailDescribeQueryParams, + CloudTrailDescribeQueryResponse +> = { + id: 'cloudtrail_describe_query', + name: 'CloudTrail Describe Query', + description: 'Check the status, run time, and scan statistics of a CloudTrail Lake query', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + queryId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the query returned by Start Query. Supply this or queryAlias, not both', + }, + queryAlias: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Query template alias; returns the last run for that alias. Supply this or queryId, not both', + }, + refreshId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Dashboard refresh ID, used together with queryAlias', + }, + eventDataStoreOwnerAccountId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Account ID of the event data store owner, for a shared event data store', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + ...(params.queryId && { queryId: params.queryId }), + ...(params.queryAlias && { queryAlias: params.queryAlias }), + ...(params.refreshId && { refreshId: params.refreshId }), + ...(params.eventDataStoreOwnerAccountId && { + eventDataStoreOwnerAccountId: params.eventDataStoreOwnerAccountId, + }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to describe CloudTrail Lake query') + } + return { + success: true, + output: { + queryId: data.output.queryId ?? null, + queryString: data.output.queryString ?? null, + queryStatus: data.output.queryStatus ?? null, + errorMessage: data.output.errorMessage ?? null, + deliveryS3Uri: data.output.deliveryS3Uri ?? null, + deliveryStatus: data.output.deliveryStatus ?? null, + prompt: data.output.prompt ?? null, + eventDataStoreOwnerAccountId: data.output.eventDataStoreOwnerAccountId ?? null, + eventsMatched: data.output.eventsMatched ?? null, + eventsScanned: data.output.eventsScanned ?? null, + bytesScanned: data.output.bytesScanned ?? null, + executionTimeInMillis: data.output.executionTimeInMillis ?? null, + creationTime: data.output.creationTime ?? null, + }, + } + }, + + outputs: { + queryId: { + type: 'string', + description: 'ID of the query', + optional: true, + }, + queryString: { + type: 'string', + description: 'SQL body of the query', + optional: true, + }, + queryStatus: { + type: 'string', + description: 'QUEUED, RUNNING, FINISHED, FAILED, CANCELLED, or TIMED_OUT', + optional: true, + }, + errorMessage: { + type: 'string', + description: 'Error message returned if the query failed', + optional: true, + }, + deliveryS3Uri: { + type: 'string', + description: 'S3 URI the results were delivered to, if configured', + optional: true, + }, + deliveryStatus: { + type: 'string', + description: 'Delivery status of the S3 results (SUCCESS, FAILED, PENDING, and similar)', + optional: true, + }, + prompt: { + type: 'string', + description: 'Natural-language prompt used to generate the query, if it was generated', + optional: true, + }, + eventDataStoreOwnerAccountId: { + type: 'string', + description: 'Account ID of the event data store owner', + optional: true, + }, + eventsMatched: { + type: 'number', + description: 'Number of events that matched the query', + optional: true, + }, + eventsScanned: { + type: 'number', + description: 'Number of events scanned by the query', + optional: true, + }, + bytesScanned: { + type: 'number', + description: 'Bytes scanned by the query', + optional: true, + }, + executionTimeInMillis: { + type: 'number', + description: 'Query run time in milliseconds', + optional: true, + }, + creationTime: { + type: 'string', + description: 'When the query was created (ISO 8601)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/describe_trails.ts b/apps/sim/tools/cloudtrail/describe_trails.ts new file mode 100644 index 00000000000..b703b652848 --- /dev/null +++ b/apps/sim/tools/cloudtrail/describe_trails.ts @@ -0,0 +1,134 @@ +import type { + CloudTrailDescribeTrailsParams, + CloudTrailDescribeTrailsResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const describeTrailsTool: InternalToolConfig< + CloudTrailDescribeTrailsParams, + CloudTrailDescribeTrailsResponse +> = { + id: 'cloudtrail_describe_trails', + name: 'CloudTrail Describe Trails', + description: + 'Retrieve the full configuration of one or more CloudTrail trails in the current Region', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + trailNameList: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated trail names or ARNs. Leave empty to describe every trail in the Region. Trails in another Region must be given as ARNs', + }, + includeShadowTrails: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Include shadow trails (replications of trails created in another Region, and organization trails in member accounts). Defaults to true', + }, + }, + + operation: { + input: (params) => { + const trailNameList = (params.trailNameList ?? '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + return { + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + ...(trailNameList.length > 0 && { trailNameList }), + ...(params.includeShadowTrails !== undefined && { + includeShadowTrails: params.includeShadowTrails, + }), + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to describe CloudTrail trails') + } + return { + success: true, + output: { + trails: data.output.trails ?? [], + }, + } + }, + + outputs: { + trails: { + type: 'array', + description: 'Full configuration of each matching trail', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Trail name' }, + s3BucketName: { type: 'string', description: 'S3 bucket that receives log files' }, + s3KeyPrefix: { type: 'string', description: 'S3 key prefix for delivered log files' }, + snsTopicName: { type: 'string', description: 'SNS topic notified on log delivery' }, + snsTopicArn: { type: 'string', description: 'ARN of that SNS topic' }, + includeGlobalServiceEvents: { + type: 'boolean', + description: 'Whether global service events are recorded', + }, + isMultiRegionTrail: { + type: 'boolean', + description: 'Whether the trail records events in all Regions', + }, + homeRegion: { type: 'string', description: 'Region in which the trail was created' }, + trailArn: { type: 'string', description: 'ARN of the trail' }, + logFileValidationEnabled: { + type: 'boolean', + description: 'Whether log file integrity validation is enabled', + }, + cloudWatchLogsLogGroupArn: { + type: 'string', + description: 'CloudWatch Logs log group receiving events', + }, + cloudWatchLogsRoleArn: { + type: 'string', + description: 'Role CloudTrail assumes to write to CloudWatch Logs', + }, + kmsKeyId: { type: 'string', description: 'KMS key used to encrypt log files' }, + hasCustomEventSelectors: { + type: 'boolean', + description: 'Whether the trail has custom event selectors', + }, + hasInsightSelectors: { + type: 'boolean', + description: 'Whether the trail has Insights event selectors', + }, + isOrganizationTrail: { + type: 'boolean', + description: 'Whether the trail is an organization trail', + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/get_event_data_store.ts b/apps/sim/tools/cloudtrail/get_event_data_store.ts new file mode 100644 index 00000000000..3a9c094be2c --- /dev/null +++ b/apps/sim/tools/cloudtrail/get_event_data_store.ts @@ -0,0 +1,160 @@ +import type { + CloudTrailGetEventDataStoreParams, + CloudTrailGetEventDataStoreResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const getEventDataStoreTool: InternalToolConfig< + CloudTrailGetEventDataStoreParams, + CloudTrailGetEventDataStoreResponse +> = { + id: 'cloudtrail_get_event_data_store', + name: 'CloudTrail Get Event Data Store', + description: 'Retrieve the configuration of a single CloudTrail Lake event data store', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + eventDataStore: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Event data store ARN, or the ID suffix of that ARN', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + eventDataStore: params.eventDataStore, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to get CloudTrail event data store') + } + return { + success: true, + output: data.output, + } + }, + + outputs: { + eventDataStoreArn: { + type: 'string', + description: 'ARN of the event data store', + nullable: true, + }, + name: { + type: 'string', + description: 'Name of the event data store', + nullable: true, + }, + status: { + type: 'string', + description: 'CREATED, ENABLED, PENDING_DELETION, or an ingestion state', + nullable: true, + }, + advancedEventSelectors: { + type: 'array', + description: 'Advanced event selectors that define what the store ingests', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name of the advanced event selector', + nullable: true, + }, + fieldSelectors: { + type: 'array', + description: + 'Field selectors, each with field plus its equals, startsWith, endsWith, notEquals, notStartsWith, and notEndsWith values', + }, + }, + }, + }, + multiRegionEnabled: { + type: 'boolean', + description: 'Whether the store collects events from all Regions', + nullable: true, + }, + organizationEnabled: { + type: 'boolean', + description: 'Whether the store collects events for the organization', + nullable: true, + }, + retentionPeriod: { + type: 'number', + description: 'Retention period in days', + nullable: true, + }, + terminationProtectionEnabled: { + type: 'boolean', + description: 'Whether termination protection is enabled', + nullable: true, + }, + createdTimestamp: { + type: 'string', + description: 'When the store was created (ISO 8601)', + nullable: true, + }, + updatedTimestamp: { + type: 'string', + description: 'When the store was last updated (ISO 8601)', + nullable: true, + }, + kmsKeyId: { + type: 'string', + description: 'KMS key used to encrypt the store', + nullable: true, + }, + billingMode: { + type: 'string', + description: 'EXTENDABLE_RETENTION_PRICING or FIXED_RETENTION_PRICING', + nullable: true, + }, + federationStatus: { + type: 'string', + description: 'Lake Formation federation status', + nullable: true, + }, + federationRoleArn: { + type: 'string', + description: 'ARN of the role used for Lake Formation federation', + nullable: true, + }, + partitionKeys: { + type: 'array', + description: 'Partition keys of the event data store', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Partition key name' }, + type: { type: 'string', description: 'Partition key data type' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/get_event_selectors.ts b/apps/sim/tools/cloudtrail/get_event_selectors.ts new file mode 100644 index 00000000000..bd76a403c86 --- /dev/null +++ b/apps/sim/tools/cloudtrail/get_event_selectors.ts @@ -0,0 +1,115 @@ +import type { + CloudTrailGetEventSelectorsParams, + CloudTrailGetEventSelectorsResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const getEventSelectorsTool: InternalToolConfig< + CloudTrailGetEventSelectorsParams, + CloudTrailGetEventSelectorsResponse +> = { + id: 'cloudtrail_get_event_selectors', + name: 'CloudTrail Get Event Selectors', + description: + 'Read which management, data, and network activity events a CloudTrail trail is configured to log', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + trailName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trail name or trail ARN', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + trailName: params.trailName, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to get CloudTrail event selectors') + } + return { + success: true, + output: { + trailArn: data.output.trailArn ?? null, + eventSelectors: data.output.eventSelectors ?? [], + advancedEventSelectors: data.output.advancedEventSelectors ?? [], + }, + } + }, + + outputs: { + trailArn: { + type: 'string', + description: 'ARN of the trail that owns these selectors', + optional: true, + }, + eventSelectors: { + type: 'array', + description: 'Basic event selectors configured on the trail', + items: { + type: 'object', + properties: { + readWriteType: { + type: 'string', + description: 'All, ReadOnly, or WriteOnly', + }, + includeManagementEvents: { + type: 'boolean', + description: 'Whether management events are recorded', + }, + dataResources: { + type: 'array', + description: 'Data resources logged by the selector, as type and values', + }, + excludeManagementEventSources: { + type: 'array', + description: 'Event sources excluded from management event logging', + }, + }, + }, + }, + advancedEventSelectors: { + type: 'array', + description: 'Advanced event selectors configured on the trail', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the advanced event selector' }, + fieldSelectors: { + type: 'array', + description: + 'Field selectors, each with field plus its equals, startsWith, endsWith, notEquals, notStartsWith, and notEndsWith values', + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/get_insight_selectors.ts b/apps/sim/tools/cloudtrail/get_insight_selectors.ts new file mode 100644 index 00000000000..8a25671e8c2 --- /dev/null +++ b/apps/sim/tools/cloudtrail/get_insight_selectors.ts @@ -0,0 +1,110 @@ +import type { + CloudTrailGetInsightSelectorsParams, + CloudTrailGetInsightSelectorsResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const getInsightSelectorsTool: InternalToolConfig< + CloudTrailGetInsightSelectorsParams, + CloudTrailGetInsightSelectorsResponse +> = { + id: 'cloudtrail_get_insight_selectors', + name: 'CloudTrail Get Insight Selectors', + description: 'Read which CloudTrail Insights types are enabled on a trail or event data store', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + trailName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Trail name or trail ARN. Cannot be combined with eventDataStore', + }, + eventDataStore: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Event data store ARN, or the ID suffix of that ARN. Cannot be combined with trailName', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + ...(params.trailName && { trailName: params.trailName }), + ...(params.eventDataStore && { eventDataStore: params.eventDataStore }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to get CloudTrail Insights selectors') + } + return { + success: true, + output: { + trailArn: data.output.trailArn ?? null, + eventDataStoreArn: data.output.eventDataStoreArn ?? null, + insightsDestination: data.output.insightsDestination ?? null, + insightSelectors: data.output.insightSelectors ?? [], + }, + } + }, + + outputs: { + trailArn: { + type: 'string', + description: 'ARN of the trail whose Insights selectors were read', + optional: true, + }, + eventDataStoreArn: { + type: 'string', + description: 'ARN of the source event data store that enabled Insights events', + optional: true, + }, + insightsDestination: { + type: 'string', + description: 'ARN of the destination event data store that logs Insights events', + optional: true, + }, + insightSelectors: { + type: 'array', + description: 'Enabled Insights types and their event categories', + items: { + type: 'object', + properties: { + insightType: { + type: 'string', + description: 'ApiCallRateInsight or ApiErrorRateInsight', + }, + eventCategories: { + type: 'array', + description: 'Event categories the Insights type applies to: Management, Data, or both', + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/get_query_results.ts b/apps/sim/tools/cloudtrail/get_query_results.ts new file mode 100644 index 00000000000..8e4299cf0b5 --- /dev/null +++ b/apps/sim/tools/cloudtrail/get_query_results.ts @@ -0,0 +1,132 @@ +import type { + CloudTrailGetQueryResultsParams, + CloudTrailGetQueryResultsResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const getQueryResultsTool: InternalToolConfig< + CloudTrailGetQueryResultsParams, + CloudTrailGetQueryResultsResponse +> = { + id: 'cloudtrail_get_query_results', + name: 'CloudTrail Get Query Results', + description: 'Fetch a page of result rows from a finished CloudTrail Lake query', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + queryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the query returned by Start Query', + }, + maxQueryResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum rows to return on a single page, 1 to 1000', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous results request', + }, + eventDataStoreOwnerAccountId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Account ID of the event data store owner, for a shared event data store', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + queryId: params.queryId, + ...(params.maxQueryResults !== undefined && { maxQueryResults: params.maxQueryResults }), + ...(params.nextToken && { nextToken: params.nextToken }), + ...(params.eventDataStoreOwnerAccountId && { + eventDataStoreOwnerAccountId: params.eventDataStoreOwnerAccountId, + }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to get CloudTrail Lake query results') + } + return { + success: true, + output: { + queryStatus: data.output.queryStatus ?? null, + rows: data.output.rows ?? [], + resultsCount: data.output.resultsCount ?? null, + totalResultsCount: data.output.totalResultsCount ?? null, + bytesScanned: data.output.bytesScanned ?? null, + errorMessage: data.output.errorMessage ?? null, + nextToken: data.output.nextToken ?? null, + }, + } + }, + + outputs: { + queryStatus: { + type: 'string', + description: 'QUEUED, RUNNING, FINISHED, FAILED, CANCELLED, or TIMED_OUT', + optional: true, + }, + rows: { + type: 'array', + description: + 'Result rows, each flattened into a single object keyed by the query column names', + items: { type: 'object' }, + }, + resultsCount: { + type: 'number', + description: 'Number of rows on this page', + optional: true, + }, + totalResultsCount: { + type: 'number', + description: 'Total number of rows the query produced', + optional: true, + }, + bytesScanned: { + type: 'number', + description: 'Bytes scanned by the query', + optional: true, + }, + errorMessage: { + type: 'string', + description: 'Error message returned if the query failed', + optional: true, + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of rows', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/get_trail.ts b/apps/sim/tools/cloudtrail/get_trail.ts new file mode 100644 index 00000000000..6acc69a3a83 --- /dev/null +++ b/apps/sim/tools/cloudtrail/get_trail.ts @@ -0,0 +1,141 @@ +import type { CloudTrailGetTrailParams, CloudTrailGetTrailResponse } from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const getTrailTool: InternalToolConfig< + CloudTrailGetTrailParams, + CloudTrailGetTrailResponse +> = { + id: 'cloudtrail_get_trail', + name: 'CloudTrail Get Trail', + description: 'Retrieve the settings of a single CloudTrail trail by name or ARN', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trail name, or the trail ARN for a trail in another Region', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + name: params.name, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to get CloudTrail trail') + } + return { + success: true, + output: data.output, + } + }, + + outputs: { + name: { + type: 'string', + description: 'Trail name', + }, + s3BucketName: { + type: 'string', + description: 'Name of the S3 bucket that receives log files', + optional: true, + }, + s3KeyPrefix: { + type: 'string', + description: 'S3 key prefix prepended to delivered log files', + optional: true, + }, + snsTopicName: { + type: 'string', + description: 'Name of the SNS topic notified on log delivery', + optional: true, + }, + snsTopicArn: { + type: 'string', + description: 'ARN of the SNS topic notified on log delivery', + optional: true, + }, + includeGlobalServiceEvents: { + type: 'boolean', + description: 'Whether the trail records global service events', + optional: true, + }, + isMultiRegionTrail: { + type: 'boolean', + description: 'Whether the trail records events in all Regions', + optional: true, + }, + homeRegion: { + type: 'string', + description: 'Region in which the trail was created', + optional: true, + }, + trailArn: { + type: 'string', + description: 'ARN of the trail', + optional: true, + }, + logFileValidationEnabled: { + type: 'boolean', + description: 'Whether log file integrity validation is enabled', + optional: true, + }, + cloudWatchLogsLogGroupArn: { + type: 'string', + description: 'ARN of the CloudWatch Logs log group receiving events', + optional: true, + }, + cloudWatchLogsRoleArn: { + type: 'string', + description: 'ARN of the role CloudTrail assumes to write to CloudWatch Logs', + optional: true, + }, + kmsKeyId: { + type: 'string', + description: 'KMS key used to encrypt log files', + optional: true, + }, + hasCustomEventSelectors: { + type: 'boolean', + description: 'Whether the trail has custom event selectors', + optional: true, + }, + hasInsightSelectors: { + type: 'boolean', + description: 'Whether the trail has Insights event selectors', + optional: true, + }, + isOrganizationTrail: { + type: 'boolean', + description: 'Whether the trail is an organization trail', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/get_trail_status.ts b/apps/sim/tools/cloudtrail/get_trail_status.ts new file mode 100644 index 00000000000..d2dac5e5ce4 --- /dev/null +++ b/apps/sim/tools/cloudtrail/get_trail_status.ts @@ -0,0 +1,133 @@ +import type { + CloudTrailGetTrailStatusParams, + CloudTrailGetTrailStatusResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const getTrailStatusTool: InternalToolConfig< + CloudTrailGetTrailStatusParams, + CloudTrailGetTrailStatusResponse +> = { + id: 'cloudtrail_get_trail_status', + name: 'CloudTrail Get Trail Status', + description: + 'Check whether a CloudTrail trail is logging and surface its most recent delivery errors', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Trail name, or the trail ARN. An organization trail read from a member account must be given as an ARN', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + name: params.name, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to get CloudTrail trail status') + } + return { + success: true, + output: { + isLogging: data.output.isLogging ?? null, + latestDeliveryError: data.output.latestDeliveryError ?? null, + latestDeliveryTime: data.output.latestDeliveryTime ?? null, + latestNotificationError: data.output.latestNotificationError ?? null, + latestNotificationTime: data.output.latestNotificationTime ?? null, + latestCloudWatchLogsDeliveryError: data.output.latestCloudWatchLogsDeliveryError ?? null, + latestCloudWatchLogsDeliveryTime: data.output.latestCloudWatchLogsDeliveryTime ?? null, + latestDigestDeliveryError: data.output.latestDigestDeliveryError ?? null, + latestDigestDeliveryTime: data.output.latestDigestDeliveryTime ?? null, + startLoggingTime: data.output.startLoggingTime ?? null, + stopLoggingTime: data.output.stopLoggingTime ?? null, + }, + } + }, + + outputs: { + isLogging: { + type: 'boolean', + description: 'Whether the trail is currently recording API calls', + }, + latestDeliveryError: { + type: 'string', + description: 'Most recent S3 error encountered delivering log files', + optional: true, + }, + latestDeliveryTime: { + type: 'string', + description: 'When log files were last delivered to S3 (ISO 8601)', + optional: true, + }, + latestNotificationError: { + type: 'string', + description: 'Most recent SNS error encountered sending a notification', + optional: true, + }, + latestNotificationTime: { + type: 'string', + description: 'When the last SNS notification was sent (ISO 8601)', + optional: true, + }, + latestCloudWatchLogsDeliveryError: { + type: 'string', + description: 'Most recent CloudWatch Logs delivery error', + optional: true, + }, + latestCloudWatchLogsDeliveryTime: { + type: 'string', + description: 'When events were last delivered to CloudWatch Logs (ISO 8601)', + optional: true, + }, + latestDigestDeliveryError: { + type: 'string', + description: 'Most recent S3 error encountered delivering a digest file', + optional: true, + }, + latestDigestDeliveryTime: { + type: 'string', + description: 'When a digest file was last delivered to S3 (ISO 8601)', + optional: true, + }, + startLoggingTime: { + type: 'string', + description: 'When logging was most recently started (ISO 8601)', + optional: true, + }, + stopLoggingTime: { + type: 'string', + description: 'When logging was most recently stopped (ISO 8601)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/index.ts b/apps/sim/tools/cloudtrail/index.ts new file mode 100644 index 00000000000..322f3c09024 --- /dev/null +++ b/apps/sim/tools/cloudtrail/index.ts @@ -0,0 +1,31 @@ +import { cancelQueryTool } from '@/tools/cloudtrail/cancel_query' +import { describeQueryTool } from '@/tools/cloudtrail/describe_query' +import { describeTrailsTool } from '@/tools/cloudtrail/describe_trails' +import { getEventDataStoreTool } from '@/tools/cloudtrail/get_event_data_store' +import { getEventSelectorsTool } from '@/tools/cloudtrail/get_event_selectors' +import { getInsightSelectorsTool } from '@/tools/cloudtrail/get_insight_selectors' +import { getQueryResultsTool } from '@/tools/cloudtrail/get_query_results' +import { getTrailTool } from '@/tools/cloudtrail/get_trail' +import { getTrailStatusTool } from '@/tools/cloudtrail/get_trail_status' +import { listEventDataStoresTool } from '@/tools/cloudtrail/list_event_data_stores' +import { listTagsTool } from '@/tools/cloudtrail/list_tags' +import { listTrailsTool } from '@/tools/cloudtrail/list_trails' +import { lookupEventsTool } from '@/tools/cloudtrail/lookup_events' +import { startQueryTool } from '@/tools/cloudtrail/start_query' + +export const cloudtrailCancelQueryTool = cancelQueryTool +export const cloudtrailDescribeQueryTool = describeQueryTool +export const cloudtrailDescribeTrailsTool = describeTrailsTool +export const cloudtrailGetEventDataStoreTool = getEventDataStoreTool +export const cloudtrailGetEventSelectorsTool = getEventSelectorsTool +export const cloudtrailGetInsightSelectorsTool = getInsightSelectorsTool +export const cloudtrailGetQueryResultsTool = getQueryResultsTool +export const cloudtrailGetTrailStatusTool = getTrailStatusTool +export const cloudtrailGetTrailTool = getTrailTool +export const cloudtrailListEventDataStoresTool = listEventDataStoresTool +export const cloudtrailListTagsTool = listTagsTool +export const cloudtrailListTrailsTool = listTrailsTool +export const cloudtrailLookupEventsTool = lookupEventsTool +export const cloudtrailStartQueryTool = startQueryTool + +export * from '@/tools/cloudtrail/types' diff --git a/apps/sim/tools/cloudtrail/list_event_data_stores.ts b/apps/sim/tools/cloudtrail/list_event_data_stores.ts new file mode 100644 index 00000000000..4a44aad9fe9 --- /dev/null +++ b/apps/sim/tools/cloudtrail/list_event_data_stores.ts @@ -0,0 +1,120 @@ +import type { + CloudTrailListEventDataStoresParams, + CloudTrailListEventDataStoresResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const listEventDataStoresTool: InternalToolConfig< + CloudTrailListEventDataStoresParams, + CloudTrailListEventDataStoresResponse +> = { + id: 'cloudtrail_list_event_data_stores', + name: 'CloudTrail List Event Data Stores', + description: 'List the CloudTrail Lake event data stores in the account for the current Region', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum event data stores to return on a single page, 1 to 1000', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous list request', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + ...(params.maxResults !== undefined && { maxResults: params.maxResults }), + ...(params.nextToken && { nextToken: params.nextToken }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to list CloudTrail event data stores') + } + return { + success: true, + output: { + eventDataStores: data.output.eventDataStores ?? [], + nextToken: data.output.nextToken ?? null, + }, + } + }, + + outputs: { + eventDataStores: { + type: 'array', + description: 'Event data stores in the account for the current Region', + items: { + type: 'object', + properties: { + eventDataStoreArn: { type: 'string', description: 'ARN of the event data store' }, + name: { type: 'string', description: 'Name of the event data store' }, + status: { + type: 'string', + description: 'CREATED, ENABLED, PENDING_DELETION, or an ingestion state', + }, + advancedEventSelectors: { + type: 'array', + description: 'Advanced event selectors that define what the store ingests', + }, + multiRegionEnabled: { + type: 'boolean', + description: 'Whether the store collects events from all Regions', + }, + organizationEnabled: { + type: 'boolean', + description: 'Whether the store collects events for the organization', + }, + retentionPeriod: { type: 'number', description: 'Retention period in days' }, + terminationProtectionEnabled: { + type: 'boolean', + description: 'Whether termination protection is enabled', + }, + createdTimestamp: { + type: 'string', + description: 'When the store was created (ISO 8601)', + }, + updatedTimestamp: { + type: 'string', + description: 'When the store was last updated (ISO 8601)', + }, + }, + }, + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of event data stores', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/list_tags.ts b/apps/sim/tools/cloudtrail/list_tags.ts new file mode 100644 index 00000000000..f71424bf0b2 --- /dev/null +++ b/apps/sim/tools/cloudtrail/list_tags.ts @@ -0,0 +1,94 @@ +import type { CloudTrailListTagsParams, CloudTrailListTagsResponse } from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const listTagsTool: InternalToolConfig< + CloudTrailListTagsParams, + CloudTrailListTagsResponse +> = { + id: 'cloudtrail_list_tags', + name: 'CloudTrail List Tags', + description: 'List the tags on CloudTrail trails, event data stores, dashboards, or channels', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + resourceIdList: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated CloudTrail resource ARNs, up to 20', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Reserved for future use by AWS', + }, + }, + + operation: { + input: (params) => { + const resourceIdList = params.resourceIdList + .split(',') + .map((arn) => arn.trim()) + .filter(Boolean) + return { + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + resourceIdList, + ...(params.nextToken && { nextToken: params.nextToken }), + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to list CloudTrail resource tags') + } + return { + success: true, + output: { + resourceTags: data.output.resourceTags ?? [], + nextToken: data.output.nextToken ?? null, + }, + } + }, + + outputs: { + resourceTags: { + type: 'array', + description: 'Tags for each requested resource', + items: { + type: 'object', + properties: { + resourceId: { type: 'string', description: 'ARN of the tagged resource' }, + tags: { type: 'array', description: 'Tags on the resource, as key and value' }, + }, + }, + }, + nextToken: { + type: 'string', + description: 'Reserved for future use by AWS', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/list_trails.ts b/apps/sim/tools/cloudtrail/list_trails.ts new file mode 100644 index 00000000000..34506679570 --- /dev/null +++ b/apps/sim/tools/cloudtrail/list_trails.ts @@ -0,0 +1,90 @@ +import type { + CloudTrailListTrailsParams, + CloudTrailListTrailsResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const listTrailsTool: InternalToolConfig< + CloudTrailListTrailsParams, + CloudTrailListTrailsResponse +> = { + id: 'cloudtrail_list_trails', + name: 'CloudTrail List Trails', + description: + 'List the ARN, name, and home Region of every CloudTrail trail visible to the account', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous list request', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + ...(params.nextToken && { nextToken: params.nextToken }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to list CloudTrail trails') + } + return { + success: true, + output: { + trails: data.output.trails ?? [], + nextToken: data.output.nextToken ?? null, + }, + } + }, + + outputs: { + trails: { + type: 'array', + description: 'Trail summaries', + items: { + type: 'object', + properties: { + trailArn: { type: 'string', description: 'ARN of the trail', nullable: true }, + name: { type: 'string', description: 'Trail name', nullable: true }, + homeRegion: { + type: 'string', + description: 'Region in which the trail was created', + nullable: true, + }, + }, + }, + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of trails, or null on the last page', + nullable: true, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/lookup_events.ts b/apps/sim/tools/cloudtrail/lookup_events.ts new file mode 100644 index 00000000000..99092c12cff --- /dev/null +++ b/apps/sim/tools/cloudtrail/lookup_events.ts @@ -0,0 +1,157 @@ +import type { + CloudTrailLookupEventsParams, + CloudTrailLookupEventsResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const lookupEventsTool: InternalToolConfig< + CloudTrailLookupEventsParams, + CloudTrailLookupEventsResponse +> = { + id: 'cloudtrail_lookup_events', + name: 'CloudTrail Look Up Events', + description: + 'Look up AWS CloudTrail management or Insights events from the last 90 days in a Region', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + attributeKey: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Lookup attribute to filter on: AccessKeyId, EventId, EventName, EventSource, ReadOnly, ResourceName, ResourceType, or Username. Must be paired with attributeValue', + }, + attributeValue: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Value the lookup attribute must equal. Must be paired with attributeKey', + }, + startTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return events at or after this ISO 8601 timestamp', + }, + endTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return events at or before this ISO 8601 timestamp', + }, + eventCategory: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Set to the value insight to return CloudTrail Insights events instead of management events', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of events to return, 1 to 50 (default 50)', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous lookup, which must repeat the same filters', + }, + }, + + operation: { + input: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + ...(params.attributeKey && { attributeKey: params.attributeKey }), + ...(params.attributeValue && { attributeValue: params.attributeValue }), + ...(params.startTime && { startTime: params.startTime }), + ...(params.endTime && { endTime: params.endTime }), + ...(params.eventCategory === 'insight' && { eventCategory: 'insight' as const }), + ...(params.maxResults !== undefined && { maxResults: params.maxResults }), + ...(params.nextToken && { nextToken: params.nextToken }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to look up CloudTrail events') + } + return { + success: true, + output: { + events: data.output.events ?? [], + nextToken: data.output.nextToken ?? null, + }, + } + }, + + outputs: { + events: { + type: 'array', + description: 'Matching events, most recent first', + items: { + type: 'object', + properties: { + eventId: { type: 'string', description: 'CloudTrail event ID' }, + eventName: { type: 'string', description: 'API action that was called' }, + readOnly: { + type: 'string', + description: "Whether the action was read-only, as the string 'true' or 'false'", + }, + accessKeyId: { + type: 'string', + description: 'Access key ID used to make the call, when applicable', + }, + eventTime: { type: 'string', description: 'When the event occurred (ISO 8601)' }, + eventSource: { + type: 'string', + description: 'AWS service endpoint that recorded the event', + }, + username: { type: 'string', description: 'Name of the principal that made the call' }, + resources: { + type: 'array', + description: 'Resources referenced by the event, as resourceType and resourceName', + }, + cloudTrailEvent: { + type: 'object', + description: + 'Full CloudTrail event record parsed from JSON, including userIdentity, sourceIPAddress, userAgent, requestParameters, responseElements, and errorCode', + }, + cloudTrailEventRaw: { + type: 'string', + description: + 'Raw CloudTrail event JSON string, populated only when it could not be parsed', + }, + }, + }, + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of events', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/operation-input.test.ts b/apps/sim/tools/cloudtrail/operation-input.test.ts new file mode 100644 index 00000000000..0ee3ac94b65 --- /dev/null +++ b/apps/sim/tools/cloudtrail/operation-input.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + * + * The CloudTrail block collects `trailNameList`, `resourceIdList`, and `queryParameters` as + * comma-separated strings, while their contracts require arrays. `operation.input` is the seam + * that converts them, and it runs before contract validation, so these fields must arrive at the + * contract already split. Regressing this would 400 every request that uses them. + */ +import { describe, expect, it } from 'vitest' +import { describeTrailsTool } from '@/tools/cloudtrail/describe_trails' +import { listTagsTool } from '@/tools/cloudtrail/list_tags' +import { startQueryTool } from '@/tools/cloudtrail/start_query' + +const CONNECTION = { + awsRegion: 'us-east-1', + awsAccessKeyId: 'AKIAIOSFODNN7EXAMPLE', + awsSecretAccessKey: 'secret', +} + +const TRAIL_ARN = 'arn:aws:cloudtrail:us-east-1:123456789012:trail/audit-trail' + +describe('cloudtrail operation input', () => { + it('splits comma-separated trail names for describe_trails', () => { + const input = describeTrailsTool.operation.input({ + ...CONNECTION, + trailNameList: 'audit-trail, security-trail', + }) + + expect(input.trailNameList).toEqual(['audit-trail', 'security-trail']) + }) + + it('omits trailNameList entirely when it is blank', () => { + const input = describeTrailsTool.operation.input({ ...CONNECTION, trailNameList: ' ' }) + + expect(input.trailNameList).toBeUndefined() + }) + + it('splits comma-separated resource ARNs for list_tags', () => { + const input = listTagsTool.operation.input({ + ...CONNECTION, + resourceIdList: `${TRAIL_ARN},${TRAIL_ARN}`, + }) + + expect(input.resourceIdList).toEqual([TRAIL_ARN, TRAIL_ARN]) + }) + + it('splits comma-separated query template parameters for start_query', () => { + const input = startQueryTool.operation.input({ + ...CONNECTION, + queryAlias: 'top-errors', + queryParameters: 'us-east-1, 2026-01-01', + }) + + expect(input.queryParameters).toEqual(['us-east-1', '2026-01-01']) + }) + + it('preserves empty positional slots in start_query query parameters', () => { + const input = startQueryTool.operation.input({ + ...CONNECTION, + queryAlias: 'top-errors', + queryParameters: 'us-east-1,,2026-01-01', + }) + + expect(input.queryParameters).toEqual(['us-east-1', '', '2026-01-01']) + }) + + it('omits queryParameters entirely when it is blank', () => { + const input = startQueryTool.operation.input({ + ...CONNECTION, + queryAlias: 'top-errors', + queryParameters: ' ', + }) + + expect(input.queryParameters).toBeUndefined() + }) +}) diff --git a/apps/sim/tools/cloudtrail/start_query.ts b/apps/sim/tools/cloudtrail/start_query.ts new file mode 100644 index 00000000000..26d0c9ecf3d --- /dev/null +++ b/apps/sim/tools/cloudtrail/start_query.ts @@ -0,0 +1,123 @@ +import type { + CloudTrailStartQueryParams, + CloudTrailStartQueryResponse, +} from '@/tools/cloudtrail/types' +import type { InternalToolConfig } from '@/tools/types' + +export const startQueryTool: InternalToolConfig< + CloudTrailStartQueryParams, + CloudTrailStartQueryResponse +> = { + id: 'cloudtrail_start_query', + name: 'CloudTrail Start Query', + description: 'Start a CloudTrail Lake SQL query over an event data store', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + queryStatement: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'SQL query to run, up to 10,000 characters. The event data store ID is named in the FROM clause. Supply this or queryAlias, not both', + }, + queryAlias: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Alias of a query template used by CloudTrail Lake dashboards. Supply this or queryStatement, not both', + }, + queryParameters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated parameter values for the query alias, up to 10 values', + }, + deliveryS3Uri: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'S3 URI where CloudTrail delivers the query results (e.g., s3://my-bucket/results)', + }, + eventDataStoreOwnerAccountId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Account ID of the event data store owner, for a shared event data store', + }, + }, + + operation: { + /** + * `QueryParameters` is positional: CloudTrail substitutes each entry into the query + * template by index. Empty slots are preserved rather than dropped, so a malformed + * list such as `a,,c` fails the contract's per-entry minimum length instead of + * silently shifting `c` into the second position. + */ + input: (params) => { + const rawQueryParameters = (params.queryParameters ?? '').trim() + const queryParameters = rawQueryParameters + ? rawQueryParameters.split(',').map((value) => value.trim()) + : [] + return { + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + ...(params.queryStatement && { queryStatement: params.queryStatement }), + ...(params.queryAlias && { queryAlias: params.queryAlias }), + ...(queryParameters.length > 0 && { queryParameters }), + ...(params.deliveryS3Uri && { deliveryS3Uri: params.deliveryS3Uri }), + ...(params.eventDataStoreOwnerAccountId && { + eventDataStoreOwnerAccountId: params.eventDataStoreOwnerAccountId, + }), + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to start CloudTrail Lake query') + } + return { + success: true, + output: { + queryId: data.output.queryId, + eventDataStoreOwnerAccountId: data.output.eventDataStoreOwnerAccountId ?? null, + }, + } + }, + + outputs: { + queryId: { + type: 'string', + description: + 'ID of the started query. Pass it to Describe Query to poll status, or to Get Query Results to page through rows', + }, + eventDataStoreOwnerAccountId: { + type: 'string', + description: 'Account ID of the event data store owner', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudtrail/types.ts b/apps/sim/tools/cloudtrail/types.ts new file mode 100644 index 00000000000..1bfa127026a --- /dev/null +++ b/apps/sim/tools/cloudtrail/types.ts @@ -0,0 +1,286 @@ +import type { ToolResponse } from '@/tools/types' + +interface CloudTrailConnectionConfig { + awsRegion: string + awsAccessKeyId: string + awsSecretAccessKey: string +} + +export interface CloudTrailAdvancedEventSelector { + name: string | null + fieldSelectors: { + field: string + equals: string[] + startsWith: string[] + endsWith: string[] + notEquals: string[] + notStartsWith: string[] + notEndsWith: string[] + }[] +} + +export interface CloudTrailTrail { + name: string + s3BucketName: string | null + s3KeyPrefix: string | null + snsTopicName: string | null + snsTopicArn: string | null + includeGlobalServiceEvents: boolean | null + isMultiRegionTrail: boolean | null + homeRegion: string | null + trailArn: string | null + logFileValidationEnabled: boolean | null + cloudWatchLogsLogGroupArn: string | null + cloudWatchLogsRoleArn: string | null + kmsKeyId: string | null + hasCustomEventSelectors: boolean | null + hasInsightSelectors: boolean | null + isOrganizationTrail: boolean | null +} + +export type CloudTrailLookupAttributeKey = + | 'AccessKeyId' + | 'EventId' + | 'EventName' + | 'EventSource' + | 'ReadOnly' + | 'ResourceName' + | 'ResourceType' + | 'Username' + +export interface CloudTrailLookupEventsParams extends CloudTrailConnectionConfig { + attributeKey?: CloudTrailLookupAttributeKey + attributeValue?: string + startTime?: string + endTime?: string + eventCategory?: 'insight' + maxResults?: number + nextToken?: string +} + +export interface CloudTrailEvent { + eventId: string | null + eventName: string | null + readOnly: string | null + accessKeyId: string | null + eventTime: string | null + eventSource: string | null + username: string | null + resources: { resourceType: string | null; resourceName: string | null }[] + cloudTrailEvent: Record | null + cloudTrailEventRaw: string | null +} + +export interface CloudTrailLookupEventsResponse extends ToolResponse { + output: { + events: CloudTrailEvent[] + nextToken: string | null + } +} + +export interface CloudTrailDescribeTrailsParams extends CloudTrailConnectionConfig { + trailNameList?: string + includeShadowTrails?: boolean +} + +export interface CloudTrailDescribeTrailsResponse extends ToolResponse { + output: { + trails: CloudTrailTrail[] + } +} + +export interface CloudTrailGetTrailParams extends CloudTrailConnectionConfig { + name: string +} + +export interface CloudTrailGetTrailResponse extends ToolResponse { + output: CloudTrailTrail +} + +export interface CloudTrailGetTrailStatusParams extends CloudTrailConnectionConfig { + name: string +} + +export interface CloudTrailGetTrailStatusResponse extends ToolResponse { + output: { + isLogging: boolean | null + latestDeliveryError: string | null + latestDeliveryTime: string | null + latestNotificationError: string | null + latestNotificationTime: string | null + latestCloudWatchLogsDeliveryError: string | null + latestCloudWatchLogsDeliveryTime: string | null + latestDigestDeliveryError: string | null + latestDigestDeliveryTime: string | null + startLoggingTime: string | null + stopLoggingTime: string | null + } +} + +export interface CloudTrailListTrailsParams extends CloudTrailConnectionConfig { + nextToken?: string +} + +export interface CloudTrailListTrailsResponse extends ToolResponse { + output: { + trails: { trailArn: string | null; name: string | null; homeRegion: string | null }[] + nextToken: string | null + } +} + +export interface CloudTrailGetEventSelectorsParams extends CloudTrailConnectionConfig { + trailName: string +} + +export interface CloudTrailGetEventSelectorsResponse extends ToolResponse { + output: { + trailArn: string | null + eventSelectors: { + readWriteType: string | null + includeManagementEvents: boolean | null + dataResources: { type: string | null; values: string[] }[] + excludeManagementEventSources: string[] + }[] + advancedEventSelectors: CloudTrailAdvancedEventSelector[] + } +} + +export interface CloudTrailGetInsightSelectorsParams extends CloudTrailConnectionConfig { + trailName?: string + eventDataStore?: string +} + +export interface CloudTrailGetInsightSelectorsResponse extends ToolResponse { + output: { + trailArn: string | null + eventDataStoreArn: string | null + insightsDestination: string | null + insightSelectors: { insightType: string | null; eventCategories: string[] }[] + } +} + +export interface CloudTrailStartQueryParams extends CloudTrailConnectionConfig { + queryStatement?: string + queryAlias?: string + queryParameters?: string + deliveryS3Uri?: string + eventDataStoreOwnerAccountId?: string +} + +export interface CloudTrailStartQueryResponse extends ToolResponse { + output: { + queryId: string + eventDataStoreOwnerAccountId: string | null + } +} + +export interface CloudTrailDescribeQueryParams extends CloudTrailConnectionConfig { + queryId?: string + queryAlias?: string + refreshId?: string + eventDataStoreOwnerAccountId?: string +} + +export interface CloudTrailDescribeQueryResponse extends ToolResponse { + output: { + queryId: string | null + queryString: string | null + queryStatus: string | null + errorMessage: string | null + deliveryS3Uri: string | null + deliveryStatus: string | null + prompt: string | null + eventDataStoreOwnerAccountId: string | null + eventsMatched: number | null + eventsScanned: number | null + bytesScanned: number | null + executionTimeInMillis: number | null + creationTime: string | null + } +} + +export interface CloudTrailGetQueryResultsParams extends CloudTrailConnectionConfig { + queryId: string + maxQueryResults?: number + nextToken?: string + eventDataStoreOwnerAccountId?: string +} + +export interface CloudTrailGetQueryResultsResponse extends ToolResponse { + output: { + queryStatus: string | null + rows: Record[] + resultsCount: number | null + totalResultsCount: number | null + bytesScanned: number | null + errorMessage: string | null + nextToken: string | null + } +} + +export interface CloudTrailCancelQueryParams extends CloudTrailConnectionConfig { + queryId: string + eventDataStoreOwnerAccountId?: string +} + +export interface CloudTrailCancelQueryResponse extends ToolResponse { + output: { + queryId: string + queryStatus: string | null + eventDataStoreOwnerAccountId: string | null + } +} + +export interface CloudTrailEventDataStoreSummary { + eventDataStoreArn: string | null + name: string | null + status: string | null + advancedEventSelectors: CloudTrailAdvancedEventSelector[] + multiRegionEnabled: boolean | null + organizationEnabled: boolean | null + retentionPeriod: number | null + terminationProtectionEnabled: boolean | null + createdTimestamp: string | null + updatedTimestamp: string | null +} + +export interface CloudTrailListEventDataStoresParams extends CloudTrailConnectionConfig { + maxResults?: number + nextToken?: string +} + +export interface CloudTrailListEventDataStoresResponse extends ToolResponse { + output: { + eventDataStores: CloudTrailEventDataStoreSummary[] + nextToken: string | null + } +} + +export interface CloudTrailGetEventDataStoreParams extends CloudTrailConnectionConfig { + eventDataStore: string +} + +export interface CloudTrailGetEventDataStoreResponse extends ToolResponse { + output: CloudTrailEventDataStoreSummary & { + kmsKeyId: string | null + billingMode: string | null + federationStatus: string | null + federationRoleArn: string | null + partitionKeys: { name: string; type: string }[] + } +} + +export interface CloudTrailListTagsParams extends CloudTrailConnectionConfig { + resourceIdList: string + nextToken?: string +} + +export interface CloudTrailListTagsResponse extends ToolResponse { + output: { + resourceTags: { + resourceId: string | null + tags: { key: string; value: string | null }[] + }[] + nextToken: string | null + } +} diff --git a/apps/sim/tools/embeddings/factory.ts b/apps/sim/tools/embeddings/factory.ts index 3aafb396677..015722d8583 100644 --- a/apps/sim/tools/embeddings/factory.ts +++ b/apps/sim/tools/embeddings/factory.ts @@ -1,5 +1,5 @@ import { BYOK_PROVIDER_IDS, DEFAULT_MODEL_BY_PROVIDER } from '@/lib/embeddings/catalog' -import type { EmbeddingCatalogProvider } from '@/lib/embeddings/types' +import type { KeyedEmbeddingProvider } from '@/lib/embeddings/types' import type { EmbeddingProvider } from '@/lib/internal/embeddings/schema' import { getEmbeddingModelPricing } from '@/providers/models' import type { EmbeddingsParams, EmbeddingsResponse } from '@/tools/embeddings/types' @@ -19,7 +19,7 @@ interface EmbeddingToolBaseOptions { } interface HostedEmbeddingToolOptions extends EmbeddingToolBaseOptions { - provider: EmbeddingCatalogProvider + provider: KeyedEmbeddingProvider envKeyPrefix: string defaultModel?: never } @@ -30,7 +30,21 @@ interface ExplicitKeyEmbeddingToolOptions extends EmbeddingToolBaseOptions { defaultModel: string } -type CreateEmbeddingToolOptions = HostedEmbeddingToolOptions | ExplicitKeyEmbeddingToolOptions +/** + * Ollama runs on the deployment's own server and authenticates with nothing, so + * it has neither a credential to collect nor a hosted key to meter. The model is + * whatever the operator pulled, so there is no default either. + */ +interface LocalEmbeddingToolOptions extends EmbeddingToolBaseOptions { + provider: Extract + envKeyPrefix?: never + defaultModel?: never +} + +type CreateEmbeddingToolOptions = + | HostedEmbeddingToolOptions + | ExplicitKeyEmbeddingToolOptions + | LocalEmbeddingToolOptions /** * Builds a provider-specific embeddings tool. Every provider shares the same @@ -41,39 +55,46 @@ export function createEmbeddingTool( options: CreateEmbeddingToolOptions ): InternalToolConfig { const { id, name, provider, description } = options + const isKeyless = provider === 'ollama' const defaultModel = - provider === 'openrouter' ? options.defaultModel : DEFAULT_MODEL_BY_PROVIDER[provider] + provider === 'openrouter' + ? options.defaultModel + : isKeyless + ? undefined + : DEFAULT_MODEL_BY_PROVIDER[provider] /** * Sim-hosted catalog providers are billed per input token with no markup. - * OpenRouter requires an explicit user key and therefore has no hosting config. + * OpenRouter requires an explicit user key and Ollama takes none at all, so + * neither has a hosting config — and neither has a Sim-funded key to meter. */ - const hostingConfig: InternalToolConfig['hosting'] = - provider === 'openrouter' - ? undefined - : { - envKeyPrefix: options.envKeyPrefix, - apiKeyParam: 'apiKey', - byokProviderId: BYOK_PROVIDER_IDS[provider], - pricing: { - type: 'custom', - getCost: (_params, output) => { - const tokens = output.__embeddingTokens - if (typeof tokens !== 'number' || Number.isNaN(tokens)) { - throw new Error('Embedding response missing token usage') - } - const model = typeof output.model === 'string' ? output.model : defaultModel - const pricing = getEmbeddingModelPricing(model) - if (!pricing) { - throw new Error(`No pricing configured for embedding model: ${model}`) - } - return { - cost: (tokens * pricing.input) / 1_000_000, - metadata: { model, totalTokens: tokens, inputPricePerMillion: pricing.input }, - } - }, - }, - rateLimit: HOSTED_KEY_RATE_LIMIT, - } + let hostingConfig: InternalToolConfig['hosting'] + if (provider !== 'openrouter' && provider !== 'ollama') { + const hostedDefaultModel = DEFAULT_MODEL_BY_PROVIDER[provider] + hostingConfig = { + envKeyPrefix: options.envKeyPrefix, + apiKeyParam: 'apiKey', + byokProviderId: BYOK_PROVIDER_IDS[provider], + pricing: { + type: 'custom', + getCost: (_params, output) => { + const tokens = output.__embeddingTokens + if (typeof tokens !== 'number' || Number.isNaN(tokens)) { + throw new Error('Embedding response missing token usage') + } + const model = typeof output.model === 'string' ? output.model : hostedDefaultModel + const pricing = getEmbeddingModelPricing(model) + if (!pricing) { + throw new Error(`No pricing configured for embedding model: ${model}`) + } + return { + cost: (tokens * pricing.input) / 1_000_000, + metadata: { model, totalTokens: tokens, inputPricePerMillion: pricing.input }, + } + }, + }, + rateLimit: HOSTED_KEY_RATE_LIMIT, + } + } return { id, @@ -90,30 +111,47 @@ export function createEmbeddingTool( }, model: { type: 'string', - required: false, + required: isKeyless, visibility: 'user-only', - description: 'Embedding model to use', - default: defaultModel, - }, - taskType: { - type: 'string', - required: false, - visibility: 'user-only', - description: - 'What the embedding is for, when the model supports task conditioning: document, query, similarity, classification, or clustering', - }, - dimensions: { - type: 'number', - required: false, - visibility: 'user-only', - description: 'Output dimensions, when the model supports truncation. Defaults to native.', - }, - apiKey: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'API key for the selected embedding provider', + description: isKeyless + ? 'Embedding model pulled on the configured Ollama server' + : 'Embedding model to use', + ...(defaultModel !== undefined ? { default: defaultModel } : {}), }, + /** + * Ollama's API accepts neither a task type nor a reduction, and its width + * is read off the server rather than requested, so the keyless tool + * declares neither — a parameter a tool ignores is worse than one it does + * not offer. + */ + ...(isKeyless + ? {} + : { + taskType: { + type: 'string' as const, + required: false, + visibility: 'user-only' as const, + description: + 'What the embedding is for, when the model supports task conditioning: document, query, similarity, classification, or clustering', + }, + dimensions: { + type: 'number' as const, + required: false, + visibility: 'user-only' as const, + description: + 'Output dimensions, when the model supports truncation. Defaults to native.', + }, + }), + ...(isKeyless + ? {} + : { + apiKey: { + type: 'string' as const, + required: true, + visibility: 'user-only' as const, + description: 'API key for the selected embedding provider', + }, + }), }, hosting: hostingConfig, diff --git a/apps/sim/tools/embeddings/index.ts b/apps/sim/tools/embeddings/index.ts index 81871fc1e60..8cd87d8bc77 100644 --- a/apps/sim/tools/embeddings/index.ts +++ b/apps/sim/tools/embeddings/index.ts @@ -2,6 +2,7 @@ export { embeddingsCohereTool } from '@/tools/embeddings/cohere' export { createEmbeddingTool } from '@/tools/embeddings/factory' export { embeddingsGeminiTool } from '@/tools/embeddings/gemini' export { embeddingsMistralTool } from '@/tools/embeddings/mistral' +export { embeddingsOllamaTool } from '@/tools/embeddings/ollama' export { embeddingsOpenAITool } from '@/tools/embeddings/openai' export { embeddingsOpenRouterTool } from '@/tools/embeddings/openrouter' export type { EmbeddingsParams, EmbeddingsResponse } from '@/tools/embeddings/types' diff --git a/apps/sim/tools/embeddings/ollama.ts b/apps/sim/tools/embeddings/ollama.ts new file mode 100644 index 00000000000..283d3a07e65 --- /dev/null +++ b/apps/sim/tools/embeddings/ollama.ts @@ -0,0 +1,8 @@ +import { createEmbeddingTool } from '@/tools/embeddings/factory' + +export const embeddingsOllamaTool = createEmbeddingTool({ + id: 'embeddings_ollama', + name: 'Ollama Embeddings', + provider: 'ollama', + description: 'Generate embeddings on a self-hosted Ollama server', +}) diff --git a/apps/sim/tools/error-extractors.test.ts b/apps/sim/tools/error-extractors.test.ts index 9959f8eb06d..29f2315bdf2 100644 --- a/apps/sim/tools/error-extractors.test.ts +++ b/apps/sim/tools/error-extractors.test.ts @@ -169,6 +169,53 @@ describe('Error Extractors', () => { }) describe('extractErrorMessage with explicit extractorId', () => { + it('formats QuickBooks faults with status guidance', () => { + const errorInfo: ErrorInfo = { + status: 401, + data: { + Fault: { + Error: [ + { + code: '3200', + Message: 'Authentication failed', + Detail: 'Token expired', + }, + ], + }, + }, + } + + expect(extractErrorMessage(errorInfo, ErrorExtractorId.QUICKBOOKS_FAULT)).toBe( + 'QuickBooks request failed with HTTP 401. Reconnect the QuickBooks credential. 3200: Authentication failed: Token expired' + ) + }) + + it('formats QuickBooks query faults nested under QueryResponse', () => { + const errorInfo: ErrorInfo = { + status: 400, + data: { + QueryResponse: { + Fault: { + Error: [{ code: '4000', Message: 'Bad query', Detail: 'Invalid field' }], + }, + }, + }, + } + + expect(extractErrorMessage(errorInfo, ErrorExtractorId.QUICKBOOKS_FAULT)).toBe( + 'QuickBooks request failed with HTTP 400. 4000: Bad query: Invalid field' + ) + }) + + it('does not claim non-QuickBooks payloads', () => { + expect( + extractErrorMessage( + { status: 400, data: { message: 'Unrelated provider error' } }, + ErrorExtractorId.QUICKBOOKS_FAULT + ) + ).toBe('Request failed with status 400') + }) + it('should use specified extractor directly (deterministic)', () => { const errorInfo: ErrorInfo = { status: 403, @@ -240,7 +287,9 @@ describe('Error Extractors', () => { it('should extract the domain error string', () => { const errorInfo: ErrorInfo = { status: 400, - data: { error: 'Invalid value for status. Allowed values are - START,STOPPED,PAUSED' }, + data: { + error: 'Invalid value for status. Allowed values are - START,STOPPED,PAUSED', + }, } expect(extractErrorMessage(errorInfo, ErrorExtractorId.SMARTLEAD_ERRORS)).toBe( diff --git a/apps/sim/tools/error-extractors.ts b/apps/sim/tools/error-extractors.ts index 449525e9f6b..a033d6acf67 100644 --- a/apps/sim/tools/error-extractors.ts +++ b/apps/sim/tools/error-extractors.ts @@ -20,6 +20,7 @@ */ import { parseGraphErrorFromData } from '@/tools/microsoft_excel/utils' +import { formatQuickBooksFaultDetail, sanitizeQuickBooksFaultData } from '@/tools/quickbooks/fault' export interface ErrorInfo { status?: number @@ -262,7 +263,10 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [ * appended to the message rather than dropped with the rest of the envelope. */ if (data.detail && typeof data.detail === 'object' && !Array.isArray(data.detail)) { - const detail = data.detail as { message?: unknown; enrichment_urn?: unknown } + const detail = data.detail as { + message?: unknown + enrichment_urn?: unknown + } const detailMessage = typeof detail.message === 'string' ? detail.message.trim() : '' const enrichmentUrn = typeof detail.enrichment_urn === 'string' ? detail.enrichment_urn.trim() : '' @@ -415,6 +419,35 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [ return typeof attr === 'string' && attr ? `${detail} (${attr})` : detail }, }, + { + id: 'quickbooks-fault', + description: 'QuickBooks Online Fault.Error[] responses with authentication and rate guidance', + examples: ['QuickBooks Online Accounting API'], + extract: (errorInfo) => { + const status = errorInfo?.status + const data = errorInfo?.data + const fault = + sanitizeQuickBooksFaultData(data) ?? + (data && typeof data === 'object' && !Array.isArray(data) + ? sanitizeQuickBooksFaultData((data as Record).QueryResponse) + : null) + if (!fault) return null + + const guidance = + status === 401 + ? 'Reconnect the QuickBooks credential.' + : status === 403 + ? 'Confirm the QuickBooks accounting scope and access to this company.' + : status === 429 + ? 'QuickBooks rate limit reached; retry after the indicated delay.' + : '' + const statusMessage = + typeof status === 'number' + ? `QuickBooks request failed with HTTP ${status}.` + : 'QuickBooks request failed.' + return [statusMessage, guidance, formatQuickBooksFaultDetail(fault)].filter(Boolean).join(' ') + }, + }, { id: 'prospeo-errors', description: 'Prospeo API error_code with optional filter_error and message details', @@ -592,6 +625,7 @@ export const ErrorExtractorId = { DYNATRACE_ERRORS: 'dynatrace-errors', SMARTLEAD_ERRORS: 'smartlead-errors', POSTHOG_ERRORS: 'posthog-errors', + QUICKBOOKS_FAULT: 'quickbooks-fault', PROSPEO_ERRORS: 'prospeo-errors', CRUNCHBASE_ERRORS: 'crunchbase-errors', PITCHBOOK_ERRORS: 'pitchbook-errors', diff --git a/apps/sim/tools/generated/tool-ids.ts b/apps/sim/tools/generated/tool-ids.ts index 6d97df92a01..e4f6f681ab3 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_query_execution","athena_create_named_query","athena_delete_named_query","athena_get_named_query","athena_get_query_execution","athena_get_query_results","athena_list_databases","athena_list_named_queries","athena_list_query_executions","athena_list_table_metadata","athena_start_query","athena_stop_query","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_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","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_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_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_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_role","iam_get_user","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","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_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","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_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","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_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","quiver_image_to_svg","quiver_list_models","quiver_text_to_svg","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_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_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_send","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_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","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_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_query_execution","athena_create_named_query","athena_delete_named_query","athena_get_named_query","athena_get_query_execution","athena_get_query_results","athena_list_databases","athena_list_named_queries","athena_list_query_executions","athena_list_table_metadata","athena_start_query","athena_stop_query","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_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_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_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_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","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_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_customer_payment","quickbooks_void_invoice","quiver_image_to_svg","quiver_list_models","quiver_text_to_svg","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_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_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_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 a6952f954fe..ec9d306adcd 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: , , , ,